From 37fab515b7a6c8e67357d3c3d00bd7d8a4ab5120 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Fri, 10 Apr 2026 23:55:50 +0700 Subject: [PATCH] feat(web): add property comparison page with side-by-side view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../app/[locale]/(public)/compare/page.tsx | 130 +++++++++ apps/web/app/[locale]/(public)/layout.tsx | 3 + .../comparison/add-to-compare-button.tsx | 62 ++++ .../comparison/compare-floating-bar.tsx | 63 ++++ .../comparison/comparison-stats.tsx | 68 +++++ .../comparison/comparison-table.tsx | 271 ++++++++++++++++++ .../listings/listing-detail-client.tsx | 4 + apps/web/components/search/property-card.tsx | 4 + .../lib/__tests__/comparison-store.spec.ts | 270 +++++++++++++++++ apps/web/lib/comparison-api.ts | 15 + apps/web/lib/comparison-store.ts | 120 ++++++++ apps/web/messages/en.json | 41 +++ apps/web/messages/vi.json | 41 +++ 13 files changed, 1092 insertions(+) create mode 100644 apps/web/app/[locale]/(public)/compare/page.tsx create mode 100644 apps/web/components/comparison/add-to-compare-button.tsx create mode 100644 apps/web/components/comparison/compare-floating-bar.tsx create mode 100644 apps/web/components/comparison/comparison-stats.tsx create mode 100644 apps/web/components/comparison/comparison-table.tsx create mode 100644 apps/web/lib/__tests__/comparison-store.spec.ts create mode 100644 apps/web/lib/comparison-api.ts create mode 100644 apps/web/lib/comparison-store.ts diff --git a/apps/web/app/[locale]/(public)/compare/page.tsx b/apps/web/app/[locale]/(public)/compare/page.tsx new file mode 100644 index 0000000..76537f8 --- /dev/null +++ b/apps/web/app/[locale]/(public)/compare/page.tsx @@ -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 ( +
+
+ +

{t('title')}

+

{t('emptyState')}

+ + + +
+
+ ); + } + + // Loading state + if (isLoading) { + return ( +
+
+
+
+
+ ); + } + + // Error state + if (error) { + return ( +
+
+

{error}

+ +
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+

{t('title')}

+

+ {t('subtitle', { count: listings.length })} +

