Files
goodgo-platform/apps/web/app/[locale]/(public)/du-an/page.tsx
Ho Ngoc Hai 580eb2a261 feat(web): residential_projects feature flag for /du-an routes (TEC-2757)
- Add useResidentialProjectsFlag hook with NEXT_PUBLIC_FEATURE_RESIDENTIAL_PROJECTS env + URL/localStorage override (mirrors AVM v2 pattern)
- Gate /du-an index (client) and /du-an/[slug] detail (server) routes via notFound() when flag disabled
- Add component tests for index page including disabled-flag notFound branch

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-18 15:13:06 +07:00

271 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { Building2, LayoutGrid, List, Map, MapPin } from 'lucide-react';
import dynamic from 'next/dynamic';
import Image from 'next/image';
import { notFound } from 'next/navigation';
import * as React from 'react';
import { ProjectCard } from '@/components/du-an/project-card';
import { ProjectFilterBar } from '@/components/du-an/project-filter-bar';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Link } from '@/i18n/navigation';
import { formatPrice } from '@/lib/currency';
import {
PROJECT_PROPERTY_TYPE_LABELS,
PROJECT_STATUS_COLORS,
PROJECT_STATUS_LABELS,
type ProjectSummary,
type SearchProjectsParams,
} from '@/lib/du-an-api';
import { useProjectsSearch } from '@/lib/hooks/use-du-an';
import { useResidentialProjectsFlag } from '@/lib/hooks/use-residential-projects-flag';
import { cn } from '@/lib/utils';
const ProjectMap = dynamic(
() => import('@/components/du-an/project-map').then((m) => m.ProjectMap),
{ ssr: false },
);
const PAGE_SIZE = 12;
type ViewMode = 'grid' | 'list' | 'map';
export default function DuAnPage() {
const flagEnabled = useResidentialProjectsFlag();
const [filters, setFilters] = React.useState<SearchProjectsParams>({
page: 1,
limit: PAGE_SIZE,
});
const [viewMode, setViewMode] = React.useState<ViewMode>('grid');
const { data, isLoading, isError } = useProjectsSearch(filters);
if (!flagEnabled) {
notFound();
}
const handleFilterChange = (newFilters: SearchProjectsParams) => {
setFilters({ ...newFilters, limit: PAGE_SIZE });
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const handlePageChange = (page: number) => {
setFilters((prev) => ({ ...prev, page }));
window.scrollTo({ top: 0, behavior: 'smooth' });
};
return (
<div className="mx-auto max-w-7xl px-4 py-6">
{/* Page header */}
<div className="mb-6 flex items-start justify-between">
<div>
<h1 className="text-2xl font-bold md:text-3xl">Dự án bất đng sản</h1>
<p className="mt-1 text-muted-foreground">
Khám phá các dự án mới nhất từ các chủ đu uy tín
</p>
</div>
<div className="flex gap-1 rounded-lg border p-1">
<button
type="button"
onClick={() => setViewMode('grid')}
className={cn(
'rounded-md p-2 transition-colors',
viewMode === 'grid'
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground',
)}
aria-label="Xem dạng lưới"
>
<LayoutGrid className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => setViewMode('list')}
className={cn(
'rounded-md p-2 transition-colors',
viewMode === 'list'
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground',
)}
aria-label="Xem dạng danh sách"
>
<List className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => setViewMode('map')}
className={cn(
'rounded-md p-2 transition-colors',
viewMode === 'map'
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground',
)}
aria-label="Xem trên bản đồ"
>
<Map className="h-4 w-4" />
</button>
</div>
</div>
{/* Filters */}
<ProjectFilterBar filters={filters} onFilterChange={handleFilterChange} />
{/* Results */}
<div className="mt-6">
{isLoading ? (
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i}
className="h-72 animate-pulse rounded-lg bg-muted"
/>
))}
</div>
) : isError ? (
<div className="py-12 text-center">
<p className="text-muted-foreground">
Không thể tải danh sách dự án. Vui lòng thử lại.
</p>
<Button
variant="outline"
className="mt-4"
onClick={() => setFilters({ ...filters })}
>
Thử lại
</Button>
</div>
) : data && data.data.length > 0 ? (
<>
<p className="mb-4 text-sm text-muted-foreground">
{data.total} dự án đưc tìm thấy
</p>
{viewMode === 'map' ? (
<ProjectMap projects={data.data} />
) : viewMode === 'list' ? (
<div className="space-y-4">
{data.data.map((project) => (
<ProjectListItem key={project.id} project={project} />
))}
</div>
) : (
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{data.data.map((project) => (
<ProjectCard key={project.id} project={project} />
))}
</div>
)}
{/* Pagination (grid/list mode) */}
{viewMode !== 'map' && data.totalPages > 1 && (
<div className="mt-8 flex items-center justify-center gap-2">
<Button
variant="outline"
size="sm"
disabled={filters.page === 1}
onClick={() => handlePageChange((filters.page || 1) - 1)}
>
Trước
</Button>
<span className="text-sm text-muted-foreground">
Trang {data.page} / {data.totalPages}
</span>
<Button
variant="outline"
size="sm"
disabled={data.page >= data.totalPages}
onClick={() => handlePageChange((filters.page || 1) + 1)}
>
Sau
</Button>
</div>
)}
</>
) : (
<div className="py-12 text-center">
<Building2 className="mx-auto h-12 w-12 text-muted-foreground/30" />
<p className="mt-4 text-lg font-medium">Không tìm thấy dự án</p>
<p className="mt-1 text-sm text-muted-foreground">
Thử thay đi bộ lọc đ tìm kiếm nhiều hơn
</p>
</div>
)}
</div>
</div>
);
}
function ProjectListItem({ project }: { project: ProjectSummary }) {
const statusLabel = PROJECT_STATUS_LABELS[project.status];
const statusColor = PROJECT_STATUS_COLORS[project.status];
const propertyLabels = project.propertyTypes
.map((t) => PROJECT_PROPERTY_TYPE_LABELS[t])
.join(', ');
return (
<Link href={`/du-an/${project.slug}`}>
<Card className="group flex overflow-hidden transition-shadow hover:shadow-lg">
{/* Thumbnail */}
<div className="relative aspect-[4/3] w-48 shrink-0 overflow-hidden bg-muted sm:w-56 md:w-64">
{project.thumbnailUrl ? (
<Image
src={project.thumbnailUrl}
alt={project.name}
fill
className="object-cover transition-transform group-hover:scale-105"
sizes="256px"
/>
) : (
<div className="flex h-full items-center justify-center">
<Building2 className="h-10 w-10 text-muted-foreground/30" />
</div>
)}
<Badge
className={cn('absolute left-2 top-2 text-xs', statusColor)}
variant="secondary"
>
{statusLabel}
</Badge>
</div>
{/* Content */}
<div className="flex min-w-0 flex-1 flex-col justify-between p-4">
<div>
<h3 className="line-clamp-1 text-base font-semibold group-hover:text-primary">
{project.name}
</h3>
<div className="mt-1 flex items-center gap-1 text-sm text-muted-foreground">
<MapPin className="h-3.5 w-3.5 shrink-0" />
<span className="line-clamp-1">
{project.district}, {project.city}
</span>
</div>
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span>{propertyLabels}</span>
<span>·</span>
<span>{project.totalUnits} căn</span>
<span>·</span>
<span>{project.developer.name}</span>
</div>
</div>
<div className="mt-2">
{project.minPrice ? (
<p className="text-sm font-bold text-primary">
{formatPrice(project.minPrice)}
{project.maxPrice && project.maxPrice !== project.minPrice && (
<span> {formatPrice(project.maxPrice)}</span>
)}
</p>
) : (
<p className="text-sm text-muted-foreground">Liên hệ</p>
)}
</div>
</div>
</Card>
</Link>
);
}