feat(web): add property comparison page with side-by-side view

Build a complete property comparison feature at /compare:
- Zustand store with localStorage persistence for selected listings (2-5)
- Side-by-side comparison table (price, area, price/m², amenities, location, etc.)
- Summary statistics banner (price range, area range, price/m² range)
- "Add to Compare" button on property cards and detail pages
- Floating comparison bar for quick access when listings are selected
- Bilingual i18n support (Vietnamese + English)
- 18 unit tests for store logic and comparison stats computation
- Mobile-responsive layout with horizontal scroll on comparison table

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-10 23:55:50 +07:00
parent 55a01c5738
commit 37fab515b7
13 changed files with 1092 additions and 0 deletions

View File

@@ -0,0 +1,130 @@
'use client';
import { ArrowLeft, BarChart3 } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useEffect } from 'react';
import { ComparisonStatsBanner } from '@/components/comparison/comparison-stats';
import { ComparisonTable } from '@/components/comparison/comparison-table';
import { Button } from '@/components/ui/button';
import { Link } from '@/i18n/navigation';
import { fetchListingsForComparison } from '@/lib/comparison-api';
import { useComparisonStore, computeComparisonStats, MIN_COMPARE } from '@/lib/comparison-store';
export default function ComparePage() {
const t = useTranslations('compare');
const selectedIds = useComparisonStore((s) => s.selectedIds);
const listings = useComparisonStore((s) => s.listings);
const isLoading = useComparisonStore((s) => s.isLoading);
const error = useComparisonStore((s) => s.error);
const setListings = useComparisonStore((s) => s.setListings);
const setLoading = useComparisonStore((s) => s.setLoading);
const setError = useComparisonStore((s) => s.setError);
const removeFromCompare = useComparisonStore((s) => s.removeFromCompare);
const clearAll = useComparisonStore((s) => s.clearAll);
useEffect(() => {
if (selectedIds.length < MIN_COMPARE) return;
let cancelled = false;
async function load() {
setLoading(true);
try {
const data = await fetchListingsForComparison(selectedIds);
if (!cancelled) {
setListings(data);
}
} catch {
if (!cancelled) {
setError(t('loadError'));
}
}
}
void load();
return () => {
cancelled = true;
};
}, [selectedIds.join(',')]);
const stats = computeComparisonStats(listings);
// Empty state — not enough listings selected
if (selectedIds.length < MIN_COMPARE) {
return (
<div className="mx-auto max-w-7xl px-4 py-16">
<div className="flex flex-col items-center justify-center text-center">
<BarChart3 className="mb-4 h-16 w-16 text-muted-foreground/50" />
<h1 className="text-2xl font-bold">{t('title')}</h1>
<p className="mt-2 text-muted-foreground">{t('emptyState')}</p>
<Link href="/search" className="mt-6">
<Button className="gap-2">
<ArrowLeft className="h-4 w-4" />
{t('goToSearch')}
</Button>
</Link>
</div>
</div>
);
}
// Loading state
if (isLoading) {
return (
<div className="mx-auto max-w-7xl px-4 py-16">
<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>
</div>
);
}
// Error state
if (error) {
return (
<div className="mx-auto max-w-7xl px-4 py-16">
<div className="flex flex-col items-center justify-center gap-3 text-muted-foreground">
<p className="text-lg font-medium">{error}</p>
<Button variant="outline" size="sm" onClick={() => window.location.reload()}>
{t('retry')}
</Button>
</div>
</div>
);
}
return (
<div className="mx-auto max-w-7xl px-4 py-8">
{/* Header */}
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold">{t('title')}</h1>
<p className="mt-1 text-sm text-muted-foreground">
{t('subtitle', { count: listings.length })}
</p>
</div>
<div className="flex gap-2">
<Link href="/search">
<Button variant="outline" size="sm" className="gap-1">
<ArrowLeft className="h-4 w-4" />
{t('addMore')}
</Button>
</Link>
<Button variant="ghost" size="sm" onClick={clearAll}>
{t('clearAll')}
</Button>
</div>
</div>
{/* Stats summary */}
{stats && (
<div className="mb-6">
<ComparisonStatsBanner stats={stats} />
</div>
)}
{/* Comparison table */}
<ComparisonTable listings={listings} onRemove={removeFromCompare} />
</div>
);
}

View File

@@ -4,6 +4,7 @@ import { Menu, X } from 'lucide-react';
import { usePathname } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { CompareFloatingBar } from '@/components/comparison/compare-floating-bar';
import { Button } from '@/components/ui/button';
import { LanguageSwitcher } from '@/components/ui/language-switcher';
import { Link } from '@/i18n/navigation';
@@ -149,6 +150,8 @@ export default function PublicLayout({ children }: { children: React.ReactNode }
{children}
</main>
<CompareFloatingBar />
<footer role="contentinfo" className="border-t bg-muted/40">
<div className="mx-auto max-w-7xl px-4 py-8">
<div className="grid gap-8 sm:grid-cols-2 lg:grid-cols-4">