+
+
+ + + + +
+
+ + {/* Stats summary */} + {stats && ( +
+ +
+ )} + + {/* Comparison table */} + +
+ ); +} diff --git a/apps/web/app/[locale]/(public)/layout.tsx b/apps/web/app/[locale]/(public)/layout.tsx index 54a7825..4158e16 100644 --- a/apps/web/app/[locale]/(public)/layout.tsx +++ b/apps/web/app/[locale]/(public)/layout.tsx @@ -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} + +
diff --git a/apps/web/components/comparison/add-to-compare-button.tsx b/apps/web/components/comparison/add-to-compare-button.tsx new file mode 100644 index 0000000..5333bf2 --- /dev/null +++ b/apps/web/components/comparison/add-to-compare-button.tsx @@ -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 { + 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 ( + + ); + } + + return ( + + ); +} diff --git a/apps/web/components/comparison/compare-floating-bar.tsx b/apps/web/components/comparison/compare-floating-bar.tsx new file mode 100644 index 0000000..326844a --- /dev/null +++ b/apps/web/components/comparison/compare-floating-bar.tsx @@ -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 ( +
+
+
+ + + {t('selected', { count: selectedIds.length, max: MAX_COMPARE })} + +
+ {selectedIds.map((id) => ( + + {id.slice(0, 8)}... + + + ))} +
+
+
+ + {canCompare ? ( + + + + ) : ( + + )} +
+
+
+ ); +} diff --git a/apps/web/components/comparison/comparison-stats.tsx b/apps/web/components/comparison/comparison-stats.tsx new file mode 100644 index 0000000..8e2125d --- /dev/null +++ b/apps/web/components/comparison/comparison-stats.tsx @@ -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 ( +
+ + + + {t('priceRange')} + + + +

+ {formatPrice(stats.priceRange.min)} — {formatPrice(stats.priceRange.max)} +

+

+ {t('average')}: {formatPrice(stats.priceRange.avg)} VND +

+
+
+ + + + + {t('areaRange')} + + + +

+ {stats.areaRange.min} — {stats.areaRange.max} m² +

+

+ {t('average')}: {stats.areaRange.avg} m² +

+
+
+ + {stats.pricePerM2Range && ( + + + + {t('pricePerM2Range')} + + + +

+ {formatPricePerM2(stats.pricePerM2Range.min)} — {formatPricePerM2(stats.pricePerM2Range.max)} +

+

+ {t('average')}: {formatPricePerM2(stats.pricePerM2Range.avg)} +

+
+
+ )} +
+ ); +} diff --git a/apps/web/components/comparison/comparison-table.tsx b/apps/web/components/comparison/comparison-table.tsx new file mode 100644 index 0000000..89b9eeb --- /dev/null +++ b/apps/web/components/comparison/comparison-table.tsx @@ -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 = { + 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 = { + 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 ( + + + {label} + + {values.map((val, i) => ( + + {val} + + ))} + + ); +} + +export function ComparisonTable({ listings, onRemove }: ComparisonTableProps) { + const t = useTranslations('compare'); + + if (listings.length === 0) return null; + + return ( +
+ + + + + {listings.map((listing) => ( + + ))} + + + + {/* Price */} + ( + + {formatPrice(l.priceVND)} VND + {l.transactionType === 'RENT' && l.rentPriceMonthly && ( + /tháng + )} + + ))} + /> + + {/* Transaction type */} + ( + + {l.transactionType === 'SALE' ? t('sale') : t('rent')} + + ))} + /> + + {/* Property type */} + ( + + {PROPERTY_TYPE_LABELS[l.property.propertyType] || l.property.propertyType} + + ))} + /> + + {/* Area */} + ( + {l.property.areaM2} m² + ))} + /> + + {/* Price per m² */} + ( + + {l.pricePerM2 != null ? formatPricePerM2(l.pricePerM2) : '—'} + + ))} + /> + + {/* Bedrooms */} + ( + + {l.property.bedrooms != null ? `${l.property.bedrooms} ${t('rooms')}` : '—'} + + ))} + /> + + {/* Bathrooms */} + ( + + {l.property.bathrooms != null ? `${l.property.bathrooms} ${t('rooms')}` : '—'} + + ))} + /> + + {/* Direction */} + ( + + {l.property.direction ? DIRECTION_LABELS[l.property.direction] || l.property.direction : '—'} + + ))} + /> + + {/* Floors */} + ( + + {l.property.floors != null ? l.property.floors : '—'} + + ))} + /> + + {/* Year built */} + ( + + {l.property.yearBuilt != null ? l.property.yearBuilt : '—'} + + ))} + /> + + {/* Legal status */} + ( + + {l.property.legalStatus || '—'} + + ))} + /> + + {/* Location */} + ( + + {l.property.address}, {l.property.district}, {l.property.city} + + ))} + /> + + {/* Amenities */} + ( +
+ {l.property.amenities && l.property.amenities.length > 0 + ? l.property.amenities.map((a) => ( + + {a} + + )) + : '—'} +
+ ))} + /> + + {/* Project name */} + ( + + {l.property.projectName || '—'} + + ))} + /> +
+
+ {t('property')} + +
+ {/* Image */} +
+ {listing.property.media.length > 0 ? ( + {listing.property.title} + ) : ( +
+ {t('noImage')} +
+ )} +
+ {/* Title */} + + {listing.property.title} + + {/* Remove button */} + +
+
+
+ ); +} diff --git a/apps/web/components/listings/listing-detail-client.tsx b/apps/web/components/listings/listing-detail-client.tsx index 07ea425..141c61e 100644 --- a/apps/web/components/listings/listing-detail-client.tsx +++ b/apps/web/components/listings/listing-detail-client.tsx @@ -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

)} +
+ +
diff --git a/apps/web/components/search/property-card.tsx b/apps/web/components/search/property-card.tsx index c873830..2433be1 100644 --- a/apps/web/components/search/property-card.tsx +++ b/apps/web/components/search/property-card.tsx @@ -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) {
)} +
+ +

diff --git a/apps/web/lib/__tests__/comparison-store.spec.ts b/apps/web/lib/__tests__/comparison-store.spec.ts new file mode 100644 index 0000000..e8aabd1 --- /dev/null +++ b/apps/web/lib/__tests__/comparison-store.spec.ts @@ -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 = {}; +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, + }; +} diff --git a/apps/web/lib/comparison-api.ts b/apps/web/lib/comparison-api.ts new file mode 100644 index 0000000..5b0d03b --- /dev/null +++ b/apps/web/lib/comparison-api.ts @@ -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 { + const results = await Promise.allSettled(ids.map((id) => listingsApi.getById(id))); + + return results + .filter((r): r is PromiseFulfilledResult => r.status === 'fulfilled') + .map((r) => r.value); +} diff --git a/apps/web/lib/comparison-store.ts b/apps/web/lib/comparison-store.ts new file mode 100644 index 0000000..db31296 --- /dev/null +++ b/apps/web/lib/comparison-store.ts @@ -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()( + 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 }; diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 097790b..cf06db7 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -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" } } diff --git a/apps/web/messages/vi.json b/apps/web/messages/vi.json index a54c2e9..e6419ae 100644 --- a/apps/web/messages/vi.json +++ b/apps/web/messages/vi.json @@ -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" } }