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,62 @@
'use client';
import { BarChart3, Check } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { Button, type ButtonProps } from '@/components/ui/button';
import { useComparisonStore } from '@/lib/comparison-store';
interface AddToCompareButtonProps extends Omit<ButtonProps, 'onClick'> {
listingId: string;
compact?: boolean;
}
export function AddToCompareButton({ listingId, compact, ...props }: AddToCompareButtonProps) {
const t = useTranslations('compare');
const isSelected = useComparisonStore((s) => s.isSelected(listingId));
const addToCompare = useComparisonStore((s) => s.addToCompare);
const removeFromCompare = useComparisonStore((s) => s.removeFromCompare);
const canAdd = useComparisonStore((s) => s.canAdd());
const handleClick = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (isSelected) {
removeFromCompare(listingId);
} else {
addToCompare(listingId);
}
};
if (compact) {
return (
<button
onClick={handleClick}
disabled={!isSelected && !canAdd}
className={`inline-flex items-center justify-center rounded-full p-1.5 transition-colors ${
isSelected
? 'bg-primary text-primary-foreground'
: 'bg-background/80 text-muted-foreground hover:bg-accent hover:text-accent-foreground'
} ${!isSelected && !canAdd ? 'opacity-50 cursor-not-allowed' : ''}`}
aria-label={isSelected ? t('removeFromCompare') : t('addToCompare')}
title={isSelected ? t('removeFromCompare') : t('addToCompare')}
>
{isSelected ? <Check className="h-4 w-4" /> : <BarChart3 className="h-4 w-4" />}
</button>
);
}
return (
<Button
variant={isSelected ? 'default' : 'outline'}
size="sm"
onClick={handleClick}
disabled={!isSelected && !canAdd}
className="gap-1.5"
aria-label={isSelected ? t('removeFromCompare') : t('addToCompare')}
{...props}
>
{isSelected ? <Check className="h-4 w-4" /> : <BarChart3 className="h-4 w-4" />}
{isSelected ? t('added') : t('addToCompare')}
</Button>
);
}

View 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>
);
}

View File

