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>
This commit is contained in:
Ho Ngoc Hai
2026-04-08 02:02:42 +07:00
parent ad7713968a
commit 5e44456d11
10 changed files with 1254 additions and 9 deletions

View File

@@ -0,0 +1,264 @@
'use client';
import * as React from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { FilterBar, type SearchFilters } from '@/components/search/filter-bar';
import { SearchResults } from '@/components/search/search-results';
import { ListingMap } from '@/components/map/listing-map';
import { listingsApi, type ListingDetail, type PaginatedResult } from '@/lib/listings-api';
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 [viewMode, setViewMode] = React.useState<ViewMode>('list');
const [showMobileFilters, setShowMobileFilters] = React.useState(false);
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);
listingsApi
.search(params)
.then(setResult)
.catch(() => setResult(null))
.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}
page={page}
sort={filters.sort}
onPageChange={setPage}
onSortChange={(sort) => handleFilterChange({ ...filters, sort })}
/>
)}
{viewMode === 'map' && (
<ListingMap
listings={result?.data || []}
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}
page={page}
sort={filters.sort}
onPageChange={setPage}
onSortChange={(sort) => handleFilterChange({ ...filters, sort })}
/>
</div>
<div className="hidden lg:block">
<ListingMap
listings={result?.data || []}
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>
);
}