Some checks failed
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 1m15s
Deploy / Build API Image (push) Failing after 20s
Deploy / Build AI Services Image (push) Failing after 12s
E2E Tests / Playwright E2E (push) Failing after 16s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Deploy to Production (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 35s
Security Scanning / Trivy Filesystem Scan (push) Failing after 30s
Backup Verification / Backup Restore Verification (push) Failing after 14m37s
Security Scanning / Trivy Scan — API Image (push) Failing after 1m4s
Security Scanning / Trivy Scan — Web Image (push) Failing after 36s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 11m6s
Deploy / Build Web Image (push) Failing after 12s
Deploy / Smoke Test Staging (push) Has been skipped
Deploy / Rollback Staging (push) Has been skipped
Deploy / Rollback Production (push) Has been skipped
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 8s
CI / E2E Tests (push) Has been skipped
Security Scanning / Security Gate (push) Has been cancelled
Backend — DELETE endpoints (hard delete, ADMIN or owner):
- DELETE /projects/:id (Admin) — new DeleteProjectCommand/Handler,
repository.delete() adapter, module wiring.
- DELETE /industrial/parks/:id (Admin) — same pattern.
- DELETE /listings/:id (JWT + owner-or-Admin check in handler).
Frontend — API clients:
- lib/du-an-api.ts: add create/update/delete + CreateProjectPayload,
UpdateProjectPayload types.
- lib/khu-cong-nghiep-api.ts: add createPark/updatePark/deletePark +
Create/Update payload types.
- lib/listings-api.ts: add delete().
Dashboard pages — new:
- /projects (Quản lý dự án): list with filters + edit/delete actions,
/projects/new form (sectioned Cards, zod-validated), /projects/[id]/edit
with danger-zone delete.
- /industrial-parks (Quản lý KCN): same triad. Fix occupancy-rate display
(percentage already 0-100, no need to *100).
Dashboard listings page:
- Add Edit/Delete row actions with confirm + useMutation; error banner
on mutation failure. Table view gains a "Thao tác" column; list view
gains a footer action bar below each card.
Dashboard nav:
- Catalog group: /du-an → /projects (Quản lý dự án), /khu-cong-nghiep
→ /industrial-parks (Quản lý KCN). Desktop primaryNav updated too.
Public homepage:
- Add "Bất động sản" as a 5th feature card/tab → /search, using
listingsApi for the "Featured listings" section.
- Bump grid to lg:grid-cols-5, update features subtitle copy ("Năm/Five
core services").
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
490 lines
20 KiB
TypeScript
490 lines
20 KiB
TypeScript
'use client';
|
|
|
|
import {
|
|
ArrowRight,
|
|
ArrowRightLeft,
|
|
Building2,
|
|
Calculator,
|
|
CheckCircle2,
|
|
Factory,
|
|
Home,
|
|
MapPin,
|
|
Users,
|
|
type LucideIcon,
|
|
} from 'lucide-react';
|
|
import { useTranslations } from 'next-intl';
|
|
import * as React from 'react';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Select } from '@/components/ui/select';
|
|
import { Link, useRouter } from '@/i18n/navigation';
|
|
import { transferApi, type TransferListingListItem } from '@/lib/chuyen-nhuong-api';
|
|
import { duAnApi, type ProjectSummary } from '@/lib/du-an-api';
|
|
import { industrialApi, type IndustrialParkListItem } from '@/lib/khu-cong-nghiep-api';
|
|
import { listingsApi, type ListingDetail } from '@/lib/listings-api';
|
|
|
|
type FeatureKey = 'listings' | 'projects' | 'industrial' | 'transfer' | 'valuation';
|
|
|
|
const FEATURES: { key: FeatureKey; href: string; icon: LucideIcon }[] = [
|
|
{ key: 'listings', href: '/search', icon: Home },
|
|
{ key: 'projects', href: '/du-an', icon: Building2 },
|
|
{ key: 'industrial', href: '/khu-cong-nghiep', icon: Factory },
|
|
{ key: 'transfer', href: '/chuyen-nhuong', icon: ArrowRightLeft },
|
|
{ key: 'valuation', href: '/dashboard/valuation', icon: Calculator },
|
|
];
|
|
|
|
type StatKey = 'listings' | 'users' | 'transactions' | 'provinces';
|
|
|
|
const STATS: { key: StatKey; value: string; icon: LucideIcon }[] = [
|
|
{ key: 'listings', value: '10,000+', icon: Home },
|
|
{ key: 'users', value: '50,000+', icon: Users },
|
|
{ key: 'transactions', value: '2,000+', icon: CheckCircle2 },
|
|
{ key: 'provinces', value: '63', icon: MapPin },
|
|
];
|
|
|
|
const PROPERTY_TYPE_KEYS = ['APARTMENT', 'HOUSE', 'VILLA', 'LAND', 'OFFICE', 'SHOPHOUSE'] as const;
|
|
const TRANSACTION_TYPE_KEYS = ['SALE', 'RENT'] as const;
|
|
|
|
type FeaturedItem = {
|
|
id: string;
|
|
href: string;
|
|
imageUrl: string | null;
|
|
fallbackIcon: LucideIcon;
|
|
title: string;
|
|
location: string;
|
|
priceLabel: string;
|
|
meta: string[];
|
|
};
|
|
|
|
const VIEW_ALL_HREFS: Record<FeatureKey, string> = {
|
|
listings: '/search',
|
|
projects: '/du-an',
|
|
industrial: '/khu-cong-nghiep',
|
|
transfer: '/chuyen-nhuong',
|
|
valuation: '/dashboard/valuation',
|
|
};
|
|
|
|
function formatVND(value: string | number | null | undefined): string {
|
|
if (value == null) return '—';
|
|
const num = typeof value === 'string' ? Number(value) : value;
|
|
if (!Number.isFinite(num) || num <= 0) return '—';
|
|
if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(1)} tỷ`;
|
|
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(0)} triệu`;
|
|
return num.toLocaleString('vi-VN');
|
|
}
|
|
|
|
export default function LandingPage() {
|
|
const router = useRouter();
|
|
const t = useTranslations();
|
|
const [searchQuery, setSearchQuery] = React.useState('');
|
|
const [transactionType, setTransactionType] = React.useState('');
|
|
const [propertyType, _setPropertyType] = React.useState('');
|
|
const [activeFeature, setActiveFeature] = React.useState<FeatureKey>('projects');
|
|
const [projects, setProjects] = React.useState<ProjectSummary[]>([]);
|
|
const [parks, setParks] = React.useState<IndustrialParkListItem[]>([]);
|
|
const [transfers, setTransfers] = React.useState<TransferListingListItem[]>([]);
|
|
const [listings, setListings] = React.useState<ListingDetail[]>([]);
|
|
const [loadingFeatured, setLoadingFeatured] = React.useState(true);
|
|
const [featuredError, setFeaturedError] = React.useState(false);
|
|
|
|
const fetchFeatured = React.useCallback((feature: FeatureKey) => {
|
|
if (feature === 'valuation') {
|
|
setLoadingFeatured(false);
|
|
setFeaturedError(false);
|
|
return;
|
|
}
|
|
setLoadingFeatured(true);
|
|
setFeaturedError(false);
|
|
const request =
|
|
feature === 'listings'
|
|
? listingsApi.search({ limit: 4, status: 'ACTIVE' }).then((res) => setListings(res.data))
|
|
: feature === 'projects'
|
|
? duAnApi.search({ limit: 4 }).then((res) => setProjects(res.data))
|
|
: feature === 'industrial'
|
|
? industrialApi.search({ limit: 4 }).then((res) => setParks(res.data))
|
|
: transferApi.search({ limit: 4 }).then((res) => setTransfers(res.data));
|
|
request
|
|
.catch(() => setFeaturedError(true))
|
|
.finally(() => setLoadingFeatured(false));
|
|
}, []);
|
|
|
|
React.useEffect(() => {
|
|
fetchFeatured(activeFeature);
|
|
}, [activeFeature, fetchFeatured]);
|
|
|
|
const featuredItems: FeaturedItem[] = React.useMemo(() => {
|
|
if (activeFeature === 'listings') {
|
|
return listings.map((l) => ({
|
|
id: l.id,
|
|
href: `/listings/${l.id}`,
|
|
imageUrl: l.property.media?.[0]?.url ?? null,
|
|
fallbackIcon: Home,
|
|
title: l.property.title,
|
|
location: `${l.property.district}, ${l.property.city}`,
|
|
priceLabel: `${formatVND(l.priceVND)} VNĐ`,
|
|
meta: [
|
|
`${l.property.areaM2} m²`,
|
|
l.property.bedrooms != null ? `${l.property.bedrooms} PN` : null,
|
|
l.transactionType === 'SALE' ? 'Bán' : 'Cho thuê',
|
|
].filter(Boolean) as string[],
|
|
}));
|
|
}
|
|
if (activeFeature === 'projects') {
|
|
return projects.map((p) => ({
|
|
id: p.id,
|
|
href: `/du-an/${p.slug}`,
|
|
imageUrl: p.thumbnailUrl,
|
|
fallbackIcon: Building2,
|
|
title: p.name,
|
|
location: `${p.district}, ${p.city}`,
|
|
priceLabel: p.minPrice ? `Từ ${formatVND(p.minPrice)} VNĐ` : '—',
|
|
meta: [p.developer.name, `${p.totalUnits} căn`].filter(Boolean) as string[],
|
|
}));
|
|
}
|
|
if (activeFeature === 'industrial') {
|
|
return parks.map((k) => ({
|
|
id: k.id,
|
|
href: `/khu-cong-nghiep/${k.slug}`,
|
|
imageUrl: null,
|
|
fallbackIcon: Factory,
|
|
title: k.name,
|
|
location: k.province,
|
|
priceLabel: k.landRentUsdM2Year ? `${k.landRentUsdM2Year} USD/m²/năm` : '—',
|
|
meta: [`${k.totalAreaHa} ha`, `Lấp đầy ${Math.round(k.occupancyRate)}%`],
|
|
}));
|
|
}
|
|
if (activeFeature === 'transfer') {
|
|
return transfers.map((tr) => ({
|
|
id: tr.id,
|
|
href: `/chuyen-nhuong/${tr.id}`,
|
|
imageUrl: tr.media?.[0]?.url ?? null,
|
|
fallbackIcon: ArrowRightLeft,
|
|
title: tr.title,
|
|
location: `${tr.district}, ${tr.city}`,
|
|
priceLabel: `${formatVND(tr.askingPriceVND)} VNĐ`,
|
|
meta: [tr.areaM2 ? `${tr.areaM2} m²` : null, `${tr.itemCount} món`].filter(Boolean) as string[],
|
|
}));
|
|
}
|
|
return [];
|
|
}, [activeFeature, projects, parks, transfers, listings]);
|
|
|
|
const handleSearch = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
const params = new URLSearchParams();
|
|
if (searchQuery) params.set('q', searchQuery);
|
|
if (transactionType) params.set('transactionType', transactionType);
|
|
if (propertyType) params.set('propertyType', propertyType);
|
|
router.push(`/search?${params.toString()}`);
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
{/* Hero Section */}
|
|
<section className="relative bg-gradient-to-br from-primary/5 via-background to-primary/10 py-16 md:py-24">
|
|
<div className="mx-auto max-w-7xl px-4">
|
|
<div className="mx-auto max-w-3xl text-center">
|
|
<h1 className="text-4xl font-bold tracking-tight md:text-5xl lg:text-6xl">
|
|
{t('landing.heroTitle')}
|
|
<span className="text-primary"> {t('landing.heroTitleHighlight')}</span>
|
|
</h1>
|
|
<p className="mt-4 text-lg text-muted-foreground md:text-xl">
|
|
{t('landing.heroSubtitle')}
|
|
</p>
|
|
|
|
{/* Search Bar */}
|
|
<form onSubmit={handleSearch} className="mt-8" role="search" aria-label={t('common.search')}>
|
|
<div className="mx-auto flex max-w-2xl flex-col gap-3 rounded-xl border bg-white p-3 shadow-lg dark:bg-background sm:flex-row">
|
|
<Input
|
|
placeholder={t('landing.searchPlaceholder')}
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="border-0 shadow-none focus-visible:ring-0"
|
|
aria-label={t('landing.searchPlaceholder')}
|
|
/>
|
|
<div className="flex gap-2">
|
|
<Select
|
|
value={transactionType}
|
|
onChange={(e) => setTransactionType(e.target.value)}
|
|
className="w-32 shrink-0"
|
|
aria-label={t('landing.transactionTypeLabel')}
|
|
>
|
|
<option value="">{t('landing.transactionTypeLabel')}</option>
|
|
{TRANSACTION_TYPE_KEYS.map((key) => (
|
|
<option key={key} value={key}>
|
|
{t(`transactionTypes.${key}`)}
|
|
</option>
|
|
))}
|
|
</Select>
|
|
<Button type="submit" className="shrink-0 px-6">
|
|
<svg
|
|
className="mr-2 h-4 w-4"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
aria-hidden="true"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
|
/>
|
|
</svg>
|
|
{t('common.search')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
|
|
{/* Quick property type links */}
|
|
<div className="mt-6 flex flex-wrap justify-center gap-2">
|
|
{PROPERTY_TYPE_KEYS.map((key) => (
|
|
<Link
|
|
key={key}
|
|
href={`/search?propertyType=${key}`}
|
|
>
|
|
<Badge variant="outline" className="cursor-pointer px-3 py-1.5 text-sm hover:bg-accent">
|
|
{t(`propertyTypes.${key}`)}
|
|
</Badge>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{/* Core Features */}
|
|
<section aria-labelledby="features-heading" className="border-b bg-muted/30 py-12 md:py-16">
|
|
<div className="mx-auto max-w-7xl px-4">
|
|
<div className="text-center">
|
|
<h2 id="features-heading" className="text-2xl font-bold md:text-3xl">
|
|
{t('landing.featuresTitle')}
|
|
</h2>
|
|
<p className="mt-1 text-muted-foreground">
|
|
{t('landing.featuresSubtitle')}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
|
{FEATURES.map((feature) => (
|
|
<Link key={feature.key} href={feature.href} className="group">
|
|
<div className="flex h-full flex-col rounded-xl border bg-card p-6 shadow-sm transition-all hover:-translate-y-0.5 hover:border-primary/40 hover:shadow-md">
|
|
<div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10 text-primary transition-colors group-hover:bg-primary group-hover:text-primary-foreground">
|
|
<feature.icon className="h-6 w-6" aria-hidden="true" />
|
|
</div>
|
|
<h3 className="text-lg font-semibold">
|
|
{t(`landing.features.${feature.key}.title`)}
|
|
</h3>
|
|
<p className="mt-2 flex-1 text-sm text-muted-foreground">
|
|
{t(`landing.features.${feature.key}.description`)}
|
|
</p>
|
|
<span className="mt-4 inline-flex items-center gap-1 text-sm font-medium text-primary">
|
|
{t('landing.features.explore')}
|
|
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" aria-hidden="true" />
|
|
</span>
|
|
</div>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{/* Featured Listings */}
|
|
<section aria-labelledby="featured-heading" className="py-12 md:py-16">
|
|
<div className="mx-auto max-w-7xl px-4">
|
|
<div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
|
<div>
|
|
<h2 id="featured-heading" className="text-2xl font-bold md:text-3xl">{t('landing.featuredTitle')}</h2>
|
|
<p className="mt-1 text-muted-foreground">
|
|
{t('landing.featuredSubtitle')}
|
|
</p>
|
|
</div>
|
|
<Link href={VIEW_ALL_HREFS[activeFeature]}>
|
|
<Button variant="outline">{t('landing.viewAll')}</Button>
|
|
</Link>
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<div role="tablist" aria-label={t('landing.featuredTitle')} className="mt-6 flex flex-wrap gap-2 border-b">
|
|
{FEATURES.map((feature) => (
|
|
<button
|
|
key={feature.key}
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={activeFeature === feature.key}
|
|
onClick={() => setActiveFeature(feature.key)}
|
|
className={`-mb-px inline-flex items-center gap-2 border-b-2 px-4 py-3 text-sm font-medium transition-colors ${
|
|
activeFeature === feature.key
|
|
? 'border-primary text-primary'
|
|
: 'border-transparent text-muted-foreground hover:text-foreground'
|
|
}`}
|
|
>
|
|
<feature.icon className="h-4 w-4" aria-hidden="true" />
|
|
{t(`landing.features.${feature.key}.title`)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* List */}
|
|
<div className="mt-6">
|
|
{activeFeature === 'valuation' ? (
|
|
<ValuationHighlight tReady={t('landing.features.valuation.title')} tDesc={t('landing.features.valuation.description')} tExplore={t('landing.features.explore')} />
|
|
) : loadingFeatured ? (
|
|
<div className="flex min-h-[240px] items-center justify-center" role="status" aria-label={t('common.loading')}>
|
|
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" aria-hidden="true" />
|
|
<span className="sr-only">{t('common.loading')}</span>
|
|
</div>
|
|
) : featuredError ? (
|
|
<div className="flex min-h-[200px] flex-col items-center justify-center gap-3 text-muted-foreground" role="alert">
|
|
<p>{t('landing.loadError')}</p>
|
|
<Button variant="outline" size="sm" onClick={() => fetchFeatured(activeFeature)}>
|
|
{t('common.retry')}
|
|
</Button>
|
|
</div>
|
|
) : featuredItems.length > 0 ? (
|
|
<ul className="flex flex-col gap-3">
|
|
{featuredItems.map((item) => (
|
|
<li key={item.id}>
|
|
<Link
|
|
href={item.href}
|
|
className="group flex gap-4 rounded-xl border bg-card p-3 shadow-sm transition-all hover:border-primary/40 hover:shadow-md"
|
|
>
|
|
<div className="relative h-24 w-32 shrink-0 overflow-hidden rounded-lg bg-muted sm:h-28 sm:w-44">
|
|
{item.imageUrl ? (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img
|
|
src={item.imageUrl}
|
|
alt={item.title}
|
|
className="h-full w-full object-cover transition-transform group-hover:scale-105"
|
|
/>
|
|
) : (
|
|
<div className="flex h-full w-full items-center justify-center bg-gradient-to-br from-primary/10 to-primary/5 text-primary">
|
|
<item.fallbackIcon className="h-8 w-8" aria-hidden="true" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
|
<h3 className="truncate text-base font-semibold group-hover:text-primary">
|
|
{item.title}
|
|
</h3>
|
|
<p className="inline-flex items-center gap-1 text-sm text-muted-foreground">
|
|
<MapPin className="h-3.5 w-3.5" aria-hidden="true" />
|
|
<span className="truncate">{item.location}</span>
|
|
</p>
|
|
{item.meta.length > 0 ? (
|
|
<p className="text-xs text-muted-foreground">
|
|
{item.meta.join(' • ')}
|
|
</p>
|
|
) : null}
|
|
<p className="mt-auto text-sm font-semibold text-primary">{item.priceLabel}</p>
|
|
</div>
|
|
<ArrowRight
|
|
className="mt-auto hidden h-5 w-5 shrink-0 text-muted-foreground transition-all group-hover:translate-x-0.5 group-hover:text-primary sm:block"
|
|
aria-hidden="true"
|
|
/>
|
|
</Link>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
) : (
|
|
<div className="flex min-h-[200px] items-center justify-center text-muted-foreground">
|
|
<p>{t('landing.noFeatured')}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{/* Market Stats */}
|
|
<section aria-labelledby="stats-heading" className="py-12 md:py-16">
|
|
<div className="mx-auto max-w-7xl px-4">
|
|
<div className="text-center">
|
|
<h2 id="stats-heading" className="text-2xl font-bold md:text-3xl">{t('landing.statsTitle')}</h2>
|
|
<p className="mt-1 text-muted-foreground">
|
|
{t('landing.statsSubtitle')}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="mt-8 grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
|
{STATS.map((stat) => (
|
|
<div
|
|
key={stat.key}
|
|
className="rounded-lg border bg-card p-6 text-center shadow-sm"
|
|
>
|
|
<stat.icon className="h-8 w-8 text-primary" aria-hidden="true" />
|
|
<p className="mt-2 text-3xl font-bold text-primary">{stat.value}</p>
|
|
<p className="mt-1 text-sm text-muted-foreground">{t(`stats.${stat.key}`)}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{/* CTA Section */}
|
|
<section className="bg-primary py-12 md:py-16">
|
|
<div className="mx-auto max-w-7xl px-4 text-center">
|
|
<h2 className="text-2xl font-bold text-primary-foreground md:text-3xl">
|
|
{t('landing.ctaTitle')}
|
|
</h2>
|
|
<p className="mt-2 text-primary-foreground/80">
|
|
{t('landing.ctaSubtitle')}
|
|
</p>
|
|
<div className="mt-6 flex justify-center gap-3">
|
|
<Link href="/register">
|
|
<Button
|
|
variant="secondary"
|
|
size="lg"
|
|
className="font-semibold"
|
|
>
|
|
{t('landing.registerFree')}
|
|
</Button>
|
|
</Link>
|
|
<Link href="/search">
|
|
<Button
|
|
variant="outline"
|
|
size="lg"
|
|
className="border-primary-foreground/40 bg-transparent text-primary-foreground hover:bg-primary-foreground/10 hover:text-primary-foreground"
|
|
>
|
|
{t('landing.searchNow')}
|
|
</Button>
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ValuationHighlight({
|
|
tReady,
|
|
tDesc,
|
|
tExplore,
|
|
}: {
|
|
tReady: string;
|
|
tDesc: string;
|
|
tExplore: string;
|
|
}) {
|
|
return (
|
|
<div className="overflow-hidden rounded-xl border bg-gradient-to-br from-primary/10 via-card to-card p-6 md:p-8">
|
|
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
|
<div className="flex items-start gap-4">
|
|
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground">
|
|
<Calculator className="h-6 w-6" aria-hidden="true" />
|
|
</div>
|
|
<div>
|
|
<h3 className="text-xl font-semibold">{tReady}</h3>
|
|
<p className="mt-1 text-sm text-muted-foreground md:max-w-xl">{tDesc}</p>
|
|
</div>
|
|
</div>
|
|
<Link href="/dashboard/valuation">
|
|
<Button className="inline-flex items-center gap-2">
|
|
{tExplore}
|
|
<ArrowRight className="h-4 w-4" aria-hidden="true" />
|
|
</Button>
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|