Files
goodgo-platform/apps/web/lib/comparison-store.ts
Ho Ngoc Hai 37fab515b7 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>
2026-04-10 23:55:50 +07:00

121 lines
3.8 KiB
TypeScript

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