Files
goodgo-platform/apps/web/app/(public)/search/page.tsx
Ho Ngoc Hai 36c1e3b39a fix(web): add proper Vietnamese diacritics to all dashboard and listing pages
Vietnamese text throughout the frontend was missing accent marks (diacritics),
using plain ASCII instead of proper Unicode characters. Fixed all user-visible
text across dashboard, analytics, listings, search, and chart components.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-08 13:21:37 +07:00

295 lines
10 KiB
TypeScript

'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>
);
}