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:
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 };
|
||||
Reference in New Issue
Block a user