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:
63
apps/web/components/comparison/compare-floating-bar.tsx
Normal file
63
apps/web/components/comparison/compare-floating-bar.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
'use client';
|
||||
|
||||
import { BarChart3, X } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Link } from '@/i18n/navigation';
|
||||
import { useComparisonStore, MAX_COMPARE } from '@/lib/comparison-store';
|
||||
|
||||
export function CompareFloatingBar() {
|
||||
const t = useTranslations('compare');
|
||||
const selectedIds = useComparisonStore((s) => s.selectedIds);
|
||||
const clearAll = useComparisonStore((s) => s.clearAll);
|
||||
const removeFromCompare = useComparisonStore((s) => s.removeFromCompare);
|
||||
const canCompare = useComparisonStore((s) => s.canCompare());
|
||||
|
||||
if (selectedIds.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-0 left-0 right-0 z-50 border-t bg-background/95 p-3 shadow-lg backdrop-blur supports-[backdrop-filter]:bg-background/80">
|
||||
<div className="mx-auto flex max-w-7xl flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart3 className="h-5 w-5 text-primary" />
|
||||
<span className="text-sm font-medium">
|
||||
{t('selected', { count: selectedIds.length, max: MAX_COMPARE })}
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
{selectedIds.map((id) => (
|
||||
<Badge key={id} variant="secondary" className="gap-1 text-xs">
|
||||
{id.slice(0, 8)}...
|
||||
<button
|
||||
onClick={() => removeFromCompare(id)}
|
||||
className="ml-0.5 rounded-full hover:bg-muted"
|
||||
aria-label={t('removeItem')}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={clearAll}>
|
||||
{t('clearAll')}
|
||||
</Button>
|
||||
{canCompare ? (
|
||||
<Link href="/compare">
|
||||
<Button size="sm" className="gap-1">
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
{t('compareNow')}
|
||||
</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<Button size="sm" disabled className="gap-1">
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
{t('needMore')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user