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"
}
}