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">
|
||||
|
||||
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">
|
||||
|
||||
270
apps/web/lib/__tests__/comparison-store.spec.ts
Normal file
270
apps/web/lib/__tests__/comparison-store.spec.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
// Provide a minimal localStorage mock before importing the store module
|
||||
// (Zustand persist needs getItem/setItem)
|
||||
const store: Record<string, string> = {};
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: (key: string) => store[key] ?? null,
|
||||
setItem: (key: string, value: string) => { store[key] = value; },
|
||||
removeItem: (key: string) => { delete store[key]; },
|
||||
clear: () => { for (const k of Object.keys(store)) delete store[k]; },
|
||||
get length() { return Object.keys(store).length; },
|
||||
key: (i: number) => Object.keys(store)[i] ?? null,
|
||||
});
|
||||
|
||||
// Now import after mocks are in place
|
||||
const { useComparisonStore, computeComparisonStats, MAX_COMPARE, MIN_COMPARE } = await import('../comparison-store');
|
||||
type ListingDetail = import('../listings-api').ListingDetail;
|
||||
|
||||
// Reset Zustand store between tests
|
||||
beforeEach(() => {
|
||||
useComparisonStore.setState({
|
||||
selectedIds: [],
|
||||
listings: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Store — addToCompare / removeFromCompare / isSelected
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('useComparisonStore', () => {
|
||||
it('adds a listing ID to the comparison set', () => {
|
||||
const { addToCompare } = useComparisonStore.getState();
|
||||
const result = addToCompare('listing-1');
|
||||
expect(result).toBe(true);
|
||||
expect(useComparisonStore.getState().selectedIds).toEqual(['listing-1']);
|
||||
});
|
||||
|
||||
it('does not add duplicate IDs', () => {
|
||||
const { addToCompare } = useComparisonStore.getState();
|
||||
addToCompare('listing-1');
|
||||
const result = addToCompare('listing-1');
|
||||
expect(result).toBe(false);
|
||||
expect(useComparisonStore.getState().selectedIds).toEqual(['listing-1']);
|
||||
});
|
||||
|
||||
it('respects MAX_COMPARE limit', () => {
|
||||
const { addToCompare } = useComparisonStore.getState();
|
||||
for (let i = 1; i <= MAX_COMPARE; i++) {
|
||||
expect(addToCompare(`listing-${i}`)).toBe(true);
|
||||
}
|
||||
const result = addToCompare(`listing-${MAX_COMPARE + 1}`);
|
||||
expect(result).toBe(false);
|
||||
expect(useComparisonStore.getState().selectedIds.length).toBe(MAX_COMPARE);
|
||||
});
|
||||
|
||||
it('removes a listing ID from the comparison set', () => {
|
||||
const store = useComparisonStore.getState();
|
||||
store.addToCompare('listing-1');
|
||||
store.addToCompare('listing-2');
|
||||
store.removeFromCompare('listing-1');
|
||||
expect(useComparisonStore.getState().selectedIds).toEqual(['listing-2']);
|
||||
});
|
||||
|
||||
it('isSelected returns correct value', () => {
|
||||
const store = useComparisonStore.getState();
|
||||
store.addToCompare('listing-1');
|
||||
expect(store.isSelected('listing-1')).toBe(true);
|
||||
expect(store.isSelected('listing-2')).toBe(false);
|
||||
});
|
||||
|
||||
it('clearAll resets the store', () => {
|
||||
const store = useComparisonStore.getState();
|
||||
store.addToCompare('listing-1');
|
||||
store.addToCompare('listing-2');
|
||||
store.clearAll();
|
||||
const state = useComparisonStore.getState();
|
||||
expect(state.selectedIds).toEqual([]);
|
||||
expect(state.listings).toEqual([]);
|
||||
expect(state.error).toBeNull();
|
||||
});
|
||||
|
||||
it('canCompare returns true when >= MIN_COMPARE items selected', () => {
|
||||
const store = useComparisonStore.getState();
|
||||
store.addToCompare('listing-1');
|
||||
expect(useComparisonStore.getState().canCompare()).toBe(false);
|
||||
store.addToCompare('listing-2');
|
||||
expect(useComparisonStore.getState().canCompare()).toBe(true);
|
||||
});
|
||||
|
||||
it('canAdd returns false when MAX_COMPARE items selected', () => {
|
||||
const store = useComparisonStore.getState();
|
||||
for (let i = 1; i <= MAX_COMPARE; i++) {
|
||||
store.addToCompare(`listing-${i}`);
|
||||
}
|
||||
expect(useComparisonStore.getState().canAdd()).toBe(false);
|
||||
});
|
||||
|
||||
it('setListings stores listing data and clears loading/error', () => {
|
||||
const store = useComparisonStore.getState();
|
||||
store.setLoading(true);
|
||||
store.setError('some error');
|
||||
const mockListings = [makeListing('1'), makeListing('2')];
|
||||
store.setListings(mockListings);
|
||||
const state = useComparisonStore.getState();
|
||||
expect(state.listings).toEqual(mockListings);
|
||||
expect(state.isLoading).toBe(false);
|
||||
expect(state.error).toBeNull();
|
||||
});
|
||||
|
||||
it('removeFromCompare also removes from listings', () => {
|
||||
const store = useComparisonStore.getState();
|
||||
store.addToCompare('listing-1');
|
||||
store.addToCompare('listing-2');
|
||||
store.setListings([makeListing('listing-1'), makeListing('listing-2')]);
|
||||
store.removeFromCompare('listing-1');
|
||||
const state = useComparisonStore.getState();
|
||||
expect(state.selectedIds).toEqual(['listing-2']);
|
||||
expect(state.listings.length).toBe(1);
|
||||
expect(state.listings[0]?.id).toBe('listing-2');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// computeComparisonStats
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('computeComparisonStats', () => {
|
||||
it('returns null for fewer than MIN_COMPARE listings', () => {
|
||||
expect(computeComparisonStats([])).toBeNull();
|
||||
expect(computeComparisonStats([makeListing('1')])).toBeNull();
|
||||
});
|
||||
|
||||
it('computes correct price range', () => {
|
||||
const listings = [
|
||||
makeListing('1', { priceVND: '1000000000' }),
|
||||
makeListing('2', { priceVND: '2000000000' }),
|
||||
makeListing('3', { priceVND: '3000000000' }),
|
||||
];
|
||||
const stats = computeComparisonStats(listings);
|
||||
expect(stats).not.toBeNull();
|
||||
expect(stats!.priceRange.min).toBe(1_000_000_000);
|
||||
expect(stats!.priceRange.max).toBe(3_000_000_000);
|
||||
expect(stats!.priceRange.avg).toBe(2_000_000_000);
|
||||
});
|
||||
|
||||
it('computes correct area range', () => {
|
||||
const listings = [
|
||||
makeListing('1', { areaM2: 50 }),
|
||||
makeListing('2', { areaM2: 100 }),
|
||||
];
|
||||
const stats = computeComparisonStats(listings);
|
||||
expect(stats).not.toBeNull();
|
||||
expect(stats!.areaRange.min).toBe(50);
|
||||
expect(stats!.areaRange.max).toBe(100);
|
||||
expect(stats!.areaRange.avg).toBe(75);
|
||||
});
|
||||
|
||||
it('computes pricePerM2Range when data is available', () => {
|
||||
const listings = [
|
||||
makeListing('1', { pricePerM2: 20_000_000 }),
|
||||
makeListing('2', { pricePerM2: 40_000_000 }),
|
||||
];
|
||||
const stats = computeComparisonStats(listings);
|
||||
expect(stats).not.toBeNull();
|
||||
expect(stats!.pricePerM2Range).not.toBeNull();
|
||||
expect(stats!.pricePerM2Range!.min).toBe(20_000_000);
|
||||
expect(stats!.pricePerM2Range!.max).toBe(40_000_000);
|
||||
expect(stats!.pricePerM2Range!.avg).toBe(30_000_000);
|
||||
});
|
||||
|
||||
it('returns null pricePerM2Range when no pricePerM2 data', () => {
|
||||
const listings = [
|
||||
makeListing('1', { pricePerM2: null }),
|
||||
makeListing('2', { pricePerM2: null }),
|
||||
];
|
||||
const stats = computeComparisonStats(listings);
|
||||
expect(stats).not.toBeNull();
|
||||
expect(stats!.pricePerM2Range).toBeNull();
|
||||
});
|
||||
|
||||
it('handles mixed pricePerM2 availability', () => {
|
||||
const listings = [
|
||||
makeListing('1', { pricePerM2: 30_000_000 }),
|
||||
makeListing('2', { pricePerM2: null }),
|
||||
makeListing('3', { pricePerM2: 50_000_000 }),
|
||||
];
|
||||
const stats = computeComparisonStats(listings);
|
||||
expect(stats).not.toBeNull();
|
||||
expect(stats!.pricePerM2Range).not.toBeNull();
|
||||
expect(stats!.pricePerM2Range!.min).toBe(30_000_000);
|
||||
expect(stats!.pricePerM2Range!.max).toBe(50_000_000);
|
||||
expect(stats!.pricePerM2Range!.avg).toBe(40_000_000);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('constants', () => {
|
||||
it('MAX_COMPARE is 5', () => {
|
||||
expect(MAX_COMPARE).toBe(5);
|
||||
});
|
||||
|
||||
it('MIN_COMPARE is 2', () => {
|
||||
expect(MIN_COMPARE).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeListing(
|
||||
id: string,
|
||||
overrides: {
|
||||
priceVND?: string;
|
||||
areaM2?: number;
|
||||
pricePerM2?: number | null;
|
||||
} = {},
|
||||
): ListingDetail {
|
||||
return {
|
||||
id,
|
||||
status: 'ACTIVE',
|
||||
transactionType: 'SALE',
|
||||
priceVND: overrides.priceVND ?? '1500000000',
|
||||
pricePerM2: overrides.pricePerM2 !== undefined ? overrides.pricePerM2 : 30_000_000,
|
||||
rentPriceMonthly: null,
|
||||
commissionPct: null,
|
||||
viewCount: 0,
|
||||
saveCount: 0,
|
||||
inquiryCount: 0,
|
||||
publishedAt: null,
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
property: {
|
||||
id: `prop-${id}`,
|
||||
propertyType: 'APARTMENT',
|
||||
title: `Test Property ${id}`,
|
||||
description: 'A test property',
|
||||
address: '123 Test St',
|
||||
ward: 'Ward 1',
|
||||
district: 'District 1',
|
||||
city: 'Ho Chi Minh',
|
||||
areaM2: overrides.areaM2 ?? 80,
|
||||
bedrooms: 2,
|
||||
bathrooms: 2,
|
||||
floors: null,
|
||||
direction: null,
|
||||
yearBuilt: null,
|
||||
legalStatus: null,
|
||||
amenities: [],
|
||||
projectName: null,
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
media: [],
|
||||
},
|
||||
seller: {
|
||||
id: 'seller-1',
|
||||
fullName: 'Test Seller',
|
||||
phone: '0912345678',
|
||||
},
|
||||
agent: null,
|
||||
};
|
||||
}
|
||||
15
apps/web/lib/comparison-api.ts
Normal file
15
apps/web/lib/comparison-api.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { listingsApi, type ListingDetail } from './listings-api';
|
||||
|
||||
/**
|
||||
* Fetch multiple listing details in parallel for comparison.
|
||||
* Returns only successfully fetched listings (silently skips 404s).
|
||||
*/
|
||||
export async function fetchListingsForComparison(
|
||||
ids: string[],
|
||||
): Promise<ListingDetail[]> {
|
||||
const results = await Promise.allSettled(ids.map((id) => listingsApi.getById(id)));
|
||||
|
||||
return results
|
||||
.filter((r): r is PromiseFulfilledResult<ListingDetail> => r.status === 'fulfilled')
|
||||
.map((r) => r.value);
|
||||
}
|
||||
120
apps/web/lib/comparison-store.ts
Normal file
120
apps/web/lib/comparison-store.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { ListingDetail } from './listings-api';
|
||||
|
||||
const MAX_COMPARE = 5;
|
||||
const MIN_COMPARE = 2;
|
||||
|
||||
export interface ComparisonStats {
|
||||
priceRange: { min: number; max: number; avg: number };
|
||||
areaRange: { min: number; max: number; avg: number };
|
||||
pricePerM2Range: { min: number; max: number; avg: number } | null;
|
||||
}
|
||||
|
||||
export interface ComparisonState {
|
||||
/** Listing IDs selected for comparison */
|
||||
selectedIds: string[];
|
||||
/** Cached listing details (loaded when user navigates to compare page) */
|
||||
listings: ListingDetail[];
|
||||
/** Whether listings data is loading */
|
||||
isLoading: boolean;
|
||||
/** Error message if fetching failed */
|
||||
error: string | null;
|
||||
|
||||
/** Add a listing ID to the comparison set (max 5) */
|
||||
addToCompare: (id: string) => boolean;
|
||||
/** Remove a listing ID from the comparison set */
|
||||
removeFromCompare: (id: string) => void;
|
||||
/** Check if a listing ID is already selected */
|
||||
isSelected: (id: string) => boolean;
|
||||
/** Clear all selections */
|
||||
clearAll: () => void;
|
||||
/** Whether the compare button should be active */
|
||||
canCompare: () => boolean;
|
||||
/** Whether more listings can be added */
|
||||
canAdd: () => boolean;
|
||||
/** Set fetched listings data */
|
||||
setListings: (listings: ListingDetail[]) => void;
|
||||
/** Set loading state */
|
||||
setLoading: (loading: boolean) => void;
|
||||
/** Set error state */
|
||||
setError: (error: string | null) => void;
|
||||
}
|
||||
|
||||
export function computeComparisonStats(listings: ListingDetail[]): ComparisonStats | null {
|
||||
if (listings.length < MIN_COMPARE) return null;
|
||||
|
||||
const prices = listings.map((l) => Number(l.priceVND)).filter(Number.isFinite);
|
||||
const areas = listings.map((l) => l.property.areaM2).filter(Number.isFinite);
|
||||
const pricesPerM2 = listings
|
||||
.map((l) => l.pricePerM2)
|
||||
.filter((v): v is number => v != null && Number.isFinite(v));
|
||||
|
||||
if (prices.length === 0 || areas.length === 0) return null;
|
||||
|
||||
return {
|
||||
priceRange: {
|
||||
min: Math.min(...prices),
|
||||
max: Math.max(...prices),
|
||||
avg: Math.round(prices.reduce((a, b) => a + b, 0) / prices.length),
|
||||
},
|
||||
areaRange: {
|
||||
min: Math.min(...areas),
|
||||
max: Math.max(...areas),
|
||||
avg: Math.round((areas.reduce((a, b) => a + b, 0) / areas.length) * 100) / 100,
|
||||
},
|
||||
pricePerM2Range:
|
||||
pricesPerM2.length > 0
|
||||
? {
|
||||
min: Math.min(...pricesPerM2),
|
||||
max: Math.max(...pricesPerM2),
|
||||
avg: Math.round(pricesPerM2.reduce((a, b) => a + b, 0) / pricesPerM2.length),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
export const useComparisonStore = create<ComparisonState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
selectedIds: [],
|
||||
listings: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
addToCompare: (id: string) => {
|
||||
const { selectedIds } = get();
|
||||
if (selectedIds.length >= MAX_COMPARE || selectedIds.includes(id)) return false;
|
||||
set({ selectedIds: [...selectedIds, id], error: null });
|
||||
return true;
|
||||
},
|
||||
|
||||
removeFromCompare: (id: string) => {
|
||||
set((state) => ({
|
||||
selectedIds: state.selectedIds.filter((sid) => sid !== id),
|
||||
listings: state.listings.filter((l) => l.id !== id),
|
||||
}));
|
||||
},
|
||||
|
||||
isSelected: (id: string) => get().selectedIds.includes(id),
|
||||
|
||||
clearAll: () => set({ selectedIds: [], listings: [], error: null }),
|
||||
|
||||
canCompare: () => get().selectedIds.length >= MIN_COMPARE,
|
||||
|
||||
canAdd: () => get().selectedIds.length < MAX_COMPARE,
|
||||
|
||||
setListings: (listings: ListingDetail[]) => set({ listings, isLoading: false, error: null }),
|
||||
|
||||
setLoading: (isLoading: boolean) => set({ isLoading }),
|
||||
|
||||
setError: (error: string | null) => set({ error, isLoading: false }),
|
||||
}),
|
||||
{
|
||||
name: 'goodgo-compare',
|
||||
partialize: (state) => ({ selectedIds: state.selectedIds }),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
export { MAX_COMPARE, MIN_COMPARE };
|
||||
@@ -215,5 +215,46 @@
|
||||
"10to20b": "10 - 20 billion",
|
||||
"over20b": "Over 20 billion"
|
||||
}
|
||||
},
|
||||
"compare": {
|
||||
"title": "Compare properties",
|
||||
"subtitle": "Comparing {count} properties",
|
||||
"emptyState": "Select at least 2 properties to compare. Go back to search to select.",
|
||||
"goToSearch": "Go to search",
|
||||
"addMore": "Add more",
|
||||
"clearAll": "Clear all",
|
||||
"compareNow": "Compare now",
|
||||
"needMore": "Need more",
|
||||
"selected": "{count}/{max} selected",
|
||||
"removeItem": "Remove",
|
||||
"addToCompare": "Compare",
|
||||
"removeFromCompare": "Remove from compare",
|
||||
"added": "Added",
|
||||
"loadError": "Unable to load data. Please try again.",
|
||||
"retry": "Retry",
|
||||
"property": "Property",
|
||||
"noImage": "No image",
|
||||
"remove": "Remove",
|
||||
"price": "Price",
|
||||
"transactionType": "Transaction",
|
||||
"sale": "Sale",
|
||||
"rent": "Rent",
|
||||
"propertyType": "Property type",
|
||||
"area": "Area",
|
||||
"pricePerM2": "Price/m²",
|
||||
"bedrooms": "Bedrooms",
|
||||
"bathrooms": "Bathrooms",
|
||||
"rooms": "rooms",
|
||||
"direction": "Direction",
|
||||
"floors": "Floors",
|
||||
"yearBuilt": "Year built",
|
||||
"legalStatus": "Legal status",
|
||||
"location": "Location",
|
||||
"amenities": "Amenities",
|
||||
"projectName": "Project",
|
||||
"priceRange": "Price range",
|
||||
"areaRange": "Area range",
|
||||
"pricePerM2Range": "Price/m² range",
|
||||
"average": "Average"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,5 +215,46 @@
|
||||
"10to20b": "10 - 20 tỷ",
|
||||
"over20b": "Trên 20 tỷ"
|
||||
}
|
||||
},
|
||||
"compare": {
|
||||
"title": "So sánh bất động sản",
|
||||
"subtitle": "Đang so sánh {count} bất động sản",
|
||||
"emptyState": "Chọn ít nhất 2 bất động sản để so sánh. Quay lại trang tìm kiếm để chọn.",
|
||||
"goToSearch": "Đi đến tìm kiếm",
|
||||
"addMore": "Thêm BĐS",
|
||||
"clearAll": "Xóa tất cả",
|
||||
"compareNow": "So sánh ngay",
|
||||
"needMore": "Cần thêm BĐS",
|
||||
"selected": "{count}/{max} đã chọn",
|
||||
"removeItem": "Bỏ chọn",
|
||||
"addToCompare": "So sánh",
|
||||
"removeFromCompare": "Bỏ so sánh",
|
||||
"added": "Đã thêm",
|
||||
"loadError": "Không thể tải dữ liệu. Vui lòng thử lại.",
|
||||
"retry": "Thử lại",
|
||||
"property": "Bất động sản",
|
||||
"noImage": "Chưa có ảnh",
|
||||
"remove": "Xóa",
|
||||
"price": "Giá",
|
||||
"transactionType": "Loại giao dịch",
|
||||
"sale": "Bán",
|
||||
"rent": "Cho thuê",
|
||||
"propertyType": "Loại BĐS",
|
||||
"area": "Diện tích",
|
||||
"pricePerM2": "Giá/m²",
|
||||
"bedrooms": "Phòng ngủ",
|
||||
"bathrooms": "Phòng tắm",
|
||||
"rooms": "phòng",
|
||||
"direction": "Hướng",
|
||||
"floors": "Số tầng",
|
||||
"yearBuilt": "Năm xây dựng",
|
||||
"legalStatus": "Pháp lý",
|
||||
"location": "Vị trí",
|
||||
"amenities": "Tiện ích",
|
||||
"projectName": "Dự án",
|
||||
"priceRange": "Khoảng giá",
|
||||
"areaRange": "Khoảng diện tích",
|
||||
"pricePerM2Range": "Khoảng giá/m²",
|
||||
"average": "Trung bình"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user