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>
This commit is contained in:
125
apps/web/app/[locale]/(public)/layout.tsx
Normal file
125
apps/web/app/[locale]/(public)/layout.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
'use client';
|
||||
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { LanguageSwitcher } from '@/components/ui/language-switcher';
|
||||
import { Link } from '@/i18n/navigation';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export default function PublicLayout({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const { user } = useAuthStore();
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<header
|
||||
role="banner"
|
||||
className="sticky top-0 z-50 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"
|
||||
>
|
||||
<div className="mx-auto flex h-14 max-w-7xl items-center px-4">
|
||||
<Link href="/" className="mr-6 flex items-center space-x-2">
|
||||
<span className="text-lg font-bold text-primary">{t('common.goodgo')}</span>
|
||||
</Link>
|
||||
|
||||
<nav aria-label={t('nav.mainNav')} className="flex items-center space-x-1">
|
||||
<Link
|
||||
href="/"
|
||||
className={cn(
|
||||
'rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground',
|
||||
pathname === '/' || pathname.match(/^\/(vi|en)\/?$/)
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{t('nav.home')}
|
||||
</Link>
|
||||
<Link
|
||||
href="/search"
|
||||
className={cn(
|
||||
'rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground',
|
||||
pathname.includes('/search')
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{t('nav.search')}
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
<div className="ml-auto flex items-center space-x-2">
|
||||
<LanguageSwitcher />
|
||||
{user ? (
|
||||
<>
|
||||
<span className="hidden text-sm text-muted-foreground sm:inline">
|
||||
{user.fullName}
|
||||
</span>
|
||||
<Link href="/dashboard">
|
||||
<Button size="sm">{t('common.dashboard')}</Button>
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link href="/login">
|
||||
<Button variant="ghost" size="sm">
|
||||
{t('common.login')}
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/register">
|
||||
<Button size="sm">{t('common.register')}</Button>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="main-content" role="main">
|
||||
{children}
|
||||
</main>
|
||||
|
||||
<footer role="contentinfo" className="border-t bg-muted/40">
|
||||
<div className="mx-auto max-w-7xl px-4 py-8">
|
||||
<div className="grid gap-8 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold">{t('common.goodgo')}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('footer.description')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold">{t('footer.propertyTypes')}</h3>
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
<li><Link href="/search?propertyType=APARTMENT" className="hover:text-foreground">{t('propertyTypes.APARTMENT')}</Link></li>
|
||||
<li><Link href="/search?propertyType=HOUSE" className="hover:text-foreground">{t('propertyTypes.HOUSE')}</Link></li>
|
||||
<li><Link href="/search?propertyType=VILLA" className="hover:text-foreground">{t('propertyTypes.VILLA')}</Link></li>
|
||||
<li><Link href="/search?propertyType=LAND" className="hover:text-foreground">{t('propertyTypes.LAND')}</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold">{t('footer.areas')}</h3>
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
<li><Link href="/search?city=Hồ Chí Minh" className="hover:text-foreground">TP. Hồ Chí Minh</Link></li>
|
||||
<li><Link href="/search?city=Hà Nội" className="hover:text-foreground">Hà Nội</Link></li>
|
||||
<li><Link href="/search?city=Đà Nẵng" className="hover:text-foreground">Đà Nẵng</Link></li>
|
||||
<li><Link href="/search?city=Nha Trang" className="hover:text-foreground">Nha Trang</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold">{t('footer.support')}</h3>
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
<li><Link href="/login" className="hover:text-foreground">{t('common.login')}</Link></li>
|
||||
<li><Link href="/register" className="hover:text-foreground">{t('common.register')}</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 border-t pt-4 text-center text-sm text-muted-foreground">
|
||||
{t('common.allRightsReserved')}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
349
apps/web/app/[locale]/(public)/listings/[id]/page.tsx
Normal file
349
apps/web/app/[locale]/(public)/listings/[id]/page.tsx
Normal file
@@ -0,0 +1,349 @@
|
||||
'use client';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import * as React from 'react';
|
||||
import { ImageGallery } from '@/components/listings/image-gallery';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { AiEstimateButton } from '@/components/valuation/ai-estimate-button';
|
||||
import { listingsApi, type ListingDetail } from '@/lib/listings-api';
|
||||
import { PROPERTY_TYPES, DIRECTIONS, TRANSACTION_TYPES } from '@/lib/validations/listings';
|
||||
|
||||
const ListingMap = dynamic(
|
||||
() => import('@/components/map/listing-map').then((mod) => mod.ListingMap),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="flex h-[300px] items-center justify-center rounded-lg bg-muted">
|
||||
<p className="text-sm text-muted-foreground">Đang tải bản đồ...</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
function formatPrice(priceVND: string): string {
|
||||
const num = Number(priceVND);
|
||||
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');
|
||||
}
|
||||
|
||||
function getLabel(list: readonly { value: string; label: string }[], value: string | null) {
|
||||
if (!value) return null;
|
||||
return list.find((item) => item.value === value)?.label ?? value;
|
||||
}
|
||||
|
||||
export default function PublicListingDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [listing, setListing] = React.useState<ListingDetail | null>(null);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
listingsApi
|
||||
.getById(id)
|
||||
.then(setListing)
|
||||
.catch((err) => setError(err instanceof Error ? err.message : 'Không tải được tin đăng'))
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-4 py-8">
|
||||
{/* Skeleton loader */}
|
||||
<div className="animate-pulse space-y-6">
|
||||
<div className="h-8 w-2/3 rounded bg-muted" />
|
||||
<div className="aspect-video rounded-lg bg-muted" />
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<div className="space-y-4 lg:col-span-2">
|
||||
<div className="h-40 rounded-lg bg-muted" />
|
||||
<div className="h-32 rounded-lg bg-muted" />
|
||||
</div>
|
||||
<div className="h-48 rounded-lg bg-muted" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !listing) {
|
||||
return (
|
||||
<div className="flex min-h-[400px] flex-col items-center justify-center space-y-4">
|
||||
<svg className="h-12 w-12 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||
</svg>
|
||||
<p className="text-destructive">{error || 'Không tìm thấy tin đăng'}</p>
|
||||
<Link href="/search">
|
||||
<Button variant="outline">Quay lại tìm kiếm</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { property, seller, agent } = listing;
|
||||
const transactionLabel = getLabel(TRANSACTION_TYPES, listing.transactionType);
|
||||
const propertyTypeLabel = getLabel(PROPERTY_TYPES, property.propertyType);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-4 py-6">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="mb-4 flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<Link href="/" className="hover:text-foreground">Trang chủ</Link>
|
||||
<span>/</span>
|
||||
<Link href="/search" className="hover:text-foreground">Tìm kiếm</Link>
|
||||
<span>/</span>
|
||||
<span className="truncate text-foreground">{property.title}</span>
|
||||
</nav>
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
{transactionLabel && (
|
||||
<Badge variant={listing.transactionType === 'SALE' ? 'default' : 'secondary'}>
|
||||
{transactionLabel}
|
||||
</Badge>
|
||||
)}
|
||||
{propertyTypeLabel && <Badge variant="outline">{propertyTypeLabel}</Badge>}
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold md:text-3xl">{property.title}</h1>
|
||||
<p className="mt-1 flex items-center gap-1 text-muted-foreground">
|
||||
<svg className="h-4 w-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
{property.address}, {property.ward}, {property.district}, {property.city}
|
||||
</p>
|
||||
</div>
|
||||
<div className="shrink-0 text-right">
|
||||
<p className="text-2xl font-bold text-primary md:text-3xl">{formatPrice(listing.priceVND)} VND</p>
|
||||
{listing.pricePerM2 != null && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
~{listing.pricePerM2.toLocaleString('vi-VN')} VND/m²
|
||||
</p>
|
||||
)}
|
||||
{listing.rentPriceMonthly && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Thuê: {formatPrice(listing.rentPriceMonthly)}/tháng
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Image gallery */}
|
||||
<ImageGallery media={property.media} />
|
||||
|
||||
{/* Quick specs bar */}
|
||||
<div className="my-6 flex flex-wrap gap-4 rounded-lg border bg-card p-4">
|
||||
<QuickStat icon="area" label="Diện tích" value={`${property.areaM2} m\u00B2`} />
|
||||
{property.bedrooms != null && (
|
||||
<QuickStat icon="bed" label="Phòng ngủ" value={`${property.bedrooms}`} />
|
||||
)}
|
||||
{property.bathrooms != null && (
|
||||
<QuickStat icon="bath" label="Phòng tắm" value={`${property.bathrooms}`} />
|
||||
)}
|
||||
{property.floors != null && (
|
||||
<QuickStat icon="floors" label="Số tầng" value={`${property.floors}`} />
|
||||
)}
|
||||
{property.direction && (
|
||||
<QuickStat icon="compass" label="Hướng" value={getLabel(DIRECTIONS, property.direction) || ''} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{/* Main content */}
|
||||
<div className="space-y-6 lg:col-span-2">
|
||||
{/* Description */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Mô tả</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="whitespace-pre-wrap text-sm leading-relaxed">{property.description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Details */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Thông tin chi tiết</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3">
|
||||
<InfoItem label="Loại BĐS" value={propertyTypeLabel || '---'} />
|
||||
<InfoItem label="Diện tích" value={`${property.areaM2} m\u00B2`} />
|
||||
<InfoItem label="Phòng ngủ" value={property.bedrooms != null ? `${property.bedrooms}` : '---'} />
|
||||
<InfoItem label="Phòng tắm" value={property.bathrooms != null ? `${property.bathrooms}` : '---'} />
|
||||
<InfoItem label="Số tầng" value={property.floors != null ? `${property.floors}` : '---'} />
|
||||
<InfoItem label="Hướng" value={getLabel(DIRECTIONS, property.direction) || '---'} />
|
||||
<InfoItem label="Năm xây" value={property.yearBuilt ? `${property.yearBuilt}` : '---'} />
|
||||
<InfoItem label="Pháp lý" value={property.legalStatus || '---'} />
|
||||
<InfoItem label="Dự án" value={property.projectName || '---'} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Amenities */}
|
||||
{property.amenities && property.amenities.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Tiện ích</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{property.amenities.map((a) => (
|
||||
<Badge key={a} variant="secondary">
|
||||
{a}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Map */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Vị trí trên bản đồ</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ListingMap
|
||||
listings={[listing]}
|
||||
className="h-[300px]"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* Contact card */}
|
||||
<Card className="sticky top-20">
|
||||
<CardHeader>
|
||||
<CardTitle>Liên hệ</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{seller.fullName}</p>
|
||||
<p className="text-sm text-muted-foreground">{seller.phone}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href={`tel:${seller.phone}`}>
|
||||
<Button className="w-full gap-2">
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
|
||||
</svg>
|
||||
Gọi ngay
|
||||
</Button>
|
||||
</a>
|
||||
<Button variant="outline" className="w-full gap-2">
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
Nhắn tin
|
||||
</Button>
|
||||
|
||||
{agent && (
|
||||
<div className="border-t pt-3">
|
||||
<p className="text-xs text-muted-foreground">Môi giới</p>
|
||||
{agent.agency && <p className="text-sm font-medium">{agent.agency}</p>}
|
||||
{listing.commissionPct != null && (
|
||||
<p className="text-xs text-muted-foreground">Hoa hồng: {listing.commissionPct}%</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* AI Estimate */}
|
||||
<AiEstimateButton listingId={listing.id} />
|
||||
|
||||
{/* Stats */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="grid grid-cols-3 gap-4 text-center">
|
||||
<div>
|
||||
<p className="text-lg font-bold">{listing.viewCount}</p>
|
||||
<p className="text-xs text-muted-foreground">Lượt xem</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-bold">{listing.saveCount}</p>
|
||||
<p className="text-xs text-muted-foreground">Lượt lưu</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-bold">{listing.inquiryCount}</p>
|
||||
<p className="text-xs text-muted-foreground">Liên hệ</p>
|
||||
</div>
|
||||
</div>
|
||||
{listing.publishedAt && (
|
||||
<p className="mt-3 border-t pt-3 text-center text-xs text-muted-foreground">
|
||||
Đăng ngày {new Date(listing.publishedAt).toLocaleDateString('vi-VN')}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QuickStat({ icon, label, value }: { icon: string; label: string; value: string }) {
|
||||
const icons: Record<string, React.ReactNode> = {
|
||||
area: (
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />
|
||||
</svg>
|
||||
),
|
||||
bed: (
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 7v11m0-7h18M3 18h18M6 14h.01M6 10a2 2 0 012-2h8a2 2 0 012 2v0H6z" />
|
||||
</svg>
|
||||
),
|
||||
bath: (
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 12h16M4 12a2 2 0 00-2 2v2a4 4 0 004 4h12a4 4 0 004-4v-2a2 2 0 00-2-2M4 12V7a3 3 0 013-3h1" />
|
||||
</svg>
|
||||
),
|
||||
floors: (
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||
</svg>
|
||||
),
|
||||
compass: (
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 9l3 3m0 0l3-3m-3 3V6m0 6l-3 3m3-3l3 3m-3-3v6" />
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-muted-foreground">{icons[icon]}</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p className="text-sm font-semibold">{value}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoItem({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p className="font-medium">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
276
apps/web/app/[locale]/(public)/page.tsx
Normal file
276
apps/web/app/[locale]/(public)/page.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
166
apps/web/app/[locale]/(public)/search/__tests__/search.spec.tsx
Normal file
166
apps/web/app/[locale]/(public)/search/__tests__/search.spec.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
/* eslint-disable import-x/order */
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Mock next-intl with Vietnamese messages
|
||||
const viMessages = await import('@/messages/vi.json');
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: (namespace?: string) => {
|
||||
const messages = viMessages.default ?? viMessages;
|
||||
const ns = namespace
|
||||
? (messages[namespace as keyof typeof messages] as Record<string, unknown> | undefined)
|
||||
: (messages as unknown as Record<string, unknown>);
|
||||
return (key: string, params?: Record<string, unknown>) => {
|
||||
if (!ns) return key;
|
||||
const parts = key.split('.');
|
||||
let val: unknown = ns;
|
||||
for (const p of parts) {
|
||||
val = (val as Record<string, unknown>)?.[p];
|
||||
}
|
||||
if (typeof val === 'string' && params) {
|
||||
return val.replace(/\{(\w+)\}/g, (_, k: string) => String(params[k] ?? `{${k}}`));
|
||||
}
|
||||
return typeof val === 'string' ? val : key;
|
||||
};
|
||||
},
|
||||
useLocale: () => 'vi',
|
||||
NextIntlClientProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||
}));
|
||||
|
||||
const mockPush = vi.fn();
|
||||
const mockReplace = vi.fn();
|
||||
const mockSearchParams = new URLSearchParams();
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push: mockPush, replace: mockReplace }),
|
||||
useSearchParams: () => mockSearchParams,
|
||||
}));
|
||||
|
||||
vi.mock('next/link', () => ({
|
||||
default: ({ children, href, ...props }: { children: React.ReactNode; href: string; [key: string]: unknown }) => (
|
||||
<a href={href} {...props}>{children}</a>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('next/image', () => ({
|
||||
default: (props: Record<string, unknown>) => <img {...props} />,
|
||||
}));
|
||||
|
||||
// Mock dynamic import for map component
|
||||
vi.mock('next/dynamic', () => ({
|
||||
default: () => {
|
||||
const MockMap = () => <div data-testid="map-placeholder">Map</div>;
|
||||
MockMap.displayName = 'MockMap';
|
||||
return MockMap;
|
||||
},
|
||||
}));
|
||||
|
||||
const mockListings = {
|
||||
data: [
|
||||
{
|
||||
id: '1',
|
||||
status: 'ACTIVE',
|
||||
transactionType: 'SALE',
|
||||
priceVND: '5000000000',
|
||||
pricePerM2: null,
|
||||
rentPriceMonthly: null,
|
||||
commissionPct: null,
|
||||
viewCount: 10,
|
||||
saveCount: 2,
|
||||
inquiryCount: 1,
|
||||
publishedAt: '2024-01-01',
|
||||
createdAt: '2024-01-01',
|
||||
property: {
|
||||
id: 'p1',
|
||||
propertyType: 'APARTMENT',
|
||||
title: 'Căn hộ Quận 7',
|
||||
description: 'Căn hộ view sông',
|
||||
address: '123 Nguyễn Hữu Thọ',
|
||||
ward: 'Phường Tân Hưng',
|
||||
district: 'Quận 7',
|
||||
city: 'Hồ Chí Minh',
|
||||
areaM2: 75,
|
||||
bedrooms: 2,
|
||||
bathrooms: 2,
|
||||
floors: null,
|
||||
direction: null,
|
||||
yearBuilt: null,
|
||||
legalStatus: null,
|
||||
amenities: null,
|
||||
projectName: null,
|
||||
media: [],
|
||||
},
|
||||
seller: { id: 's1', fullName: 'Nguyen Van A', phone: '0912345678' },
|
||||
agent: null,
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
page: 1,
|
||||
limit: 12,
|
||||
totalPages: 1,
|
||||
};
|
||||
|
||||
vi.mock('@/lib/listings-api', () => ({
|
||||
listingsApi: {
|
||||
search: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { listingsApi } from '@/lib/listings-api';
|
||||
import SearchPage from '../page';
|
||||
|
||||
const mockedListingsApi = vi.mocked(listingsApi);
|
||||
|
||||
describe('SearchPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockedListingsApi.search.mockResolvedValue(mockListings as never);
|
||||
});
|
||||
|
||||
it('renders the search page title', async () => {
|
||||
render(<SearchPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Tìm kiếm bất động sản')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders view mode toggle buttons', async () => {
|
||||
render(<SearchPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /danh sách/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /bản đồ/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls listings API on mount', async () => {
|
||||
render(<SearchPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedListingsApi.search).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('displays listing results after loading', async () => {
|
||||
render(<SearchPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/căn hộ quận 7/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('switches to map view when map button is clicked', async () => {
|
||||
render(<SearchPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /bản đồ/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /bản đồ/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('map-placeholder')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
97
apps/web/app/[locale]/(public)/search/error.tsx
Normal file
97
apps/web/app/[locale]/(public)/search/error.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export default function SearchError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
const [retryCount, setRetryCount] = useState(0);
|
||||
const [autoRetrying, setAutoRetrying] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
console.error('Search error:', error);
|
||||
}, [error]);
|
||||
|
||||
useEffect(() => {
|
||||
if (retryCount > 0) return;
|
||||
setAutoRetrying(true);
|
||||
const timer = setTimeout(() => {
|
||||
setAutoRetrying(false);
|
||||
setRetryCount((c) => c + 1);
|
||||
reset();
|
||||
}, 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [error, reset, retryCount]);
|
||||
|
||||
const handleRetry = () => {
|
||||
setRetryCount((c) => c + 1);
|
||||
reset();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-4 py-6">
|
||||
<div className="flex min-h-[400px] flex-col items-center justify-center">
|
||||
<div className="mx-auto max-w-md text-center">
|
||||
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-destructive/10">
|
||||
<svg
|
||||
className="h-7 w-7 text-destructive"
|
||||
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>
|
||||
</div>
|
||||
<h2 className="mt-4 text-xl font-semibold">Lỗi tìm kiếm</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{autoRetrying
|
||||
? 'Đang tự động thử lại...'
|
||||
: 'Không thể thực hiện tìm kiếm. Vui lòng thử lại hoặc thay đổi bộ lọc.'}
|
||||
</p>
|
||||
{error.digest && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">Mã lỗi: {error.digest}</p>
|
||||
)}
|
||||
{retryCount > 0 && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Đã thử lại {retryCount} lần
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-6 flex justify-center gap-3">
|
||||
<button
|
||||
onClick={handleRetry}
|
||||
disabled={autoRetrying}
|
||||
className="inline-flex h-9 items-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{autoRetrying ? (
|
||||
<>
|
||||
<svg className="mr-2 h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Đang thử lại...
|
||||
</>
|
||||
) : (
|
||||
'Thử lại'
|
||||
)}
|
||||
</button>
|
||||
<a
|
||||
href="/"
|
||||
className="inline-flex h-9 items-center rounded-md border border-input bg-background px-4 text-sm font-medium shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
Về trang chủ
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
apps/web/app/[locale]/(public)/search/layout.tsx
Normal file
16
apps/web/app/[locale]/(public)/search/layout.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Tìm kiếm bất động sản',
|
||||
description:
|
||||
'Tìm kiếm mua bán, cho thuê bất động sản trên toàn quốc — căn hộ, nhà phố, biệt thự, đất nền với bộ lọc thông minh.',
|
||||
openGraph: {
|
||||
title: 'Tìm kiếm bất động sản | GoodGo',
|
||||
description:
|
||||
'Tìm kiếm mua bán, cho thuê bất động sản trên toàn quốc với GoodGo.',
|
||||
},
|
||||
};
|
||||
|
||||
export default function SearchLayout({ children }: { children: React.ReactNode }) {
|
||||
return children;
|
||||
}
|
||||
72
apps/web/app/[locale]/(public)/search/loading.tsx
Normal file
72
apps/web/app/[locale]/(public)/search/loading.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
export default function SearchLoading() {
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-4 py-6">
|
||||
{/* Header skeleton */}
|
||||
<div className="mb-6">
|
||||
<div className="h-8 w-64 animate-pulse rounded bg-muted" />
|
||||
<div className="mt-2 h-4 w-80 animate-pulse rounded bg-muted" />
|
||||
</div>
|
||||
|
||||
{/* View mode toggle skeleton */}
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="flex gap-1 rounded-lg border p-1">
|
||||
<div className="h-8 w-24 animate-pulse rounded bg-muted" />
|
||||
<div className="h-8 w-24 animate-pulse rounded bg-muted" />
|
||||
<div className="hidden h-8 w-24 animate-pulse rounded bg-muted lg:block" />
|
||||
</div>
|
||||
<div className="h-8 w-20 animate-pulse rounded bg-muted lg:hidden" />
|
||||
</div>
|
||||
|
||||
{/* Filter bar skeleton (desktop) */}
|
||||
<div className="mb-4 hidden lg:block">
|
||||
<div className="flex gap-3 rounded-lg border p-4">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="h-9 w-36 animate-pulse rounded bg-muted" />
|
||||
))}
|
||||
<div className="h-9 w-24 animate-pulse rounded-md bg-muted" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content area skeleton */}
|
||||
<div className="flex gap-6">
|
||||
{/* Sidebar skeleton (desktop) */}
|
||||
<aside className="hidden w-64 shrink-0 lg:block">
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i}>
|
||||
<div className="h-4 w-20 animate-pulse rounded bg-muted" />
|
||||
<div className="mt-2 h-9 w-full animate-pulse rounded bg-muted" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Results grid skeleton */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="h-4 w-32 animate-pulse rounded bg-muted" />
|
||||
<div className="h-9 w-40 animate-pulse rounded bg-muted" />
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="rounded-lg border bg-card shadow-sm">
|
||||
<div className="aspect-[16/10] animate-pulse rounded-t-lg bg-muted" />
|
||||
<div className="p-4">
|
||||
<div className="h-5 w-3/4 animate-pulse rounded bg-muted" />
|
||||
<div className="mt-2 h-4 w-1/2 animate-pulse rounded bg-muted" />
|
||||
<div className="mt-3 flex gap-2">
|
||||
<div className="h-6 w-16 animate-pulse rounded bg-muted" />
|
||||
<div className="h-6 w-16 animate-pulse rounded bg-muted" />
|
||||
</div>
|
||||
<div className="mt-3 h-5 w-24 animate-pulse rounded bg-muted" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
294
apps/web/app/[locale]/(public)/search/page.tsx
Normal file
294
apps/web/app/[locale]/(public)/search/page.tsx
Normal file
@@ -0,0 +1,294 @@
|
||||
'use client';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import * as React from 'react';
|
||||
import { FilterBar, type SearchFilters } from '@/components/search/filter-bar';
|
||||
import { SearchResults } from '@/components/search/search-results';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { listingsApi, type ListingDetail, type PaginatedResult } from '@/lib/listings-api';
|
||||
|
||||
const ListingMap = dynamic(
|
||||
() => import('@/components/map/listing-map').then((mod) => mod.ListingMap),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="flex h-[calc(100vh-220px)] items-center justify-center rounded-lg bg-muted">
|
||||
<p className="text-sm text-muted-foreground">Đang tải bản đồ...</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
type ViewMode = 'list' | 'map' | 'split';
|
||||
|
||||
const defaultFilters: SearchFilters = {
|
||||
transactionType: '',
|
||||
propertyType: '',
|
||||
city: '',
|
||||
district: '',
|
||||
minPrice: '',
|
||||
maxPrice: '',
|
||||
minArea: '',
|
||||
maxArea: '',
|
||||
bedrooms: '',
|
||||
sort: '',
|
||||
};
|
||||
|
||||
function SearchContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const [filters, setFilters] = React.useState<SearchFilters>(() => ({
|
||||
...defaultFilters,
|
||||
transactionType: searchParams.get('transactionType') || '',
|
||||
propertyType: searchParams.get('propertyType') || '',
|
||||
city: searchParams.get('city') || '',
|
||||
district: searchParams.get('district') || '',
|
||||
minPrice: searchParams.get('minPrice') || '',
|
||||
maxPrice: searchParams.get('maxPrice') || '',
|
||||
bedrooms: searchParams.get('bedrooms') || '',
|
||||
sort: searchParams.get('sort') || '',
|
||||
}));
|
||||
|
||||
const [page, setPage] = React.useState(Number(searchParams.get('page')) || 1);
|
||||
const [result, setResult] = React.useState<PaginatedResult<ListingDetail> | null>(null);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [searchError, setSearchError] = React.useState(false);
|
||||
const [viewMode, setViewMode] = React.useState<ViewMode>('list');
|
||||
const [showMobileFilters, setShowMobileFilters] = React.useState(false);
|
||||
const [selectedListingId, setSelectedListingId] = React.useState<string | undefined>();
|
||||
|
||||
const handleMarkerClick = (listing: ListingDetail) => {
|
||||
setSelectedListingId(listing.id);
|
||||
};
|
||||
|
||||
const fetchListings = React.useCallback(() => {
|
||||
setLoading(true);
|
||||
const params: Record<string, string | number> = {
|
||||
page,
|
||||
limit: 12,
|
||||
status: 'ACTIVE',
|
||||
};
|
||||
if (filters.transactionType) params['transactionType'] = filters.transactionType;
|
||||
if (filters.propertyType) params['propertyType'] = filters.propertyType;
|
||||
if (filters.city) params['city'] = filters.city;
|
||||
if (filters.district) params['district'] = filters.district;
|
||||
if (filters.minPrice) params['minPrice'] = filters.minPrice;
|
||||
if (filters.maxPrice) params['maxPrice'] = filters.maxPrice;
|
||||
if (filters.minArea) params['minArea'] = Number(filters.minArea);
|
||||
if (filters.maxArea) params['maxArea'] = Number(filters.maxArea);
|
||||
if (filters.bedrooms) params['bedrooms'] = Number(filters.bedrooms);
|
||||
|
||||
setSearchError(false);
|
||||
listingsApi
|
||||
.search(params)
|
||||
.then(setResult)
|
||||
.catch(() => {
|
||||
setResult(null);
|
||||
setSearchError(true);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [filters, page]);
|
||||
|
||||
React.useEffect(() => {
|
||||
fetchListings();
|
||||
}, [fetchListings]);
|
||||
|
||||
// Sync filters to URL
|
||||
React.useEffect(() => {
|
||||
const params = new URLSearchParams();
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
if (value) params.set(key, value);
|
||||
});
|
||||
if (page > 1) params.set('page', String(page));
|
||||
const qs = params.toString();
|
||||
router.replace(`/search${qs ? `?${qs}` : ''}`, { scroll: false });
|
||||
}, [filters, page, router]);
|
||||
|
||||
const handleFilterChange = (newFilters: SearchFilters) => {
|
||||
setFilters(newFilters);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
setPage(1);
|
||||
fetchListings();
|
||||
};
|
||||
|
||||
const activeFilterCount = Object.entries(filters).filter(
|
||||
([key, value]) => value && key !== 'sort',
|
||||
).length;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-4 py-6">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold md:text-3xl">Tìm kiếm bất động sản</h1>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
Tìm bất động sản phù hợp với nhu cầu của bạn
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* View Mode Toggle + Mobile Filter Button */}
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="flex gap-1 rounded-lg border p-1">
|
||||
<Button
|
||||
variant={viewMode === 'list' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode('list')}
|
||||
>
|
||||
<svg className="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
||||
</svg>
|
||||
Danh sách
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'map' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode('map')}
|
||||
>
|
||||
<svg className="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
|
||||
</svg>
|
||||
Bản đồ
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'split' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
className="hidden lg:flex"
|
||||
onClick={() => setViewMode('split')}
|
||||
>
|
||||
<svg className="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7" />
|
||||
</svg>
|
||||
Chia đôi
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="lg:hidden"
|
||||
onClick={() => setShowMobileFilters(!showMobileFilters)}
|
||||
>
|
||||
<svg className="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
|
||||
</svg>
|
||||
Bộ lọc
|
||||
{activeFilterCount > 0 && (
|
||||
<span className="ml-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-xs text-primary-foreground">
|
||||
{activeFilterCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Desktop horizontal filter bar */}
|
||||
<div className="mb-4 hidden lg:block">
|
||||
<FilterBar
|
||||
filters={filters}
|
||||
onChange={handleFilterChange}
|
||||
onSearch={handleSearch}
|
||||
layout="horizontal"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Mobile filter panel */}
|
||||
{showMobileFilters && (
|
||||
<div className="mb-4 rounded-lg border bg-card p-4 lg:hidden">
|
||||
<FilterBar
|
||||
filters={filters}
|
||||
onChange={handleFilterChange}
|
||||
onSearch={() => {
|
||||
handleSearch();
|
||||
setShowMobileFilters(false);
|
||||
}}
|
||||
layout="sidebar"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="flex gap-6">
|
||||
{/* Sidebar filters (desktop, split/list mode) */}
|
||||
{viewMode !== 'map' && (
|
||||
<aside className="hidden w-64 shrink-0 lg:block">
|
||||
<div className="sticky top-20 rounded-lg border bg-card p-4">
|
||||
<FilterBar
|
||||
filters={filters}
|
||||
onChange={handleFilterChange}
|
||||
onSearch={handleSearch}
|
||||
layout="sidebar"
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
|
||||
{/* Main content */}
|
||||
<div className="min-w-0 flex-1">
|
||||
{viewMode === 'list' && (
|
||||
<SearchResults
|
||||
result={result}
|
||||
loading={loading}
|
||||
error={searchError}
|
||||
onRetry={fetchListings}
|
||||
page={page}
|
||||
sort={filters.sort}
|
||||
onPageChange={setPage}
|
||||
onSortChange={(sort) => handleFilterChange({ ...filters, sort })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{viewMode === 'map' && (
|
||||
<ListingMap
|
||||
listings={result?.data || []}
|
||||
selectedListingId={selectedListingId}
|
||||
onMarkerClick={handleMarkerClick}
|
||||
className="h-[calc(100vh-220px)]"
|
||||
/>
|
||||
)}
|
||||
|
||||
{viewMode === 'split' && (
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<div className="overflow-auto" style={{ maxHeight: 'calc(100vh - 220px)' }}>
|
||||
<SearchResults
|
||||
result={result}
|
||||
loading={loading}
|
||||
error={searchError}
|
||||
onRetry={fetchListings}
|
||||
page={page}
|
||||
sort={filters.sort}
|
||||
onPageChange={setPage}
|
||||
onSortChange={(sort) => handleFilterChange({ ...filters, sort })}
|
||||
/>
|
||||
</div>
|
||||
<div className="hidden lg:block">
|
||||
<ListingMap
|
||||
listings={result?.data || []}
|
||||
selectedListingId={selectedListingId}
|
||||
onMarkerClick={handleMarkerClick}
|
||||
className="sticky top-20 h-[calc(100vh-220px)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SearchPage() {
|
||||
return (
|
||||
<React.Suspense
|
||||
fallback={
|
||||
<div className="flex min-h-[400px] items-center justify-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<SearchContent />
|
||||
</React.Suspense>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user