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>
121 lines
3.8 KiB
TypeScript
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 };
|