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:
62
apps/web/components/comparison/add-to-compare-button.tsx
Normal file
62
apps/web/components/comparison/add-to-compare-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
68
apps/web/components/comparison/comparison-stats.tsx
Normal file
68
apps/web/components/comparison/comparison-stats.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
271
apps/web/components/comparison/comparison-table.tsx
Normal file
271
apps/web/components/comparison/comparison-table.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user