Some checks failed
Deploy / Smoke Test Staging (push) Has been cancelled
Deploy / Deploy to Staging (push) Has been cancelled
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 5s
CI / AI Services (Python) — Smoke (push) Failing after 5s
Deploy / Build Web Image (push) Failing after 4s
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 43s
Deploy / Build API Image (push) Failing after 6s
Security Scanning / Trivy Scan — AI Services Image (push) Has started running
Security Scanning / Trivy Filesystem Scan (push) Has been cancelled
Security Scanning / Security Gate (push) Has been cancelled
CI / E2E Tests (push) Has been skipped
Deploy / Build AI Services Image (push) Failing after 4s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 3s
E2E Tests / Playwright E2E (push) Failing after 8s
Security Scanning / Trivy Scan — API Image (push) Failing after 36s
Security Scanning / Trivy Scan — Web Image (push) Failing after 49s
Deploy / Rollback Staging (push) Has been cancelled
Deploy / Smoke Test Production (push) Has been cancelled
Deploy / Rollback Production (push) Has been cancelled
Deploy / Deploy to Production (push) Has been cancelled
- Hide the desktop horizontal FilterBar in list/split modes — the sidebar already renders an identical control set, so showing both duplicated every dropdown. Keep horizontal bar only when in map mode where there's no sidebar. - Replace `hsl(var(--…))` paint colors in ListingMap with literal hex constants. Mapbox-gl's color parser rejects CSS variable references and was throwing 'circle-color: Could not parse color from value hsl(var(--primary))' for cluster + marker layers, leaving the map blank. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
470 lines
17 KiB
TypeScript
470 lines
17 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 { useCreateSavedSearch } from '@/lib/hooks/use-saved-searches';
|
|
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 [showSaveDialog, setShowSaveDialog] = React.useState(false);
|
|
const [saveName, setSaveName] = React.useState('');
|
|
const [saveAlertEnabled, setSaveAlertEnabled] = React.useState(true);
|
|
const [saveSuccess, setSaveSuccess] = React.useState(false);
|
|
|
|
const saveDialogRef = React.useRef<HTMLDivElement>(null);
|
|
const saveButtonRef = React.useRef<HTMLButtonElement>(null);
|
|
const saveNameInputRef = React.useRef<HTMLInputElement>(null);
|
|
|
|
const createSavedSearch = useCreateSavedSearch();
|
|
|
|
// Focus management for save-search dialog
|
|
React.useEffect(() => {
|
|
if (showSaveDialog) {
|
|
saveNameInputRef.current?.focus();
|
|
}
|
|
}, [showSaveDialog]);
|
|
|
|
// Focus trap + Escape key for save-search dialog
|
|
React.useEffect(() => {
|
|
if (!showSaveDialog) return;
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') {
|
|
setShowSaveDialog(false);
|
|
saveButtonRef.current?.focus();
|
|
return;
|
|
}
|
|
if (e.key === 'Tab') {
|
|
const dialog = saveDialogRef.current;
|
|
if (!dialog) return;
|
|
const focusable = dialog.querySelectorAll<HTMLElement>(
|
|
'button, input, [tabindex]:not([tabindex="-1"])',
|
|
);
|
|
const first = focusable[0];
|
|
const last = focusable[focusable.length - 1];
|
|
if (e.shiftKey && document.activeElement === first) {
|
|
e.preventDefault();
|
|
last?.focus();
|
|
} else if (!e.shiftKey && document.activeElement === last) {
|
|
e.preventDefault();
|
|
first?.focus();
|
|
}
|
|
}
|
|
};
|
|
document.addEventListener('keydown', handleKeyDown);
|
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
}, [showSaveDialog]);
|
|
|
|
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;
|
|
|
|
const handleSaveSearch = () => {
|
|
if (!saveName.trim()) return;
|
|
|
|
const filterData: Record<string, string> = {};
|
|
Object.entries(filters).forEach(([key, value]) => {
|
|
if (value && key !== 'sort') filterData[key] = value;
|
|
});
|
|
|
|
createSavedSearch.mutate(
|
|
{ name: saveName.trim(), filters: filterData, alertEnabled: saveAlertEnabled },
|
|
{
|
|
onSuccess: () => {
|
|
setSaveSuccess(true);
|
|
setSaveName('');
|
|
setTimeout(() => {
|
|
setShowSaveDialog(false);
|
|
setSaveSuccess(false);
|
|
}, 1500);
|
|
},
|
|
},
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className="mx-auto max-w-7xl px-4 py-6">
|
|
{/* Header */}
|
|
<div className="mb-6 flex items-start justify-between">
|
|
<div>
|
|
<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>
|
|
{activeFilterCount > 0 && (
|
|
<div className="relative">
|
|
<Button
|
|
ref={saveButtonRef}
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setShowSaveDialog(!showSaveDialog)}
|
|
aria-expanded={showSaveDialog}
|
|
aria-controls="save-search-dialog"
|
|
aria-haspopup="dialog"
|
|
>
|
|
<svg aria-hidden="true" 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="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
|
|
</svg>
|
|
Lưu tìm kiếm
|
|
</Button>
|
|
|
|
{/* Save search dialog */}
|
|
{showSaveDialog && (
|
|
<div
|
|
id="save-search-dialog"
|
|
ref={saveDialogRef}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="save-search-heading"
|
|
className="absolute right-0 top-full z-50 mt-2 w-80 rounded-lg border bg-card p-4 shadow-lg"
|
|
>
|
|
{saveSuccess ? (
|
|
<div className="flex items-center gap-2 text-green-600">
|
|
<svg aria-hidden="true" className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
|
</svg>
|
|
<span className="text-sm font-medium">Đã lưu tìm kiếm!</span>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<h3 className="mb-3 font-medium" id="save-search-heading">Lưu bộ lọc tìm kiếm</h3>
|
|
<label htmlFor="save-search-name" className="sr-only">Tên tìm kiếm</label>
|
|
<input
|
|
ref={saveNameInputRef}
|
|
id="save-search-name"
|
|
type="text"
|
|
value={saveName}
|
|
onChange={(e) => setSaveName(e.target.value)}
|
|
placeholder="Tên tìm kiếm (VD: Chung cư Q7 dưới 3 tỷ)"
|
|
className="mb-3 w-full rounded-md border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary"
|
|
maxLength={100}
|
|
onKeyDown={(e) => e.key === 'Enter' && handleSaveSearch()}
|
|
aria-describedby="save-search-heading"
|
|
/>
|
|
<div className="mb-3 flex items-center gap-2 text-sm">
|
|
<input
|
|
id="save-alert-enabled"
|
|
type="checkbox"
|
|
checked={saveAlertEnabled}
|
|
onChange={(e) => setSaveAlertEnabled(e.target.checked)}
|
|
className="rounded"
|
|
/>
|
|
<label htmlFor="save-alert-enabled">
|
|
Nhận thông báo khi có kết quả mới
|
|
</label>
|
|
</div>
|
|
<div className="flex items-center justify-end gap-2">
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => setShowSaveDialog(false)}
|
|
>
|
|
Hủy
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
onClick={handleSaveSearch}
|
|
disabled={!saveName.trim() || createSavedSearch.isPending}
|
|
>
|
|
{createSavedSearch.isPending ? 'Đang lưu...' : 'Lưu'}
|
|
</Button>
|
|
</div>
|
|
{createSavedSearch.isError && (
|
|
<p className="mt-2 text-xs text-destructive">
|
|
Không thể lưu tìm kiếm. Vui lòng thử lại.
|
|
</p>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</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')}
|
|
aria-pressed={viewMode === 'list'}
|
|
>
|
|
<svg aria-hidden="true" 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')}
|
|
aria-pressed={viewMode === 'map'}
|
|
>
|
|
<svg aria-hidden="true" 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')}
|
|
aria-pressed={viewMode === 'split'}
|
|
>
|
|
<svg aria-hidden="true" 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)}
|
|
aria-expanded={showMobileFilters}
|
|
aria-controls="mobile-filter-panel"
|
|
>
|
|
<svg aria-hidden="true" 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 — only when there's no sidebar
|
|
(i.e. full-width map view). Showing it alongside the sidebar in
|
|
list/split mode would just duplicate every control. */}
|
|
{viewMode === 'map' && (
|
|
<div className="mb-4 hidden lg:block">
|
|
<FilterBar
|
|
filters={filters}
|
|
onChange={handleFilterChange}
|
|
onSearch={handleSearch}
|
|
layout="horizontal"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Mobile filter panel */}
|
|
{showMobileFilters && (
|
|
<div id="mobile-filter-panel" 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
|
|
role="status"
|
|
aria-label="Đang tải..."
|
|
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>
|
|
);
|
|
}
|