@@ -0,0 +1,68 @@
'use client';
import { useTranslations } from 'next-intl';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import type { ComparisonStats } from '@/lib/comparison-store';
import { formatPrice, formatPricePerM2 } from '@/lib/currency';
interface ComparisonStatsBannerProps {
stats: ComparisonStats;
}
export function ComparisonStatsBanner({ stats }: ComparisonStatsBannerProps) {
const t = useTranslations('compare');
return (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{t('priceRange')}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-lg font-bold text-primary">
{formatPrice(stats.priceRange.min)} {formatPrice(stats.priceRange.max)}
</p>
<p className="mt-1 text-sm text-muted-foreground">
{t('average')}: {formatPrice(stats.priceRange.avg)} VND
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{t('areaRange')}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-lg font-bold">
{stats.areaRange.min} {stats.areaRange.max} m²
</p>
<p className="mt-1 text-sm text-muted-foreground">
{t('average')}: {stats.areaRange.avg} m²
</p>
</CardContent>
</Card>
{stats.pricePerM2Range && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{t('pricePerM2Range')}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-lg font-bold">
{formatPricePerM2(stats.pricePerM2Range.min)} {formatPricePerM2(stats.pricePerM2Range.max)}
</p>
<p className="mt-1 text-sm text-muted-foreground">
{t('average')}: {formatPricePerM2(stats.pricePerM2Range.avg)}
</p>
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,271 @@
'use client';
import { X } from 'lucide-react';
import Image from 'next/image';
import { useTranslations } from 'next-intl';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Link } from '@/i18n/navigation';
import { formatPrice, formatPricePerM2 } from '@/lib/currency';
import type { ListingDetail } from '@/lib/listings-api';
const PROPERTY_TYPE_LABELS: Record<string, string> = {
APARTMENT: 'Căn hộ',
HOUSE: 'Nhà riêng',
VILLA: 'Biệt thự',
LAND: 'Đất nền',
OFFICE: 'Văn phòng',
SHOPHOUSE: 'Shophouse',
};
const DIRECTION_LABELS: Record<string, string> = {
NORTH: 'Bắc',
SOUTH: 'Nam',
EAST: 'Đông',
WEST: 'Tây',
NORTHEAST: 'Đông Bắc',
NORTHWEST: 'Tây Bắc',
SOUTHEAST: 'Đông Nam',
SOUTHWEST: 'Tây Nam',
};
interface ComparisonTableProps {
listings: ListingDetail[];
onRemove: (id: string) => void;
}
interface ComparisonRowProps {
label: string;
values: React.ReactNode[];
highlight?: boolean;
}
function ComparisonRow({ label, values, highlight }: ComparisonRowProps) {
return (
<tr className={highlight ? 'bg-muted/50' : ''}>
<td className="whitespace-nowrap border-r px-4 py-3 text-sm font-medium text-muted-foreground">
{label}
</td>
{values.map((val, i) => (
<td key={i} className="border-r px-4 py-3 text-sm last:border-r-0">
{val}
</td>
))}
</tr>
);
}
export function ComparisonTable({ listings, onRemove }: ComparisonTableProps) {
const t = useTranslations('compare');
if (listings.length === 0) return null;
return (
<div className="overflow-x-auto rounded-lg border">
<table className="w-full min-w-[600px] caption-bottom text-sm">
<thead>
<tr className="border-b bg-muted/30">
<th className="w-40 border-r px-4 py-3 text-left text-sm font-medium text-muted-foreground">
{t('property')}
</th>
{listings.map((listing) => (
<th key={listing.id} className="border-r px-4 py-3 last:border-r-0">
<div className="flex flex-col items-center gap-2">
{/* Image */}
<div className="relative aspect-[4/3] w-full max-w-[200px] overflow-hidden rounded-md bg-muted">
{listing.property.media.length > 0 ? (
<Image
src={listing.property.media[0]?.url ?? ''}
alt={listing.property.title}
fill
sizes="200px"
className="object-cover"
/>
) : (
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">
{t('noImage')}
</div>
)}
</div>
{/* Title */}
<Link
href={`/listings/${listing.id}` as '/listings/[id]'}
className="line-clamp-2 text-center text-sm font-semibold hover:text-primary"
>
{listing.property.title}
</Link>
{/* Remove button */}
<Button
variant="ghost"
size="sm"
className="h-7 gap-1 text-xs text-muted-foreground hover:text-destructive"
onClick={() => onRemove(listing.id)}
aria-label={`${t('remove')} ${listing.property.title}`}
>
<X className="h-3 w-3" />
{t('remove')}
</Button>
</div>
</th>
))}
</tr>
</thead>
<tbody>
{/* Price */}
<ComparisonRow
label={t('price')}
highlight
values={listings.map((l) => (
<span key={l.id} className="font-bold text-primary">
{formatPrice(l.priceVND)} VND
{l.transactionType === 'RENT' && l.rentPriceMonthly && (
<span className="block text-xs font-normal text-muted-foreground">/tháng</span>
)}
</span>
))}
/>
{/* Transaction type */}
<ComparisonRow
label={t('transactionType')}
values={listings.map((l) => (
<Badge key={l.id} variant="default" className="text-xs">
{l.transactionType === 'SALE' ? t('sale') : t('rent')}
</Badge>
))}
/>
{/* Property type */}
<ComparisonRow
label={t('propertyType')}
highlight
values={listings.map((l) => (
<span key={l.id}>
{PROPERTY_TYPE_LABELS[l.property.propertyType] || l.property.propertyType}
</span>
))}
/>
{/* Area */}
<ComparisonRow
label={t('area')}
values={listings.map((l) => (
<span key={l.id} className="font-medium">{l.property.areaM2} m²</span>
))}
/>
{/* Price per m² */}
<ComparisonRow
label={t('pricePerM2')}
highlight
values={listings.map((l) => (
<span key={l.id}>
{l.pricePerM2 != null ? formatPricePerM2(l.pricePerM2) : '—'}
</span>
))}
/>
{/* Bedrooms */}
<ComparisonRow
label={t('bedrooms')}
values={listings.map((l) => (
<span key={l.id}>
{l.property.bedrooms != null ? `${l.property.bedrooms} ${t('rooms')}` : '—'}
</span>
))}
/>
{/* Bathrooms */}
<ComparisonRow
label={t('bathrooms')}
highlight
values={listings.map((l) => (
<span key={l.id}>
{l.property.bathrooms != null ? `${l.property.bathrooms} ${t('rooms')}` : '—'}
</span>
))}
/>
{/* Direction */}
<ComparisonRow
label={t('direction')}
values={listings.map((l) => (
<span key={l.id}>
{l.property.direction ? DIRECTION_LABELS[l.property.direction] || l.property.direction : '—'}
</span>
))}
/>
{/* Floors */}
<ComparisonRow
label={t('floors')}
highlight
values={listings.map((l) => (
<span key={l.id}>
{l.property.floors != null ? l.property.floors : '—'}
</span>
))}
/>
{/* Year built */}
<ComparisonRow
label={t('yearBuilt')}
values={listings.map((l) => (
<span key={l.id}>
{l.property.yearBuilt != null ? l.property.yearBuilt : '—'}
</span>
))}
/>
{/* Legal status */}
<ComparisonRow
label={t('legalStatus')}
highlight
values={listings.map((l) => (
<span key={l.id}>
{l.property.legalStatus || '—'}
</span>
))}
/>
{/* Location */}
<ComparisonRow
label={t('location')}
values={listings.map((l) => (
<span key={l.id} className="text-xs">
{l.property.address}, {l.property.district}, {l.property.city}
</span>
))}
/>
{/* Amenities */}
<ComparisonRow
label={t('amenities')}
highlight
values={listings.map((l) => (
<div key={l.id} className="flex flex-wrap gap-1">
{l.property.amenities && l.property.amenities.length > 0
? l.property.amenities.map((a) => (
<Badge key={a} variant="outline" className="text-xs">
{a}
</Badge>
))
: '—'}
</div>
))}
/>
{/* Project name */}
<ComparisonRow
label={t('projectName')}
values={listings.map((l) => (
<span key={l.id}>
{l.property.projectName || '—'}
</span>
))}
/>
</tbody>
</table>
</div>
);
}

View File

@@ -3,6 +3,7 @@
import dynamic from 'next/dynamic';
import Link from 'next/link';
import * as React from 'react';
import { AddToCompareButton } from '@/components/comparison/add-to-compare-button';
import { ImageGallery } from '@/components/listings/image-gallery';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@@ -81,6 +82,9 @@ export function ListingDetailClient({ listing }: ListingDetailClientProps) {
Thu\u00ea: {formatPrice(listing.rentPriceMonthly)}/th\u00e1ng
</p>
)}
<div className="mt-3">
<AddToCompareButton listingId={listing.id} />
</div>
</div>
</div>

View File

@@ -1,5 +1,6 @@
import Image from 'next/image';
import Link from 'next/link';
import { AddToCompareButton } from '@/components/comparison/add-to-compare-button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import { formatPrice } from '@/lib/currency';
@@ -58,6 +59,9 @@ export function PropertyCard({ listing, compact }: PropertyCardProps) {
</Badge>
</div>
)}
<div className="absolute right-2 top-2">
<AddToCompareButton listingId={listing.id} compact />
</div>
</div>
<CardContent className="p-4">
<p className="text-lg font-bold text-primary">