feat(web): redesign homepage with solutions showcase + tabbed featured section
Some checks failed
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 16s
CI / E2E Tests (push) Has been skipped
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 1m26s
Deploy / Build API Image (push) Failing after 24s
Deploy / Build Web Image (push) Failing after 12s
Deploy / Build AI Services Image (push) Failing after 9s
E2E Tests / Playwright E2E (push) Failing after 8s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 2s
Security Scanning / Trivy Scan — API Image (push) Failing after 1m8s
Deploy / Deploy to Staging (push) Has been cancelled
Deploy / Smoke Test Staging (push) Has been cancelled
Deploy / Rollback Staging (push) Has been cancelled
Deploy / Smoke Test Production (push) Has been cancelled
Deploy / Rollback Production (push) Has been cancelled
Deploy / Deploy to Production (push) Has been cancelled
Security Scanning / Trivy Scan — AI Services Image (push) Has been cancelled
Security Scanning / Trivy Filesystem Scan (push) Has been cancelled
Security Scanning / Security Gate (push) Has been cancelled
Security Scanning / Trivy Scan — Web Image (push) Has started running

- Add "Giải pháp GoodGo" section after hero with 4 feature cards
  linking to the platform's core products: Dự án, Khu công nghiệp,
  Chuyển nhượng, Định giá BĐS.
- Convert "Tin đăng nổi bật" from residential-only 3-column grid into a
  tabbed section with one tab per core feature. Items render as a
  vertical list of horizontal cards (image left, title/location/meta
  right, price + arrow). Valuation tab shows a highlight CTA since it's
  a tool, not a listing type.
- Remove "Khu vực nổi bật" district quick-links block (didn't fit the
  platform's multi-product positioning).
- Fix invisible "Tìm kiếm ngay" button on CTA section — outline variant
  defaulted to bg-background (white) masking text-primary-foreground
  (white) on the primary background.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ho Ngoc Hai
2026-04-18 21:52:36 +07:00
parent 312532b1cb
commit b4ef4fc81c
4 changed files with 306 additions and 87 deletions

View File

@@ -80,15 +80,6 @@ describe('LandingPage', () => {
});
});
it('renders districts section', async () => {
render(<LandingPage />);
await waitFor(() => {
expect(screen.getByText('Quận 1')).toBeInTheDocument();
expect(screen.getByText('Quận 7')).toBeInTheDocument();
});
});
it('renders stats section', async () => {
render(<LandingPage />);

View File

@@ -1,26 +1,35 @@
'use client';
import { Building2, CheckCircle2, Home, MapPin, Users, type LucideIcon } from 'lucide-react';
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 { PropertyCard } from '@/components/search/property-card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Select } from '@/components/ui/select';
import { Link, useRouter } from '@/i18n/navigation';
import { listingsApi, type ListingDetail } from '@/lib/listings-api';
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';
const DISTRICTS = [
{ name: 'Quận 1', city: 'Hồ Chí Minh', img: null },
{ name: 'Quận 2', city: 'Hồ Chí Minh', img: null },
{ name: 'Quận 7', city: 'Hồ Chí Minh', img: null },
{ name: 'Bình Thạnh', city: 'Hồ Chí Minh', img: null },
{ name: 'Thủ Đức', city: 'Hồ Chí Minh', img: null },
{ name: 'Ba Đình', city: 'Hà Nội', img: null },
{ name: 'Hoàn Kiếm', city: 'Hà Nội', img: null },
{ name: 'Hải Châu', city: 'Đà Nẵng', img: null },
type FeatureKey = 'projects' | 'industrial' | 'transfer' | 'valuation';
const FEATURES: { key: FeatureKey; href: string; icon: LucideIcon }[] = [
{ 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';
@@ -35,29 +44,108 @@ const STATS: { key: StatKey; value: string; icon: LucideIcon }[] = [
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> = {
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 [featuredListings, setFeaturedListings] = React.useState<ListingDetail[]>([]);
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 [loadingFeatured, setLoadingFeatured] = React.useState(true);
const [featuredError, setFeaturedError] = React.useState(false);
const fetchFeatured = React.useCallback(() => {
const fetchFeatured = React.useCallback((feature: FeatureKey) => {
if (feature === 'valuation') {
setLoadingFeatured(false);
setFeaturedError(false);
return;
}
setLoadingFeatured(true);
setFeaturedError(false);
listingsApi
.search({ status: 'ACTIVE', limit: 6 })
.then((res) => setFeaturedListings(res.data))
const request =
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();
}, [fetchFeatured]);
fetchFeatured(activeFeature);
}, [activeFeature, fetchFeatured]);
const featuredItems: FeaturedItem[] = React.useMemo(() => {
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}` : null, `${tr.itemCount} món`].filter(Boolean) as string[],
}));
}
return [];
}, [activeFeature, projects, parks, transfers]);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
@@ -144,75 +232,145 @@ export default function LandingPage() {
</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-4">
{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 items-center justify-between">
<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="/search">
<Link href={VIEW_ALL_HREFS[activeFeature]}>
<Button variant="outline">{t('landing.viewAll')}</Button>
</Link>
</div>
{loadingFeatured ? (
<div className="mt-8 flex min-h-[300px] 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="mt-8 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}>
{t('common.retry')}
</Button>
</div>
) : featuredListings.length > 0 ? (
<div className="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{featuredListings.map((listing) => (
<PropertyCard key={listing.id} listing={listing} />
))}
</div>
) : (
<div className="mt-8 flex min-h-[200px] items-center justify-center text-muted-foreground">
<p>{t('landing.noFeatured')}</p>
</div>
)}
</div>
</section>
{/* Districts / Quick Links */}
<section aria-labelledby="districts-heading" className="bg-muted/40 py-12 md:py-16">
<div className="mx-auto max-w-7xl px-4">
<h2 id="districts-heading" className="text-2xl font-bold md:text-3xl">{t('landing.districtsTitle')}</h2>
<p className="mt-1 text-muted-foreground">
{t('landing.districtsSubtitle')}
</p>
<div className="mt-8 grid gap-3 sm:grid-cols-2 md:grid-cols-4">
{DISTRICTS.map((district) => (
<Link
key={`${district.name}-${district.city}`}
href={`/search?district=${encodeURIComponent(district.name)}&city=${encodeURIComponent(district.city)}`}
{/* 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'
}`}
>
<Card className="group cursor-pointer overflow-hidden transition-shadow hover:shadow-md">
<div className="aspect-[16/9] bg-gradient-to-br from-primary/10 to-primary/5">
<div className="flex h-full items-center justify-center">
<Building2 className="h-8 w-8 text-primary" aria-hidden="true" />
</div>
</div>
<CardContent className="p-3">
<p className="font-medium group-hover:text-primary">{district.name}</p>
<p className="text-xs text-muted-foreground">{district.city}</p>
</CardContent>
</Card>
</Link>
<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>
@@ -264,7 +422,7 @@ export default function LandingPage() {
<Button
variant="outline"
size="lg"
className="border-primary-foreground/30 text-primary-foreground hover:bg-primary-foreground/10 hover:text-primary-foreground"
className="border-primary-foreground/40 bg-transparent text-primary-foreground hover:bg-primary-foreground/10 hover:text-primary-foreground"
>
{t('landing.searchNow')}
</Button>
@@ -275,3 +433,35 @@ export default function LandingPage() {
</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>
);
}