Files
goodgo-platform/apps/web/app/[locale]/(public)/page.tsx
Ho Ngoc Hai 7195064f12 feat(web): add i18n locale routes and language switcher component
Add locale-prefixed routes for admin, auth, dashboard, and public pages.
Add error, loading, and not-found pages for locale context. Add language
switcher UI component for Vietnamese/English toggle.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-09 09:44:18 +07:00

277 lines
11 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
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';
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 StatKey = 'listings' | 'users' | 'transactions' | 'provinces';
const STATS: { key: StatKey; value: string; icon: string }[] = [
{ key: 'listings', value: '10,000+', icon: '🏠' },
{ key: 'users', value: '50,000+', icon: '👥' },
{ key: 'transactions', value: '2,000+', icon: '✅' },
{ key: 'provinces', value: '63', icon: '📍' },
];
const PROPERTY_TYPE_KEYS = ['APARTMENT', 'HOUSE', 'VILLA', 'LAND', 'OFFICE', 'SHOPHOUSE'] as const;
const TRANSACTION_TYPE_KEYS = ['SALE', 'RENT'] as const;
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 [loadingFeatured, setLoadingFeatured] = React.useState(true);
const [featuredError, setFeaturedError] = React.useState(false);
const fetchFeatured = React.useCallback(() => {
setLoadingFeatured(true);
setFeaturedError(false);
listingsApi
.search({ status: 'ACTIVE', limit: 6 })
.then((res) => setFeaturedListings(res.data))
.catch(() => setFeaturedError(true))
.finally(() => setLoadingFeatured(false));
}, []);
React.useEffect(() => {
fetchFeatured();
}, [fetchFeatured]);
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>
{/* 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>
<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">
<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)}`}
>
<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">
<span className="text-3xl" aria-hidden="true">🏙</span>
</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>
))}
</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"
>
<span className="text-3xl" aria-hidden="true">{stat.icon}</span>
<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/30 text-primary-foreground hover:bg-primary-foreground/10 hover:text-primary-foreground"
>
{t('landing.searchNow')}
</Button>
</Link>
</div>
</div>
</section>
</div>
);
}