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:
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