Files
goodgo-platform/apps/web/app/(public)/page.tsx
Ho Ngoc Hai 5e44456d11 feat(search-frontend): add public landing page, search page with map view, filters, and property cards
- Create (public) route group with landing page (hero, featured listings, district links, stats, CTA)
- Create search page with filter sidebar, list/map/split view modes, URL-synced filters, pagination
- Build ListingMap component with CSS-based marker visualization and popup details
- Build FilterBar with transaction type, property type, city, price range, area, bedrooms filters
- Build PropertyCard and SearchResults components with responsive grid layout
- Update middleware to allow public access to / and /search routes
- Move dashboard home to /dashboard to avoid route conflict
- All content in Vietnamese, mobile responsive

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-08 02:02:42 +07:00

254 lines
10 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 * as React from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select } from '@/components/ui/select';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { PropertyCard } from '@/components/search/property-card';
import { listingsApi, type ListingDetail } from '@/lib/listings-api';
import { PROPERTY_TYPES, TRANSACTION_TYPES } from '@/lib/validations/listings';
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 },
];
const STATS = [
{ label: 'Tin đăng', value: '10,000+', icon: '🏠' },
{ label: 'Người dùng', value: '50,000+', icon: '👥' },
{ label: 'Giao dịch thành công', value: '2,000+', icon: '✅' },
{ label: 'Tỉnh thành', value: '63', icon: '📍' },
];
export default function LandingPage() {
const router = useRouter();
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);
React.useEffect(() => {
listingsApi
.search({ status: 'ACTIVE', limit: 6 })
.then((res) => setFeaturedListings(res.data))
.catch(() => {})
.finally(() => setLoadingFeatured(false));
}, []);
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ìm kiếm bất đng sản
<span className="text-primary"> hoàn hảo</span>
</h1>
<p className="mt-4 text-lg text-muted-foreground md:text-xl">
Nền tảng bất đng sản thông minh tại Việt Nam mua bán, cho thuê nhà đt dễ dàng
</p>
{/* Search Bar */}
<form onSubmit={handleSearch} className="mt-8">
<div className="mx-auto flex max-w-2xl flex-col gap-3 rounded-xl border bg-white p-3 shadow-lg sm:flex-row">
<Input
placeholder="Nhập khu vực, dự án, hoặc từ khóa..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="border-0 shadow-none focus-visible:ring-0"
/>
<div className="flex gap-2">
<Select
value={transactionType}
onChange={(e) => setTransactionType(e.target.value)}
className="w-32 shrink-0"
>
<option value="">Loại GD</option>
{TRANSACTION_TYPES.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</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"
>
<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ìm kiếm
</Button>
</div>
</div>
</form>
{/* Quick property type links */}
<div className="mt-6 flex flex-wrap justify-center gap-2">
{PROPERTY_TYPES.map((pt) => (
<Link
key={pt.value}
href={`/search?propertyType=${pt.value}`}
>
<Badge variant="outline" className="cursor-pointer px-3 py-1.5 text-sm hover:bg-accent">
{pt.label}
</Badge>
</Link>
))}
</div>
</div>
</div>
</section>
{/* Featured Listings */}
<section className="py-12 md:py-16">
<div className="mx-auto max-w-7xl px-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold md:text-3xl">Tin đăng nổi bật</h2>
<p className="mt-1 text-muted-foreground">
Khám phá các bất đng sản đưc quan tâm nhất
</p>
</div>
<Link href="/search">
<Button variant="outline">Xem tất cả</Button>
</Link>
</div>
{loadingFeatured ? (
<div className="mt-8 flex min-h-[300px] items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</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>Chưa tin đăng nổi bật</p>
</div>
)}
</div>
</section>
{/* Districts / Quick Links */}
<section className="bg-muted/40 py-12 md:py-16">
<div className="mx-auto max-w-7xl px-4">
<h2 className="text-2xl font-bold md:text-3xl">Khu vực nổi bật</h2>
<p className="mt-1 text-muted-foreground">
Tìm kiếm theo quận huyện phổ biến
</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">🏙</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 className="py-12 md:py-16">
<div className="mx-auto max-w-7xl px-4">
<div className="text-center">
<h2 className="text-2xl font-bold md:text-3xl">GoodGo trong số liệu</h2>
<p className="mt-1 text-muted-foreground">
Nền tảng bất đng sản đáng tin cậy tại Việt Nam
</p>
</div>
<div className="mt-8 grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
{STATS.map((stat) => (
<div
key={stat.label}
className="rounded-lg border bg-card p-6 text-center shadow-sm"
>
<span className="text-3xl">{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">{stat.label}</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">
Bạn bất đng sản muốn đăng?
</h2>
<p className="mt-2 text-primary-foreground/80">
Đăng tin miễn phí ngay hôm nay, tiếp cận hàng ngàn người mua tiềm năng
</p>
<div className="mt-6 flex justify-center gap-3">
<Link href="/register">
<Button
variant="secondary"
size="lg"
className="font-semibold"
>
Đăng miễn phí
</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ìm kiếm ngay
</Button>
</Link>
</div>
</div>
</section>
</div>
);
}