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:
130
apps/web/app/[locale]/(public)/compare/page.tsx
Normal file
130
apps/web/app/[locale]/(public)/compare/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user