setIsOpen(false)}
- />
- )}
-
- {/* Sidebar */}
-
-
- {/* Header */}
-
-
Filters
-
- {hasActiveFilters() && (
-
- Clear all
-
- )}
-
setIsOpen(false)}
- className="lg:hidden text-gray-400 hover:text-gray-600"
- >
-
-
-
-
-
-
-
- {/* Price Range */}
-
-
- {/* Property Type */}
-
-
- Property Type
-
-
- {(Object.keys(PROPERTY_TYPE_LABELS) as PropertyType[]).map((type) => (
-
- togglePropertyType(type)}
- className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
- />
-
- {PROPERTY_TYPE_LABELS[type]}
-
-
- ))}
-
-
-
- {/* Blockchain */}
-
-
- Blockchain Network
-
-
- {(Object.keys(BLOCKCHAIN_LABELS) as BlockchainNetwork[]).map((chain) => (
-
- toggleBlockchain(chain)}
- className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
- />
-
- {BLOCKCHAIN_LABELS[chain]}
-
-
- ))}
-
-
-
- {/* ROI Range */}
-
-
- {/* Bedrooms */}
-
-
- Bedrooms
-
-
- {[1, 2, 3, 4, 5].map((num) => (
- {
- const current = filters.bedrooms || [];
- const updated = current.includes(num)
- ? current.filter(n => n !== num)
- : [...current, num];
- onFilterChange('bedrooms', updated);
- }}
- className={`px-4 py-2 rounded-lg border transition-colors ${
- filters.bedrooms.includes(num)
- ? 'bg-blue-600 text-white border-blue-600'
- : 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600 hover:border-blue-600'
- }`}
- >
- {num}+
-
- ))}
-
-
-
- {/* Bathrooms */}
-
-
- Bathrooms
-
-
- {[1, 2, 3, 4].map((num) => (
- {
- const current = filters.bathrooms || [];
- const updated = current.includes(num)
- ? current.filter(n => n !== num)
- : [...current, num];
- onFilterChange('bathrooms', updated);
- }}
- className={`px-4 py-2 rounded-lg border transition-colors ${
- filters.bathrooms.includes(num)
- ? 'bg-blue-600 text-white border-blue-600'
- : 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600 hover:border-blue-600'
- }`}
- >
- {num}+
-
- ))}
-
-
-
- {/* Square Feet */}
-
-
-
- >
- );
-};
-
-export const FilterSidebar = React.memo(FilterSidebarInner);
diff --git a/src/components/FloatingComparisonBar.tsx b/src/components/FloatingComparisonBar.tsx
index a9ec85eb..11ba8db2 100644
--- a/src/components/FloatingComparisonBar.tsx
+++ b/src/components/FloatingComparisonBar.tsx
@@ -3,7 +3,7 @@
import React, { memo, useMemo } from 'react';
import Link from 'next/link';
import { X, BarChart3 } from 'lucide-react';
-import { useComparisonStore } from '@/store/comparisonStore';
+import { useCompareStore } from '@/store/compareStore';
import { formatPrice } from '@/utils/searchUtils';
// ============================================================================
@@ -41,7 +41,12 @@ const PropertyChip = memo(function PropertyChip({ name, price, onRemove }: Prope
// ============================================================================
const FloatingComparisonBar = () => {
- const { selectedProperties, removeProperty, clearProperties } = useComparisonStore();
+ // Resolved from the selection cache (#1090). Ids whose object is not cached
+ // - a selection restored from storage or a share link - are omitted rather
+ // than rendered as placeholders.
+ const selectedProperties = useCompareStore((state) => state.getSelectedProperties());
+ const removeProperty = useCompareStore((state) => state.removeProperty);
+ const clearProperties = useCompareStore((state) => state.clearCompare);
const propertyCount = selectedProperties.length;
diff --git a/src/components/PropertyCard.tsx b/src/components/PropertyCard.tsx
index eea475dd..d534e73a 100644
--- a/src/components/PropertyCard.tsx
+++ b/src/components/PropertyCard.tsx
@@ -8,8 +8,7 @@ import type { Property } from '@/types/property';
import { formatPrice, formatNumber, formatROI, getBlockchainColor } from '@/utils/searchUtils';
import { BLOCKCHAIN_LABELS, PROPERTY_TYPE_LABELS } from '@/types/property';
import { useCartStore } from '@/store/cartStore';
-import { useComparisonStore } from '@/store/comparisonStore';
-import { useCompareStore } from '@/store/compareStore';
+import { MAX_COMPARE, useCompareStore } from '@/store/compareStore';
import { useFavoritesStore } from '@/store/favoritesStore';
import { ShareButton } from './property/ShareButton';
import { CurrencyToggle } from './property/CurrencyToggle';
@@ -31,14 +30,15 @@ const PropertyCardInner: React.FC
= ({
}) => {
const isListView = viewMode === 'list';
const { addItem } = useCartStore();
- const { isPropertySelected, toggleProperty } = useComparisonStore();
-
- const isSelectedForComparison = isPropertySelected(property.id);
const selectedIds = useCompareStore((state) => state.selectedIds);
- const togglePropertyId = useCompareStore((state) => state.toggleProperty);
+ const toggleProperty = useCompareStore((state) => state.toggleProperty);
+ // One source of truth (#1090). This used to write to two stores per toggle
+ // while reading selection from one and the limit from the other, so the two
+ // could disagree.
const isCompared = selectedIds.includes(property.id);
- const compareLimitReached = selectedIds.length >= 3 && !isCompared;
+ const isSelectedForComparison = isCompared;
+ const compareLimitReached = selectedIds.length >= MAX_COMPARE && !isCompared;
const { addFavorite, removeFavorite, isFavorite } = useFavoritesStore();
const handleAddToCart = (e: React.MouseEvent) => {
@@ -48,13 +48,15 @@ const PropertyCardInner: React.FC = ({
const handleComparisonToggle = (e: React.MouseEvent) => {
e.stopPropagation();
+ // Passing the object caches it, so the floating bar can render a chip for a
+ // property the user just picked without refetching it.
toggleProperty(property);
};
const handleCompareToggle = (e: React.MouseEvent) => {
e.stopPropagation();
if (!compareLimitReached) {
- togglePropertyId(property.id);
+ toggleProperty(property);
}
};
diff --git a/src/components/PropertyDetail.tsx b/src/components/PropertyDetail.tsx
deleted file mode 100644
index f8b02b6d..00000000
--- a/src/components/PropertyDetail.tsx
+++ /dev/null
@@ -1,424 +0,0 @@
-'use client';
-
-import React, { useState, useEffect } from 'react';
-import Image from 'next/image';
-import { usePropertyQuery } from '@/hooks/usePropertySearchQuery';
-import { Button } from '@/components/ui/button';
-import { Badge } from '@/components/ui/badge';
-import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
-import { ShoppingCart, Plus, Share2, Heart, ExternalLink, Bell, Star, CheckCircle2 } from 'lucide-react';
-import { formatPrice, formatROI, getBlockchainColor } from '@/utils/searchUtils';
-import { BLOCKCHAIN_LABELS, PROPERTY_TYPE_LABELS, type PriceAlertType } from '@/types/property';
-import { useCartStore } from '@/store/cartStore';
-import { useNotificationStore } from '@/store/notificationStore';
-import { useRecentlyViewedStore } from '@/store/recentlyViewedStore';
-import { toast } from 'sonner';
-import { withLazyChart } from '@/components/LazyChart';
-const MortgageCalculator = withLazyChart(() => import('@/components/MortgageCalculator').then(m => ({ default: m.MortgageCalculator })));
-import { Loader2, ArrowLeft } from 'lucide-react';
-import Link from 'next/link';
-import { SetPriceAlertModal } from './property/SetPriceAlertModal';
-import { QRCode } from './QRCode';
-
-interface PropertyDetailProps {
- propertyId: string;
-}
-
-export const PropertyDetail: React.FC = ({ propertyId }) => {
- const { addItem } = useCartStore();
- const { priceAlerts, addPriceAlert } = useNotificationStore();
- const { addProperty: addRecentlyViewed } = useRecentlyViewedStore();
- const [isAlertModalOpen, setIsAlertModalOpen] = useState(false);
-
- const {
- data: property,
- isLoading,
- error
- } = usePropertyQuery(propertyId);
-
- // Track property view
- useEffect(() => {
- if (property) {
- addRecentlyViewed({
- id: property.id,
- name: property.name,
- location: `${property.location.city}, ${property.location.state}`,
- price: property.price.total,
- image: property.images[0],
- });
- }
- }, [property, addRecentlyViewed]);
-
- // Check if there's an existing alert for this property
- const existingAlert = priceAlerts.find(alert => alert.propertyId === propertyId);
-
- const handleSetAlert = (alertType: PriceAlertType, targetPrice: number, emailNotification: boolean) => {
- if (property) {
- addPriceAlert({
- id: `alert-${property.id}-${Date.now()}`,
- propertyId: property.id,
- propertyName: property.name,
- propertyImage: property.images[0],
- alertType,
- targetPrice,
- currentPrice: property.price.perToken,
- createdAt: new Date().toISOString(),
- isActive: true,
- isTriggered: false,
- userId: '', // Will be set from wallet
- emailNotification,
- });
- toast.success('Price alert set successfully!');
- }
- };
-
- const handleAddToCart = () => {
- if (property) {
- addItem(property, 1);
- toast.success('Property added to cart!');
- }
- };
-
-
- const handleSaveToFavorites = () => {
- if (property) {
- // Implement save to favorites logic
- toast.success('Property saved to favorites!');
- }
- };
-
- if (isLoading) {
- return (
-
-
-
-
Loading property details...
-
-
- );
- }
-
- if (error || !property) {
- return (
-
-
-
- Property not found
-
-
- The property you're looking for doesn't exist or has been removed.
-
-
-
-
- Back to Properties
-
-
-
-
- );
- }
-
- return (
-
- {/* Property Header */}
-
- {/* Main Image and Gallery */}
-
-
- {/* Badges */}
-
- {property.featured && (
-
-
- Featured
-
- )}
- {property.verified && (
-
-
- Verified
-
- )}
-
-
- {/* ROI Badge */}
-
-
- {formatROI(property.metrics.roi)} ROI
-
-
-
-
-
-
-
- {/* Property Info Sidebar */}
-
- {/* Title and Location */}
-
-
-
- {PROPERTY_TYPE_LABELS[property.propertyType]}
-
-
-
- {property.name}
-
-
-
-
-
-
-
- {property.location.address}, {property.location.city}, {property.location.state}
-
-
-
-
- {/* Price Information */}
-
-
- Investment Details
-
-
-
- Total Value
-
-
-
- Per Token
-
-
-
- Available Tokens
-
- {property.tokenInfo.available.toLocaleString()} / {property.tokenInfo.totalSupply.toLocaleString()}
-
-
-
- Expected ROI
-
- {formatROI(property.metrics.roi)}
-
-
-
-
-
- {/* Blockchain Information */}
-
-
- Blockchain
-
-
-
-
-
- {BLOCKCHAIN_LABELS[property.blockchain]}
-
-
-
- Contract: {property.tokenInfo.contractAddress}
-
-
-
-
- {/* Action Buttons */}
-
-
-
-
- Add to Cart
-
-
-
- {property && (
-
- )}
-
-
- Save
-
-
-
- {/* Set Price Alert Button */}
-
setIsAlertModalOpen(true)}
- >
-
- {existingAlert ? 'Manage Alert' : 'Set Price Alert'}
-
-
-
-
-
- {/* Property Details */}
-
- {/* Description */}
-
-
-
- About this Property
-
-
-
- {property.description}
-
-
-
-
- {/* Property Features */}
-
-
- Property Features
-
-
-
- {property.details.bedrooms && (
-
-
-
{property.details.bedrooms}
-
Bedrooms
-
- )}
-
- {property.details.bathrooms && (
-
-
-
{property.details.bathrooms}
-
Bathrooms
-
- )}
-
-
-
-
{property.details.squareFeet.toLocaleString()}
-
Square Feet
-
-
-
-
-
{formatROI(property.metrics.roi)}
-
ROI
-
-
-
-
-
-
- {/* Sidebar */}
-
- {/* Investment Summary */}
-
-
- Investment Summary
-
-
-
- Annual Yield
-
- {formatROI(property.metrics.roi)}
-
-
-
- Transaction Volume
-
- {property.metrics.transactionVolume.toLocaleString()}
-
-
-
- Listed Date
-
- {new Date(property.listedDate).toLocaleDateString()}
-
-
-
-
-
- {/* External Links */}
-
-
- External Links
-
-
-
-
- View on Blockchain
-
-
-
- Property Documents
-
-
-
-
-
-
- {/* Set Price Alert Modal */}
- {property && (
-
- )}
- {/* Investment Calculator */}
-
-
-
-
- {/* QR Code for Print - Only visible when printing */}
-
-
-
- Scan to view this property online
-
-
-
- );
-};
diff --git a/src/components/PropertyDetailClient.tsx b/src/components/PropertyDetailClient.tsx
deleted file mode 100644
index b91a6b02..00000000
--- a/src/components/PropertyDetailClient.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-'use client';
-
-import React from 'react';
-import { WalletConnector } from '@/components/WalletConnector';
-import { PriceAlertBell } from '@/components/PriceAlertBell';
-
-interface PropertyDetailClientProps {
- propertyId: string;
-}
-
-export const PropertyDetailClient: React.FC = ({ propertyId }) => {
- return (
-
- );
-};
diff --git a/src/components/__tests__/FilterSidebar.test.tsx b/src/components/__tests__/FilterSidebar.test.tsx
deleted file mode 100644
index 96647109..00000000
--- a/src/components/__tests__/FilterSidebar.test.tsx
+++ /dev/null
@@ -1,318 +0,0 @@
-import { render, screen, fireEvent, within } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
-import { FilterSidebar } from '../FilterSidebar';
-import { DEFAULT_FILTERS } from '@/types/property';
-import type { SearchFilters } from '@/types/property';
-
-const activeFilters: SearchFilters = {
- ...DEFAULT_FILTERS,
- priceRange: [100000, 500000],
- propertyTypes: ['residential'],
- blockchains: ['ethereum'],
- roiMin: 5,
- bedrooms: [2],
-};
-
-describe('FilterSidebar visual snapshots', () => {
- it('matches snapshot with default filters', () => {
- const { container } = render(
- ,
- );
- expect(container).toMatchSnapshot();
- });
-
- it('matches snapshot with active filters', () => {
- const { container } = render(
- ,
- );
- expect(container).toMatchSnapshot();
- });
-
- it('matches snapshot for sidebar panel only', () => {
- const { getByTestId } = render(
- ,
- );
- expect(getByTestId('filter-sidebar')).toMatchSnapshot();
- });
-});
-
-// ─── Clear all ────────────────────────────────────────────────────────────────
-
-describe('FilterSidebar – clear filters', () => {
- it('does not show "Clear all" button when no filters are active', () => {
- render(
- ,
- );
- expect(screen.queryByRole('button', { name: /clear all/i })).not.toBeInTheDocument();
- });
-
- it('shows "Clear all" button when filters are active', () => {
- render(
- ,
- );
- expect(screen.getByRole('button', { name: /clear all/i })).toBeInTheDocument();
- });
-
- it('calls onClearFilters when "Clear all" is clicked', async () => {
- const onClearFilters = jest.fn();
- render(
- ,
- );
- await userEvent.click(screen.getByRole('button', { name: /clear all/i }));
- expect(onClearFilters).toHaveBeenCalledTimes(1);
- });
-});
-
-// ─── Mobile drawer ────────────────────────────────────────────────────────────
-
-describe('FilterSidebar – mobile drawer', () => {
- it('sidebar is off-screen by default (translate-x-full class)', () => {
- render(
- ,
- );
- const sidebar = screen.getByTestId('filter-sidebar');
- expect(sidebar.className).toMatch(/-translate-x-full/);
- });
-
- it('opens drawer when mobile Filters button is clicked', async () => {
- render(
- ,
- );
- const mobileBtn = screen.getByRole('button', { name: /filters/i });
- await userEvent.click(mobileBtn);
- const sidebar = screen.getByTestId('filter-sidebar');
- expect(sidebar.className).toMatch(/translate-x-0/);
- });
-
- it('closes drawer when overlay is clicked', async () => {
- const { container } = render(
- ,
- );
- // Open first
- await userEvent.click(screen.getByRole('button', { name: /filters/i }));
- // Click the overlay (fixed inset-0 div)
- const overlay = container.querySelector('.fixed.inset-0');
- expect(overlay).not.toBeNull();
- fireEvent.click(overlay!);
- const sidebar = screen.getByTestId('filter-sidebar');
- expect(sidebar.className).toMatch(/-translate-x-full/);
- });
-
- it('shows "Active" badge on mobile button when filters are active', () => {
- render(
- ,
- );
- expect(screen.getByText('Active')).toBeInTheDocument();
- });
-
- it('does not show "Active" badge when no filters are active', () => {
- render(
- ,
- );
- expect(screen.queryByText('Active')).not.toBeInTheDocument();
- });
-});
-
-// ─── Accessibility tree ───────────────────────────────────────────────────────
-
-describe('FilterSidebar – accessibility', () => {
- it('sidebar heading is present and labelled', () => {
- render(
- ,
- );
- expect(screen.getByRole('heading', { name: /filters/i })).toBeInTheDocument();
- });
-
- it('price range inputs have accessible labels', () => {
- render(
- ,
- );
- expect(screen.getByText(/min price/i)).toBeInTheDocument();
- expect(screen.getByText(/max price/i)).toBeInTheDocument();
- });
-
- it('property type checkboxes are keyboard-accessible', () => {
- render(
- ,
- );
- const checkboxes = screen.getAllByRole('checkbox');
- // At least one checkbox must exist (property types + blockchains)
- expect(checkboxes.length).toBeGreaterThan(0);
- checkboxes.forEach(cb => {
- expect(cb).not.toHaveAttribute('tabindex', '-1');
- });
- });
-
- it('bedroom buttons are accessible buttons', () => {
- render(
- ,
- );
- // The bedroom buttons render as role=button
- const bedroomSection = screen.getByText(/bedrooms/i).closest('div')!;
- const buttons = within(bedroomSection).getAllByRole('button');
- expect(buttons.length).toBeGreaterThan(0);
- });
-});
-
-// ─── Filter interactions & URL-sync callbacks ─────────────────────────────────
-
-describe('FilterSidebar – filter interactions', () => {
- it('calls onFilterChange with propertyTypes when a type checkbox is toggled', async () => {
- const onFilterChange = jest.fn();
- render(
- ,
- );
- const residentialCheckbox = screen.getByRole('checkbox', { name: /residential/i });
- await userEvent.click(residentialCheckbox);
- expect(onFilterChange).toHaveBeenCalledWith('propertyTypes', ['residential']);
- });
-
- it('removes a property type when an already-checked checkbox is clicked', async () => {
- const onFilterChange = jest.fn();
- render(
- ,
- );
- const residentialCheckbox = screen.getByRole('checkbox', { name: /residential/i });
- await userEvent.click(residentialCheckbox);
- expect(onFilterChange).toHaveBeenCalledWith('propertyTypes', []);
- });
-
- it('calls onFilterChange with blockchains when a network checkbox is toggled', async () => {
- const onFilterChange = jest.fn();
- render(
- ,
- );
- const ethereumCheckbox = screen.getByRole('checkbox', { name: /ethereum/i });
- await userEvent.click(ethereumCheckbox);
- expect(onFilterChange).toHaveBeenCalledWith('blockchains', ['ethereum']);
- });
-
- it('calls onFilterChange with bedrooms when a bedroom button is clicked', async () => {
- const onFilterChange = jest.fn();
- render(
- ,
- );
- const bedroomSection = screen.getByText(/bedrooms/i).closest('div')!;
- const btn2 = within(bedroomSection).getByRole('button', { name: /^2\+$/i });
- await userEvent.click(btn2);
- expect(onFilterChange).toHaveBeenCalledWith('bedrooms', [2]);
- });
-
- it('calls onFilterChange for min price input change', async () => {
- const onFilterChange = jest.fn();
- render(
- ,
- );
- const minPriceInput = screen.getByPlaceholderText('$0');
- fireEvent.change(minPriceInput, { target: { value: '100000' } });
- expect(onFilterChange).toHaveBeenCalledWith('priceRange', [100000, 10000000]);
- });
-
- it('calls onFilterChange for max price input change', async () => {
- const onFilterChange = jest.fn();
- render(
- ,
- );
- const maxPriceInput = screen.getByPlaceholderText('$10,000,000');
- fireEvent.change(maxPriceInput, { target: { value: '5000000' } });
- expect(onFilterChange).toHaveBeenCalledWith('priceRange', [0, 5000000]);
- });
-
- it('calls onFilterChange for roiMin change', async () => {
- const onFilterChange = jest.fn();
- render(
- ,
- );
- const minRoiInput = screen.getByPlaceholderText('0%');
- fireEvent.change(minRoiInput, { target: { value: '5' } });
- expect(onFilterChange).toHaveBeenCalledWith('roiMin', 5);
- });
-});
diff --git a/src/components/__tests__/PropertyCard.a11y.test.tsx b/src/components/__tests__/PropertyCard.a11y.test.tsx
index 64b02c0d..15888d0f 100644
--- a/src/components/__tests__/PropertyCard.a11y.test.tsx
+++ b/src/components/__tests__/PropertyCard.a11y.test.tsx
@@ -70,16 +70,20 @@ jest.mock('@/store/cartStore', () => ({
useCartStore: () => ({ addItem: jest.fn() }),
}));
-jest.mock('@/store/comparisonStore', () => ({
- useComparisonStore: () => ({
- isPropertySelected: jest.fn().mockReturnValue(false),
- toggleProperty: jest.fn(),
- }),
-}));
-
jest.mock('@/store/compareStore', () => ({
- useCompareStore: (selector: (state: { selectedIds: string[]; toggleProperty: jest.Mock }) => unknown) =>
- selector({ selectedIds: [], toggleProperty: jest.fn() }),
+ useCompareStore: (
+ selector: (state: {
+ selectedIds: string[];
+ toggleProperty: jest.Mock;
+ isPropertySelected: () => boolean;
+ }) => unknown,
+ ) =>
+ selector({
+ selectedIds: [],
+ toggleProperty: jest.fn(),
+ isPropertySelected: () => false,
+ }),
+ MAX_COMPARE: 3,
}));
jest.mock('@/store/favoritesStore', () => ({
diff --git a/src/components/__tests__/PropertyCardContrast.test.tsx b/src/components/__tests__/PropertyCardContrast.test.tsx
index 6ac82ff1..6f03c59e 100644
--- a/src/components/__tests__/PropertyCardContrast.test.tsx
+++ b/src/components/__tests__/PropertyCardContrast.test.tsx
@@ -46,14 +46,15 @@ const mockProperty: Property = {
// Selector-aware mock so PropertyCard's zustand-style calls work in tests.
jest.mock('@/store/cartStore', () => ({ useCartStore: () => ({ addItem: jest.fn() }) }));
-jest.mock('@/store/comparisonStore', () => ({
- useComparisonStore: () => ({ isPropertySelected: () => false, toggleProperty: jest.fn() }),
-}));
jest.mock('@/store/compareStore', () => {
- const stub = { selectedIds: [] as string[], toggleProperty: jest.fn() };
+ const stub = {
+ selectedIds: [] as string[],
+ toggleProperty: jest.fn(),
+ isPropertySelected: () => false,
+ };
const useCompareStore = (selector?: (state: typeof stub) => unknown) =>
typeof selector === 'function' ? selector(stub) : stub;
- return { useCompareStore };
+ return { useCompareStore, MAX_COMPARE: 3 };
});
jest.mock('@/store/favoritesStore', () => ({
useFavoritesStore: () => ({
diff --git a/src/components/__tests__/PropertyListingSemantics.test.tsx b/src/components/__tests__/PropertyListingSemantics.test.tsx
index d679201e..360b0a6a 100644
--- a/src/components/__tests__/PropertyListingSemantics.test.tsx
+++ b/src/components/__tests__/PropertyListingSemantics.test.tsx
@@ -62,14 +62,15 @@ const mockProperty: Property = {
};
jest.mock('@/store/cartStore', () => ({ useCartStore: () => ({ addItem: jest.fn() }) }));
-jest.mock('@/store/comparisonStore', () => ({
- useComparisonStore: () => ({ isPropertySelected: () => false, toggleProperty: jest.fn() }),
-}));
jest.mock('@/store/compareStore', () => {
- const stub = { selectedIds: [] as string[], toggleProperty: jest.fn() };
+ const stub = {
+ selectedIds: [] as string[],
+ toggleProperty: jest.fn(),
+ isPropertySelected: () => false,
+ };
const useCompareStore = (selector?: (state: typeof stub) => unknown) =>
typeof selector === 'function' ? selector(stub) : stub;
- return { useCompareStore };
+ return { useCompareStore, MAX_COMPARE: 3 };
});
jest.mock('@/store/favoritesStore', () => ({
useFavoritesStore: () => ({
diff --git a/src/components/__tests__/__snapshots__/FilterSidebar.test.tsx.snap b/src/components/__tests__/__snapshots__/FilterSidebar.test.tsx.snap
deleted file mode 100644
index 58be0173..00000000
--- a/src/components/__tests__/__snapshots__/FilterSidebar.test.tsx.snap
+++ /dev/null
@@ -1,1148 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`FilterSidebar visual snapshots matches snapshot for sidebar panel only 1`] = `
-
-`;
-
-exports[`FilterSidebar visual snapshots matches snapshot with active filters 1`] = `
-
-
-
-
-
- Filters
-
- Active
-
-
-
-
-`;
-
-exports[`FilterSidebar visual snapshots matches snapshot with default filters 1`] = `
-
-`;
diff --git a/src/components/filters/AdvancedFilters.tsx b/src/components/filters/AdvancedFilters.tsx
deleted file mode 100644
index f8b4fad2..00000000
--- a/src/components/filters/AdvancedFilters.tsx
+++ /dev/null
@@ -1,33 +0,0 @@
-interface Props {
- values: any;
- onChange: (name: string, value: any) => void;
-}
-
-export default function AdvancedFilters({
- values,
- onChange,
-}: Props) {
- return (
- <>
-
-
-
-
-
-
-
- >
- );
-}
\ No newline at end of file
diff --git a/src/components/filters/FilterSection.tsx b/src/components/filters/FilterSection.tsx
deleted file mode 100644
index 9cd2b5d3..00000000
--- a/src/components/filters/FilterSection.tsx
+++ /dev/null
@@ -1,33 +0,0 @@
-import { useState } from "react";
-
-interface FilterSectionProps {
- title: string;
- children: React.ReactNode;
- defaultOpen?: boolean;
-}
-
-export default function FilterSection({
- title,
- children,
- defaultOpen = true,
-}: FilterSectionProps) {
- const [isOpen, setIsOpen] = useState(defaultOpen);
-
- return (
-
-
setIsOpen(!isOpen)}
- >
- {title}
- {isOpen ? "−" : "+"}
-
-
- {isOpen && (
-
- {children}
-
- )}
-
- );
-}
\ No newline at end of file
diff --git a/src/components/filters/FilterSidebar.tsx b/src/components/filters/FilterSidebar.tsx
deleted file mode 100644
index 1e8483e7..00000000
--- a/src/components/filters/FilterSidebar.tsx
+++ /dev/null
@@ -1,33 +0,0 @@
-import FilterSection from "./FilterSection";
-import AdvancedFilters from "./AdvancedFilters";
-
-export default function FilterSidebar(props) {
- return (
- <>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- >
- );
-}
\ No newline at end of file
diff --git a/src/components/filters/index.ts b/src/components/filters/index.ts
deleted file mode 100644
index 513f2de1..00000000
--- a/src/components/filters/index.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export { default as FilterSidebar } from "./FilterSidebar";
-export { default as FilterSection } from "./FilterSection";
-export { default as AdvancedFilters } from "./AdvancedFilters";
\ No newline at end of file
diff --git a/src/components/forms/SearchFilterForm.tsx b/src/components/forms/SearchFilterForm.tsx
index d614dfad..74d33991 100644
--- a/src/components/forms/SearchFilterForm.tsx
+++ b/src/components/forms/SearchFilterForm.tsx
@@ -9,6 +9,7 @@ import {
Form,
FormControl,
FormDescription,
+ FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
diff --git a/src/components/forms/__tests__/FilterUiConsolidation.test.tsx b/src/components/forms/__tests__/FilterUiConsolidation.test.tsx
new file mode 100644
index 00000000..f6271ba3
--- /dev/null
+++ b/src/components/forms/__tests__/FilterUiConsolidation.test.tsx
@@ -0,0 +1,125 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import { render, waitFor } from '@testing-library/react';
+import { SearchFilterForm } from '@/components/forms/SearchFilterForm';
+import { DEFAULT_FILTERS, type SearchFilters } from '@/types/property';
+
+/**
+ * Issue #1092 — one canonical filter UI, one canonical filter state.
+ *
+ * Filter UI existed three times over:
+ *
+ * components/forms/SearchFilterForm live on /properties and /forms
+ * components/FilterSidebar same controls, per-key onFilterChange,
+ * tested but rendered by no page
+ * components/filters/* a sidebar referencing ten filter
+ * components that were never imported and
+ * do not exist; ten TS2304 errors
+ *
+ * The third could not compile at all. It went unnoticed because a syntax error
+ * in `src/middleware.ts` makes `tsc` skip the semantic pass for the whole
+ * program, so every type error in the repo is currently masked.
+ *
+ * `SearchFilterForm` is canonical: it is the one two pages actually render, it
+ * validates through `searchFilterSchema`, and it already speaks `SearchFilters`.
+ * The other two are gone.
+ *
+ * These assertions are structural on purpose. They cannot stop someone building
+ * a fourth filter UI, but they fail the moment a duplicate module reappears or
+ * the canonical form stops emitting the canonical state shape.
+ */
+const SRC = path.join(process.cwd(), 'src');
+
+function sourceFiles(dir: string, acc: string[] = []): string[] {
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
+ const full = path.join(dir, entry.name);
+ if (entry.isDirectory()) {
+ sourceFiles(full, acc);
+ } else if (/\.(ts|tsx)$/.test(entry.name)) {
+ acc.push(full);
+ }
+ }
+ return acc;
+}
+
+describe('filter UI module graph', () => {
+ const files = sourceFiles(SRC);
+
+ it('finds source files to scan', () => {
+ expect(files.length).toBeGreaterThan(100);
+ });
+
+ it('has exactly one filter form module', () => {
+ const forms = files.filter((file) =>
+ /[\\/]SearchFilterForm\.tsx$/.test(file),
+ );
+
+ expect(forms).toHaveLength(1);
+ });
+
+ it('has no FilterSidebar module left', () => {
+ const sidebars = files.filter((file) =>
+ /[\\/]FilterSidebar\.tsx$/.test(file),
+ );
+
+ expect(sidebars).toEqual([]);
+ });
+
+ it('has no components/filters directory left', () => {
+ expect(fs.existsSync(path.join(SRC, 'components', 'filters'))).toBe(false);
+ });
+
+ it('has no import of the removed filter modules', () => {
+ const offenders = files.filter((file) => {
+ if (file.includes('__tests__')) return false;
+ const source = fs.readFileSync(file, 'utf8');
+ return /from\s+['"][^'"]*(FilterSidebar|components\/filters|filters\/AdvancedFilters|filters\/FilterSection)['"]/.test(
+ source,
+ );
+ });
+
+ expect(offenders).toEqual([]);
+ });
+});
+
+describe('canonical filter state', () => {
+ it('emits exactly the SearchFilters keys and nothing else', async () => {
+ const onApplyFilters = jest.fn();
+
+ render(
+ ,
+ );
+
+ await waitFor(() => expect(onApplyFilters).toHaveBeenCalled());
+
+ const emitted = onApplyFilters.mock.calls.at(-1)?.[0] as SearchFilters;
+
+ // Key-set equality in both directions: a new control that forgets to feed
+ // the canonical type, or a canonical field the form silently drops, fails.
+ expect(Object.keys(emitted).sort()).toEqual(
+ Object.keys(DEFAULT_FILTERS).sort(),
+ );
+ });
+
+ it('round-trips the default filters unchanged', async () => {
+ const onApplyFilters = jest.fn();
+
+ render(
+ ,
+ );
+
+ await waitFor(() => expect(onApplyFilters).toHaveBeenCalled());
+
+ const emitted = onApplyFilters.mock.calls.at(-1)?.[0] as SearchFilters;
+
+ expect(emitted).toEqual(DEFAULT_FILTERS);
+ });
+});
diff --git a/src/components/layout/AppHeader.tsx b/src/components/layout/AppHeader.tsx
new file mode 100644
index 00000000..293ccabd
--- /dev/null
+++ b/src/components/layout/AppHeader.tsx
@@ -0,0 +1,154 @@
+"use client";
+
+import Link from "next/link";
+import { ArrowLeft } from "lucide-react";
+import type { ReactNode } from "react";
+import { cn } from "@/lib/utils";
+
+/**
+ * Issue #1094 — the single PropChain page header.
+ *
+ * The same header markup was inlined in twenty places across `src/app`: the
+ * chrome (`bg-white dark:bg-gray-800 shadow-sm border-b …`), the 7xl container,
+ * the `h-16` flex row, and the PC-square-plus-wordmark brand block. Only the
+ * contents of the two ends ever differed, so everything that varies is a prop
+ * and everything that does not lives here.
+ *
+ * ## Two behaviours this fixes on the way through
+ *
+ * **The brand is no longer an ``.** Most pages rendered `PropChain `
+ * in the header *and* an `` for the page title, giving every page two level-1
+ * headings and making the real title ambiguous to a screen reader. The brand is
+ * a `` here, so each page keeps exactly one `` — its own.
+ *
+ * **One banner landmark per page.** A page that rendered its own `` and
+ * also mounted a component that rendered one produced two `banner` landmarks.
+ * Rendering this component once per route keeps that to one; `as="div"` is
+ * available for the rare case where a second header-shaped block is wanted
+ * inside a page without claiming the landmark.
+ */
+export interface AppHeaderProps {
+ /**
+ * Pin the header to the top of the viewport. Most routes do; the dashboard
+ * shell does not, because it scrolls inside its own ``.
+ */
+ sticky?: boolean;
+ /** Back link rendered before the brand. */
+ backHref?: string;
+ /**
+ * Visible label for the back link, hidden below `sm`. When omitted the link
+ * still carries an accessible name via `aria-label`.
+ */
+ backLabel?: string;
+ /** Slot before the brand — e.g. the dashboard's mobile sidebar toggle. */
+ leading?: ReactNode;
+ /** Right-hand slot. `WalletConnector` is the common case. */
+ actions?: ReactNode;
+ /**
+ * Where the brand links to. Pass `null` to render it as plain text, which is
+ * the right choice on the home page where the link would point at itself.
+ */
+ brandHref?: string | null;
+ /**
+ * Render as a plain `div` instead of ``, so a second header-shaped
+ * row inside a page does not create a competing `banner` landmark.
+ */
+ as?: "header" | "div";
+ className?: string;
+ /**
+ * Override the inner container width. Defaults to `max-w-7xl`, matching most
+ * routes; `/alerts` is laid out at `max-w-4xl` and its header must line up
+ * with its own body rather than with the wider pages.
+ */
+ containerClassName?: string;
+}
+
+/**
+ * The PC square plus the PropChain wordmark.
+ *
+ * Deliberately not a heading: see the note on {@link AppHeaderProps}.
+ */
+function Brand() {
+ return (
+
+
+ PC
+
+
+ PropChain
+
+
+ );
+}
+
+/**
+ * Renders the one PropChain page header.
+ *
+ * @param props - See {@link AppHeaderProps}.
+ * @returns The header row, with `leading`/back-link/brand on the left and
+ * `actions` on the right.
+ */
+export function AppHeader({
+ sticky = false,
+ backHref,
+ backLabel,
+ leading,
+ actions,
+ brandHref = "/",
+ as: Element = "header",
+ className,
+ containerClassName,
+}: AppHeaderProps) {
+ return (
+
+
+
+
+ {leading}
+
+ {backHref ? (
+
+
+ {backLabel ? (
+
{backLabel}
+ ) : null}
+
+ ) : null}
+
+ {brandHref ? (
+
+
+
+ ) : (
+
+ )}
+
+
+ {actions ? (
+
{actions}
+ ) : null}
+
+
+
+ );
+}
+
+export default AppHeader;
diff --git a/src/components/layout/__tests__/AppHeader.test.tsx b/src/components/layout/__tests__/AppHeader.test.tsx
new file mode 100644
index 00000000..df440576
--- /dev/null
+++ b/src/components/layout/__tests__/AppHeader.test.tsx
@@ -0,0 +1,107 @@
+import { render, screen } from '@testing-library/react';
+import { AppHeader } from '@/components/layout/AppHeader';
+
+/**
+ * Issue #1094 — the shared header.
+ *
+ * The interesting assertions are the accessibility ones. The twenty inlined
+ * copies this replaces mostly rendered `PropChain `, so a page ended up
+ * with two level-1 headings, and any page that also mounted a header-rendering
+ * component ended up with two `banner` landmarks. Both are pinned here so they
+ * cannot come back.
+ */
+describe('AppHeader', () => {
+ it('renders exactly one banner landmark', () => {
+ render( );
+ expect(screen.getAllByRole('banner')).toHaveLength(1);
+ });
+
+ it('does not render the brand as a heading', () => {
+ render( );
+
+ expect(screen.getByText('PropChain')).toBeInTheDocument();
+ // The page owns the only ; the brand must not compete with it.
+ expect(screen.queryByRole('heading')).not.toBeInTheDocument();
+ });
+
+ it('links the brand to the home page by default', () => {
+ render( );
+ const link = screen.getByRole('link');
+
+ expect(link).toHaveAttribute('href', '/');
+ expect(link).toHaveTextContent('PropChain');
+ });
+
+ it('renders the brand as plain text when brandHref is null', () => {
+ render( );
+
+ expect(screen.getByText('PropChain')).toBeInTheDocument();
+ expect(screen.queryByRole('link')).not.toBeInTheDocument();
+ });
+
+ it('renders a labelled back link when backHref is given', () => {
+ render( );
+
+ const back = screen.getByRole('link', { name: 'Back to dashboard' });
+ expect(back).toHaveAttribute('href', '/dashboard');
+ });
+
+ it('still names the back link when no visible label is supplied', () => {
+ render( );
+
+ expect(screen.getByRole('link', { name: 'Go back' })).toHaveAttribute(
+ 'href',
+ '/properties',
+ );
+ });
+
+ it('renders leading and action slots', () => {
+ render(
+ Open sidebar}
+ actions={Connect wallet }
+ />,
+ );
+
+ expect(
+ screen.getByRole('button', { name: 'Open sidebar' }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole('button', { name: 'Connect wallet' }),
+ ).toBeInTheDocument();
+ });
+
+ it('applies sticky positioning only when asked', () => {
+ const { container: plain } = render( );
+ expect(plain.querySelector('header')).not.toHaveClass('sticky');
+
+ const { container: stuck } = render( );
+ expect(stuck.querySelector('header')).toHaveClass('sticky', 'top-0');
+ });
+
+ it('can render without claiming the banner landmark', () => {
+ render( );
+
+ expect(screen.queryByRole('banner')).not.toBeInTheDocument();
+ expect(screen.getByText('PropChain')).toBeInTheDocument();
+ });
+
+ it('allows the container width to be overridden', () => {
+ const { container } = render( );
+
+ expect(container.querySelector('.max-w-4xl')).toBeInTheDocument();
+ });
+
+ it('matches the rendered structure', () => {
+ const { container } = render(
+ wallet }
+ />,
+ );
+
+ expect(container.firstChild).toMatchSnapshot();
+ });
+});
diff --git a/src/components/layout/__tests__/__snapshots__/AppHeader.test.tsx.snap b/src/components/layout/__tests__/__snapshots__/AppHeader.test.tsx.snap
new file mode 100644
index 00000000..e5a3c6d9
--- /dev/null
+++ b/src/components/layout/__tests__/__snapshots__/AppHeader.test.tsx.snap
@@ -0,0 +1,82 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`AppHeader matches the rendered structure 1`] = `
+
+`;
diff --git a/src/components/property/PropertyDetailHeaderActions.tsx b/src/components/property/PropertyDetailHeaderActions.tsx
new file mode 100644
index 00000000..13d76ff9
--- /dev/null
+++ b/src/components/property/PropertyDetailHeaderActions.tsx
@@ -0,0 +1,31 @@
+'use client';
+
+import { PriceAlertBell } from '@/components/PriceAlertBell';
+import { WalletConnector } from '@/components/WalletConnector';
+
+/**
+ * Issue #1093 — the client island in the property detail header.
+ *
+ * This was `src/components/PropertyDetailClient.tsx`, a name that promised a
+ * client rendering of the whole property detail and delivered two buttons. The
+ * misleading name is most of why a second, unrelated `PropertyDetailClient`
+ * grew under `src/app/properties/[id]/` taking a different prop shape: nothing
+ * about the old name said "header actions", so the obvious name was still free.
+ *
+ * It also took a `propertyId` prop it never read. That is gone; the bell and the
+ * connector both resolve their own state.
+ *
+ * `AppHeader` supplies the flex row, so this returns a fragment.
+ *
+ * @returns The interactive controls for the property detail header.
+ */
+export function PropertyDetailHeaderActions() {
+ return (
+ <>
+
+
+ >
+ );
+}
+
+export default PropertyDetailHeaderActions;
diff --git a/src/components/property/__tests__/PropertyDetailConsolidation.test.tsx b/src/components/property/__tests__/PropertyDetailConsolidation.test.tsx
new file mode 100644
index 00000000..8f04d85a
--- /dev/null
+++ b/src/components/property/__tests__/PropertyDetailConsolidation.test.tsx
@@ -0,0 +1,101 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import { render, screen } from '@testing-library/react';
+import { PropertyDetailHeaderActions } from '@/components/property/PropertyDetailHeaderActions';
+
+jest.mock('@/components/PriceAlertBell', () => ({
+ PriceAlertBell: () => Price alerts ,
+}));
+
+jest.mock('@/components/WalletConnector', () => ({
+ WalletConnector: () => Connect wallet ,
+}));
+
+/**
+ * Issue #1093 — property detail is one server entry plus one client island.
+ *
+ * Property detail used to render through four modules: the legacy
+ * `components/PropertyDetail`, `components/PropertyDetailServer`, and *two*
+ * different `PropertyDetailClient` implementations — one keyed by `propertyId`,
+ * one by `property` — reached from two different page files. Only one path was
+ * ever live; the rest was reachable only from `page-new.tsx`, which Next does
+ * not route and which carried hardcoded mock data.
+ *
+ * The structural assertions below are the part worth keeping. A unit test cannot
+ * stop someone re-adding a parallel tree, but it can fail the moment a deleted
+ * module is imported again or a second detail client appears.
+ */
+const SRC = path.join(process.cwd(), 'src');
+
+function sourceFiles(dir: string, acc: string[] = []): string[] {
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
+ const full = path.join(dir, entry.name);
+ if (entry.isDirectory()) {
+ sourceFiles(full, acc);
+ } else if (/\.(ts|tsx)$/.test(entry.name)) {
+ acc.push(full);
+ }
+ }
+ return acc;
+}
+
+describe('PropertyDetailHeaderActions', () => {
+ it('renders the price alert bell and the wallet connector', () => {
+ render( );
+
+ expect(
+ screen.getByRole('button', { name: 'Price alerts' }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole('button', { name: 'Connect wallet' }),
+ ).toBeInTheDocument();
+ });
+
+ it('takes no props, so it cannot drift from a property shape', () => {
+ expect(PropertyDetailHeaderActions.length).toBe(0);
+ });
+});
+
+describe('property detail module graph', () => {
+ const files = sourceFiles(SRC);
+
+ it('finds source files to scan', () => {
+ // Guards against the walker silently returning nothing and making the
+ // assertions below vacuous.
+ expect(files.length).toBeGreaterThan(100);
+ });
+
+ it.each([
+ 'components/PropertyDetail',
+ 'components/PropertyDetailClient',
+ './PropertyDetailClient',
+ 'PropertyDetailPageClient',
+ ])('has no import of the removed module %s', (removed) => {
+ const offenders = files.filter((file) => {
+ const source = fs.readFileSync(file, 'utf8');
+ // Match only import/export specifiers, so prose in a comment is fine.
+ const pattern = new RegExp(
+ `from\\s+['"][^'"]*${removed.replace(/[./]/g, '\\$&')}['"]`,
+ );
+ return pattern.test(source) && !file.includes('__tests__');
+ });
+
+ expect(offenders).toEqual([]);
+ });
+
+ it('keeps exactly one property detail server entry', () => {
+ const entries = files.filter((file) =>
+ /[\\/]PropertyDetailServer\.tsx$/.test(file),
+ );
+
+ expect(entries).toHaveLength(1);
+ });
+
+ it('has no module named PropertyDetailClient anywhere', () => {
+ const clients = files.filter((file) =>
+ /[\\/]PropertyDetailClient\.tsx$/.test(file),
+ );
+
+ expect(clients).toEqual([]);
+ });
+});
diff --git a/src/hooks/usePropertyCardData.ts b/src/hooks/usePropertyCardData.ts
index bb9b6d94..63e08ed7 100644
--- a/src/hooks/usePropertyCardData.ts
+++ b/src/hooks/usePropertyCardData.ts
@@ -1,8 +1,7 @@
import { useMemo } from 'react';
import type { Property } from '@/types/property';
-import { useComparisonStore } from '@/store/comparisonStore';
-import { useCompareStore } from '@/store/compareStore';
+import { MAX_COMPARE, useCompareStore } from '@/store/compareStore';
import { useFavoritesStore } from '@/store/favoritesStore';
import {
@@ -16,7 +15,7 @@ import {
* Derives the display-ready fields a property card renders from a `Property`.
*/
export function usePropertyCardData(property: Property) {
- const { isPropertySelected } = useComparisonStore();
+ const isPropertySelected = useCompareStore((state) => state.isPropertySelected);
const selectedIds = useCompareStore((state) => state.selectedIds);
@@ -31,7 +30,7 @@ export function usePropertyCardData(property: Property) {
isCompared,
compareLimitReached:
- selectedIds.length >= 3 && !isCompared,
+ selectedIds.length >= MAX_COMPARE && !isCompared,
isFavorite: isFavorite(property.id),
diff --git a/src/store/__tests__/compareStore.test.ts b/src/store/__tests__/compareStore.test.ts
index bb698adc..0a620254 100644
--- a/src/store/__tests__/compareStore.test.ts
+++ b/src/store/__tests__/compareStore.test.ts
@@ -1,119 +1,211 @@
-import { act, renderHook } from '@testing-library/react';
-import { useCompareStore } from '../compareStore';
+/**
+ * Tests for the canonical compare store (#1090).
+ *
+ * Replaces the separate compareStore / comparisonStore / comparisonHistoryStore
+ * suites. Both slices are covered here because the point of the merge is that
+ * selection and history can no longer disagree with each other.
+ */
+import type { Property } from '@/types/property';
+import { MAX_COMPARE, MAX_HISTORY, useCompareStore } from '../compareStore';
+
+const propertyFixture = (id: string): Property =>
+ ({
+ id,
+ name: `Property ${id}`,
+ price: { total: 100_000, currency: 'USD' },
+ }) as unknown as Property;
+
+const reset = () =>
+ useCompareStore.setState({ selectedIds: [], propertyCache: {}, history: [] });
+
+describe('selection slice', () => {
+ beforeEach(reset);
+
+ it('starts empty', () => {
+ const state = useCompareStore.getState();
+ expect(state.selectedIds).toEqual([]);
+ expect(state.getSelectedProperties()).toEqual([]);
+ });
-describe('compareStore', () => {
- beforeEach(() => {
- localStorage.clear();
- useCompareStore.getState().clearCompare();
+ it('adds by id', () => {
+ useCompareStore.getState().addProperty('a');
+ expect(useCompareStore.getState().selectedIds).toEqual(['a']);
});
- it('starts with no selected properties', () => {
- const { result } = renderHook(() => useCompareStore());
- expect(result.current.selectedIds).toEqual([]);
+ it('adds by property and caches the object', () => {
+ useCompareStore.getState().addProperty(propertyFixture('a'));
+
+ const state = useCompareStore.getState();
+ expect(state.selectedIds).toEqual(['a']);
+ expect(state.getSelectedProperties()).toHaveLength(1);
+ expect(state.getSelectedProperties()[0].name).toBe('Property a');
});
- it('adds a property to the selection', () => {
- const { result } = renderHook(() => useCompareStore());
+ it('ignores a duplicate', () => {
+ const store = useCompareStore.getState();
+ store.addProperty('a');
+ store.addProperty('a');
+ expect(useCompareStore.getState().selectedIds).toEqual(['a']);
+ });
- act(() => {
- result.current.addProperty('prop-1');
- });
+ it('caps the selection at MAX_COMPARE', () => {
+ const store = useCompareStore.getState();
+ for (const id of ['a', 'b', 'c', 'd']) store.addProperty(id);
- expect(result.current.selectedIds).toEqual(['prop-1']);
+ expect(useCompareStore.getState().selectedIds).toHaveLength(MAX_COMPARE);
+ expect(useCompareStore.getState().selectedIds).not.toContain('d');
});
- it('does not add a duplicate property', () => {
- const { result } = renderHook(() => useCompareStore());
+ it('removes by id and by property alike', () => {
+ const store = useCompareStore.getState();
+ store.addProperty(propertyFixture('a'));
+ store.addProperty(propertyFixture('b'));
- act(() => {
- result.current.addProperty('prop-1');
- result.current.addProperty('prop-1');
- });
+ useCompareStore.getState().removeProperty('a');
+ expect(useCompareStore.getState().selectedIds).toEqual(['b']);
- expect(result.current.selectedIds).toEqual(['prop-1']);
+ useCompareStore.getState().removeProperty(propertyFixture('b'));
+ expect(useCompareStore.getState().selectedIds).toEqual([]);
});
- it('limits the selection to 3 properties', () => {
- const { result } = renderHook(() => useCompareStore());
+ it('drops the cached object when a selection is removed', () => {
+ useCompareStore.getState().addProperty(propertyFixture('a'));
+ useCompareStore.getState().removeProperty('a');
- act(() => {
- result.current.addProperty('prop-1');
- result.current.addProperty('prop-2');
- result.current.addProperty('prop-3');
- result.current.addProperty('prop-4');
- });
+ expect(useCompareStore.getState().propertyCache).toEqual({});
+ });
+
+ it('toggles on and off', () => {
+ const toggle = () => useCompareStore.getState().toggleProperty('a');
- expect(result.current.selectedIds).toEqual(['prop-1', 'prop-2', 'prop-3']);
+ toggle();
+ expect(useCompareStore.getState().isPropertySelected('a')).toBe(true);
+
+ toggle();
+ expect(useCompareStore.getState().isPropertySelected('a')).toBe(false);
});
- it('removes a property from the selection', () => {
- const { result } = renderHook(() => useCompareStore());
+ it('does not toggle a fourth property in', () => {
+ const store = useCompareStore.getState();
+ for (const id of ['a', 'b', 'c']) store.addProperty(id);
+
+ useCompareStore.getState().toggleProperty('d');
- act(() => {
- result.current.addProperty('prop-1');
- result.current.addProperty('prop-2');
- result.current.removeProperty('prop-1');
- });
+ expect(useCompareStore.getState().isPropertySelected('d')).toBe(false);
+ expect(useCompareStore.getState().selectedIds).toHaveLength(MAX_COMPARE);
+ });
+
+ it('clears selection and cache together', () => {
+ useCompareStore.getState().addProperty(propertyFixture('a'));
+ useCompareStore.getState().clearCompare();
- expect(result.current.selectedIds).toEqual(['prop-2']);
+ const state = useCompareStore.getState();
+ expect(state.selectedIds).toEqual([]);
+ expect(state.propertyCache).toEqual({});
});
- it('toggles a property in and out of the selection', () => {
- const { result } = renderHook(() => useCompareStore());
+ it('caps setSelectedIds and keeps only still-selected cache entries', () => {
+ useCompareStore.getState().addProperty(propertyFixture('a'));
+ useCompareStore.getState().addProperty(propertyFixture('b'));
- act(() => {
- result.current.toggleProperty('prop-1');
- });
- expect(result.current.selectedIds).toEqual(['prop-1']);
+ useCompareStore.getState().setSelectedIds(['b', 'x', 'y', 'z']);
- act(() => {
- result.current.toggleProperty('prop-1');
- });
- expect(result.current.selectedIds).toEqual([]);
+ const state = useCompareStore.getState();
+ expect(state.selectedIds).toEqual(['b', 'x', 'y']);
+ // 'a' is no longer selected, so its object is not retained.
+ expect(Object.keys(state.propertyCache)).toEqual(['b']);
});
- it('does not toggle beyond the 3-property limit', () => {
- const { result } = renderHook(() => useCompareStore());
+ it('returns selected properties in selection order, skipping uncached ids', () => {
+ const store = useCompareStore.getState();
+ store.addProperty(propertyFixture('a'));
+ // Added by bare id, as a share link or restored session would.
+ store.addProperty('b');
+ store.addProperty(propertyFixture('c'));
- act(() => {
- result.current.addProperty('prop-1');
- result.current.addProperty('prop-2');
- result.current.addProperty('prop-3');
- result.current.toggleProperty('prop-4');
- });
+ const resolved = useCompareStore.getState().getSelectedProperties();
- expect(result.current.selectedIds).toEqual(['prop-1', 'prop-2', 'prop-3']);
+ expect(resolved.map((p) => p.id)).toEqual(['a', 'c']);
});
+});
- it('setSelectedIds replaces the selection and clamps to 3', () => {
- const { result } = renderHook(() => useCompareStore());
+describe('history slice', () => {
+ beforeEach(reset);
- act(() => {
- result.current.setSelectedIds(['a', 'b', 'c', 'd', 'e']);
- });
+ it('records a comparison with a share URL', () => {
+ useCompareStore.getState().addComparison(['a', 'b']);
- expect(result.current.selectedIds).toEqual(['a', 'b', 'c']);
+ const [entry] = useCompareStore.getState().history;
+ expect(entry.propertyIds).toEqual(['a', 'b']);
+ expect(entry.shareUrl).toBe('/compare?ids=a,b');
+ expect(entry.timestamp).toBeGreaterThan(0);
+ expect(entry.id).toMatch(/^comp/);
});
- it('clearCompare empties the selection', () => {
- const { result } = renderHook(() => useCompareStore());
+ it('ignores an empty comparison', () => {
+ useCompareStore.getState().addComparison([]);
+ expect(useCompareStore.getState().history).toEqual([]);
+ });
+
+ it('puts the newest entry first', () => {
+ useCompareStore.getState().addComparison(['a']);
+ useCompareStore.getState().addComparison(['b']);
+
+ expect(useCompareStore.getState().history[0].propertyIds).toEqual(['b']);
+ });
- act(() => {
- result.current.addProperty('prop-1');
- result.current.clearCompare();
- });
+ it('caps history at MAX_HISTORY', () => {
+ for (let i = 0; i < MAX_HISTORY + 3; i++) {
+ useCompareStore.getState().addComparison([`p${i}`]);
+ }
- expect(result.current.selectedIds).toEqual([]);
+ expect(useCompareStore.getState().history).toHaveLength(MAX_HISTORY);
});
- it('persists the selection across store instances', () => {
- const { result } = renderHook(() => useCompareStore());
+ it('removes a single entry by id', () => {
+ useCompareStore.getState().addComparison(['a']);
+ const { id } = useCompareStore.getState().history[0];
+
+ useCompareStore.getState().removeComparison(id);
+
+ expect(useCompareStore.getState().history).toEqual([]);
+ });
+
+ it('clears all history', () => {
+ useCompareStore.getState().addComparison(['a']);
+ useCompareStore.getState().addComparison(['b']);
+
+ useCompareStore.getState().clearHistory();
+
+ expect(useCompareStore.getState().getHistory()).toEqual([]);
+ });
+
+ it('keeps history independent of selection', () => {
+ // The reason history was a separate store; it must survive a clear.
+ useCompareStore.getState().addComparison(['a', 'b']);
+ useCompareStore.getState().addProperty('a');
+
+ useCompareStore.getState().clearCompare();
+
+ expect(useCompareStore.getState().selectedIds).toEqual([]);
+ expect(useCompareStore.getState().history).toHaveLength(1);
+ });
+});
+
+describe('persistence', () => {
+ beforeEach(reset);
+
+ it('persists selection, cache and history under one key', () => {
+ useCompareStore.getState().addProperty(propertyFixture('a'));
+ useCompareStore.getState().addComparison(['a']);
- act(() => {
- result.current.addProperty('prop-1');
- });
+ const raw = window.localStorage.getItem('propchain-compare');
+ expect(raw).not.toBeNull();
- const { result: result2 } = renderHook(() => useCompareStore());
- expect(result2.current.selectedIds).toEqual(['prop-1']);
+ const parsed = JSON.parse(raw as string);
+ expect(parsed.state.selectedIds).toEqual(['a']);
+ expect(parsed.state.history).toHaveLength(1);
+ expect(Object.keys(parsed.state.propertyCache)).toEqual(['a']);
+ expect(parsed.version).toBe(2);
});
});
diff --git a/src/store/__tests__/comparisonHistoryStore.test.ts b/src/store/__tests__/comparisonHistoryStore.test.ts
deleted file mode 100644
index b0de39da..00000000
--- a/src/store/__tests__/comparisonHistoryStore.test.ts
+++ /dev/null
@@ -1,94 +0,0 @@
-import { act, renderHook } from '@testing-library/react';
-import { useComparisonHistoryStore } from '../comparisonHistoryStore';
-
-describe('comparisonHistoryStore', () => {
- beforeEach(() => {
- localStorage.clear();
- useComparisonHistoryStore.getState().clearHistory();
- });
-
- it('starts with an empty history', () => {
- const { result } = renderHook(() => useComparisonHistoryStore());
- expect(result.current.getHistory()).toEqual([]);
- });
-
- it('records a comparison with a share URL', () => {
- const { result } = renderHook(() => useComparisonHistoryStore());
-
- act(() => {
- result.current.addComparison(['prop-1', 'prop-2']);
- });
-
- const history = result.current.getHistory();
- expect(history).toHaveLength(1);
- expect(history[0].propertyIds).toEqual(['prop-1', 'prop-2']);
- expect(history[0].shareUrl).toBe('/compare?ids=prop-1,prop-2');
- expect(history[0].timestamp).toBeGreaterThan(0);
- });
-
- it('ignores empty comparison requests', () => {
- const { result } = renderHook(() => useComparisonHistoryStore());
-
- act(() => {
- result.current.addComparison([]);
- });
-
- expect(result.current.getHistory()).toEqual([]);
- });
-
- it('caps the history at 5 entries, evicting the oldest', () => {
- const { result } = renderHook(() => useComparisonHistoryStore());
-
- act(() => {
- for (let i = 1; i <= 6; i++) {
- result.current.addComparison([`prop-${i}`]);
- }
- });
-
- const history = result.current.getHistory();
- expect(history).toHaveLength(5);
- expect(history[0].propertyIds).toEqual(['prop-6']);
- expect(history[4].propertyIds).toEqual(['prop-2']);
- });
-
- it('removes a single comparison entry', () => {
- const { result } = renderHook(() => useComparisonHistoryStore());
-
- act(() => {
- result.current.addComparison(['prop-1']);
- result.current.addComparison(['prop-2']);
- });
-
- const firstId = result.current.getHistory()[0].id;
- act(() => {
- result.current.removeComparison(firstId);
- });
-
- const history = result.current.getHistory();
- expect(history).toHaveLength(1);
- expect(history[0].propertyIds).toEqual(['prop-1']);
- });
-
- it('clearHistory empties the history', () => {
- const { result } = renderHook(() => useComparisonHistoryStore());
-
- act(() => {
- result.current.addComparison(['prop-1']);
- result.current.clearHistory();
- });
-
- expect(result.current.getHistory()).toEqual([]);
- });
-
- it('persists the history across store instances', () => {
- const { result } = renderHook(() => useComparisonHistoryStore());
-
- act(() => {
- result.current.addComparison(['prop-1', 'prop-2']);
- });
-
- const { result: result2 } = renderHook(() => useComparisonHistoryStore());
- expect(result2.current.getHistory()).toHaveLength(1);
- expect(result2.current.getHistory()[0].propertyIds).toEqual(['prop-1', 'prop-2']);
- });
-});
diff --git a/src/store/__tests__/comparisonStore.test.ts b/src/store/__tests__/comparisonStore.test.ts
deleted file mode 100644
index 7cfcb18a..00000000
--- a/src/store/__tests__/comparisonStore.test.ts
+++ /dev/null
@@ -1,159 +0,0 @@
-import { act, renderHook } from '@testing-library/react';
-import { useComparisonStore } from '../comparisonStore';
-import type { Property } from '@/types/property';
-
-const mockProperty = (id: string): Property => ({
- id,
- name: `Property ${id}`,
- description: 'A test property',
- location: {
- address: '1 Test St',
- city: 'Testville',
- state: 'TS',
- country: 'Testland',
- zipCode: '00000',
- coordinates: { lat: 0, lng: 0 },
- },
- price: { total: 100000, perToken: 100, currency: 'USD' },
- propertyType: 'residential',
- blockchain: 'ethereum',
- tokenInfo: {
- totalSupply: 1000,
- available: 900,
- sold: 100,
- contractAddress: '0x1234...5678',
- tokenSymbol: 'TST',
- },
- metrics: {
- roi: 8,
- annualReturn: 8000,
- transactionVolume: 50000,
- appreciationRate: 5,
- },
- details: { squareFeet: 1000, yearBuilt: 2020, amenities: [] },
- images: [],
- listedDate: '2024-01-01',
- status: 'active',
-});
-
-describe('comparisonStore', () => {
- beforeEach(() => {
- localStorage.clear();
- useComparisonStore.getState().clearProperties();
- });
-
- it('starts with no selected properties and a max of 3', () => {
- const { result } = renderHook(() => useComparisonStore());
- expect(result.current.selectedProperties).toEqual([]);
- expect(result.current.maxProperties).toBe(3);
- });
-
- it('adds a property to the selection', () => {
- const { result } = renderHook(() => useComparisonStore());
-
- act(() => {
- result.current.addProperty(mockProperty('prop-1'));
- });
-
- expect(result.current.selectedProperties).toHaveLength(1);
- expect(result.current.selectedProperties[0].id).toBe('prop-1');
- });
-
- it('does not add a property twice', () => {
- const { result } = renderHook(() => useComparisonStore());
-
- act(() => {
- result.current.addProperty(mockProperty('prop-1'));
- result.current.addProperty(mockProperty('prop-1'));
- });
-
- expect(result.current.selectedProperties).toHaveLength(1);
- });
-
- it('stops adding once the max is reached', () => {
- const { result } = renderHook(() => useComparisonStore());
-
- act(() => {
- result.current.addProperty(mockProperty('prop-1'));
- result.current.addProperty(mockProperty('prop-2'));
- result.current.addProperty(mockProperty('prop-3'));
- result.current.addProperty(mockProperty('prop-4'));
- });
-
- expect(result.current.selectedProperties).toHaveLength(3);
- });
-
- it('removes a property from the selection', () => {
- const { result } = renderHook(() => useComparisonStore());
-
- act(() => {
- result.current.addProperty(mockProperty('prop-1'));
- result.current.addProperty(mockProperty('prop-2'));
- result.current.removeProperty(mockProperty('prop-1'));
- });
-
- expect(result.current.selectedProperties).toHaveLength(1);
- expect(result.current.selectedProperties[0].id).toBe('prop-2');
- });
-
- it('reports whether a property is selected', () => {
- const { result } = renderHook(() => useComparisonStore());
-
- act(() => {
- result.current.addProperty(mockProperty('prop-1'));
- });
-
- expect(result.current.isPropertySelected('prop-1')).toBe(true);
- expect(result.current.isPropertySelected('prop-2')).toBe(false);
- });
-
- it('toggles a property in and out of the selection', () => {
- const { result } = renderHook(() => useComparisonStore());
-
- act(() => {
- result.current.toggleProperty(mockProperty('prop-1'));
- });
- expect(result.current.isPropertySelected('prop-1')).toBe(true);
-
- act(() => {
- result.current.toggleProperty(mockProperty('prop-1'));
- });
- expect(result.current.isPropertySelected('prop-1')).toBe(false);
- });
-
- it('does not toggle beyond the max selection size', () => {
- const { result } = renderHook(() => useComparisonStore());
-
- act(() => {
- result.current.addProperty(mockProperty('prop-1'));
- result.current.addProperty(mockProperty('prop-2'));
- result.current.addProperty(mockProperty('prop-3'));
- result.current.toggleProperty(mockProperty('prop-4'));
- });
-
- expect(result.current.selectedProperties).toHaveLength(3);
- });
-
- it('clearProperties empties the selection', () => {
- const { result } = renderHook(() => useComparisonStore());
-
- act(() => {
- result.current.addProperty(mockProperty('prop-1'));
- result.current.clearProperties();
- });
-
- expect(result.current.selectedProperties).toEqual([]);
- });
-
- it('persists the selection across store instances', () => {
- const { result } = renderHook(() => useComparisonStore());
-
- act(() => {
- result.current.addProperty(mockProperty('prop-1'));
- });
-
- const { result: result2 } = renderHook(() => useComparisonStore());
- expect(result2.current.selectedProperties).toHaveLength(1);
- expect(result2.current.selectedProperties[0].id).toBe('prop-1');
- });
-});
diff --git a/src/store/compareStore.ts b/src/store/compareStore.ts
index e23ecef1..7906de24 100644
--- a/src/store/compareStore.ts
+++ b/src/store/compareStore.ts
@@ -2,68 +2,221 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
+import type { Property } from '@/types/property';
+import { generateSecureId } from '@/utils/secureId';
+
+/** Most properties that can be compared at once. */
+export const MAX_COMPARE = 3;
+
+/** Most comparison snapshots retained in history. */
+export const MAX_HISTORY = 5;
+
+/** A saved comparison, shareable by URL. */
+export interface ComparisonHistory {
+ id: string;
+ propertyIds: string[];
+ timestamp: number;
+ shareUrl: string;
+}
interface CompareStore {
+ // ── selection slice ────────────────────────────────────────────────────
+ /**
+ * The canonical selection. Ids rather than objects because an id is what the
+ * share URL (`/compare?ids=…`) and persistence carry, and what a card knows
+ * about itself.
+ */
selectedIds: string[];
- addProperty: (id: string) => void;
- removeProperty: (id: string) => void;
- toggleProperty: (id: string) => void;
+ /**
+ * Best-effort cache of the full objects, populated when a caller toggles with
+ * a `Property` rather than a bare id.
+ *
+ * Deliberately not authoritative: a selection restored from storage or from a
+ * share link has ids but no objects until something fetches them. UI that
+ * needs an object must tolerate a miss — `getSelectedProperties()` drops
+ * unresolved ids rather than inventing placeholders.
+ */
+ propertyCache: Record;
+
+ addProperty: (target: Property | string) => void;
+ removeProperty: (target: Property | string) => void;
+ toggleProperty: (target: Property | string) => void;
clearCompare: () => void;
setSelectedIds: (ids: string[]) => void;
+ isPropertySelected: (propertyId: string) => boolean;
+ /** Selected properties resolvable from the cache, ordered by selection. */
+ getSelectedProperties: () => Property[];
+
+ // ── history slice ──────────────────────────────────────────────────────
+ history: ComparisonHistory[];
+ addComparison: (propertyIds: string[]) => void;
+ removeComparison: (id: string) => void;
+ clearHistory: () => void;
+ getHistory: () => ComparisonHistory[];
}
+/** Accepts either a `Property` or a bare id, since both call styles exist. */
+const idOf = (target: Property | string): string =>
+ typeof target === 'string' ? target : target.id;
+
+/**
+ * Canonical compare store: selection and history in one module (#1090).
+ *
+ * Previously three stores split this. `compareStore` held ids, `comparisonStore`
+ * held whole `Property` objects under a separate persistence key, and
+ * `comparisonHistoryStore` held snapshots. A fourth module,
+ * `compareStoreCanonical`, declared `compareStore` authoritative but had no
+ * importers, so it changed nothing.
+ *
+ * The split was not merely untidy. `PropertyCard` wrote to *both* selection
+ * stores on every toggle while reading "is this selected" from one and "is the
+ * limit reached" from the other, so the two could disagree — and `/compare` read
+ * `comparisonStore` while `ComparisonBar` read `compareStore`, so the bar and
+ * the page could show different selections. One store removes the class of bug
+ * rather than patching an instance of it.
+ */
export const useCompareStore = create()(
persist(
(set, get) => ({
selectedIds: [],
+ propertyCache: {},
+ history: [],
+
+ addProperty: (target) => {
+ const id = idOf(target);
- addProperty: (id: string) => {
set((state) => {
- if (state.selectedIds.includes(id) || state.selectedIds.length >= 3) {
+ if (state.selectedIds.includes(id) || state.selectedIds.length >= MAX_COMPARE) {
return state;
}
return {
selectedIds: [...state.selectedIds, id],
+ propertyCache:
+ typeof target === 'string'
+ ? state.propertyCache
+ : { ...state.propertyCache, [id]: target },
};
});
},
- removeProperty: (id: string) => {
- set((state) => ({
- selectedIds: state.selectedIds.filter((selectedId) => selectedId !== id),
- }));
- },
+ removeProperty: (target) => {
+ const id = idOf(target);
- toggleProperty: (id: string) => {
set((state) => {
- if (state.selectedIds.includes(id)) {
- return {
- selectedIds: state.selectedIds.filter((selectedId) => selectedId !== id),
- };
- }
-
- if (state.selectedIds.length >= 3) {
- return state;
- }
+ // The cache entry is dropped with the selection so a long session does
+ // not accumulate objects for properties nobody is comparing.
+ const { [id]: _removed, ...remainingCache } = state.propertyCache;
return {
- selectedIds: [...state.selectedIds, id],
+ selectedIds: state.selectedIds.filter((selectedId) => selectedId !== id),
+ propertyCache: remainingCache,
};
});
},
- clearCompare: () => {
- set({ selectedIds: [] });
+ toggleProperty: (target) => {
+ const id = idOf(target);
+
+ if (get().selectedIds.includes(id)) {
+ get().removeProperty(id);
+ return;
+ }
+
+ get().addProperty(target);
},
- setSelectedIds: (ids: string[]) => {
- set({ selectedIds: ids.slice(0, 3) });
+ clearCompare: () => set({ selectedIds: [], propertyCache: {} }),
+
+ setSelectedIds: (ids) =>
+ set((state) => {
+ const capped = ids.slice(0, MAX_COMPARE);
+ const cache: Record = {};
+
+ for (const id of capped) {
+ const cached = state.propertyCache[id];
+ if (cached) cache[id] = cached;
+ }
+
+ return { selectedIds: capped, propertyCache: cache };
+ }),
+
+ isPropertySelected: (propertyId) => get().selectedIds.includes(propertyId),
+
+ getSelectedProperties: () => {
+ const { selectedIds, propertyCache } = get();
+
+ return selectedIds
+ .map((id) => propertyCache[id])
+ .filter((property): property is Property => Boolean(property));
+ },
+
+ addComparison: (propertyIds) => {
+ if (propertyIds.length === 0) return;
+
+ const entry: ComparisonHistory = {
+ id: generateSecureId('comp'),
+ propertyIds,
+ timestamp: Date.now(),
+ shareUrl: `/compare?ids=${propertyIds.join(',')}`,
+ };
+
+ set((state) => ({
+ history: [entry, ...state.history].slice(0, MAX_HISTORY),
+ }));
},
+
+ removeComparison: (id) =>
+ set((state) => ({
+ history: state.history.filter((item) => item.id !== id),
+ })),
+
+ clearHistory: () => set({ history: [] }),
+
+ getHistory: () => get().history,
}),
{
+ // Same key as the previous selection store, so an existing selection
+ // survives this refactor rather than being silently dropped.
name: 'propchain-compare',
- partialize: (state) => ({ selectedIds: state.selectedIds }),
- }
- )
+ version: 2,
+ partialize: (state) => ({
+ selectedIds: state.selectedIds,
+ propertyCache: state.propertyCache,
+ history: state.history,
+ }),
+ /**
+ * v1 persisted only `selectedIds` under this key, and history lived under
+ * `propchain-comparison-history`. Carry both forward so users do not lose
+ * a selection or their recent comparisons on upgrade.
+ */
+ migrate: (persisted, version) => {
+ const state = (persisted ?? {}) as Partial;
+ if (version >= 2) return state;
+
+ let history: ComparisonHistory[] = state.history ?? [];
+
+ if (history.length === 0 && typeof window !== 'undefined') {
+ try {
+ const legacy = window.localStorage.getItem('propchain-comparison-history');
+ if (legacy) {
+ const parsed = JSON.parse(legacy) as { state?: { history?: ComparisonHistory[] } };
+ history = parsed.state?.history ?? [];
+ }
+ } catch {
+ // A malformed or unreadable legacy entry is not worth failing the
+ // upgrade over; history is recoverable by comparing again.
+ history = [];
+ }
+ }
+
+ return {
+ ...state,
+ selectedIds: state.selectedIds ?? [],
+ propertyCache: state.propertyCache ?? {},
+ history,
+ };
+ },
+ },
+ ),
);
diff --git a/src/store/compareStoreCanonical.ts b/src/store/compareStoreCanonical.ts
deleted file mode 100644
index 396b4c7b..00000000
--- a/src/store/compareStoreCanonical.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-/**
- * Canonical compare-selection accessor.
- *
- * PropertyCard, the compare page, and the header badge previously read
- * selection state from two independent stores (`compareStore` and
- * `comparisonStore`), which could disagree. `compareStore` is the single
- * source of truth for the current selection; `comparisonHistoryStore`
- * remains scoped to history snapshots only. Consumers should import
- * `useCanonicalCompareSelection` instead of reading `comparisonStore`
- * directly, so selection state can't fork again.
- */
-import { useCompareStore } from './compareStore';
-
-export function useCanonicalCompareSelection() {
- const selectedIds = useCompareStore((state) => state.selectedIds ?? []);
- const toggle = useCompareStore((state) => state.toggleProperty);
- const clear = useCompareStore((state) => state.clearCompare);
-
- return {
- selectedIds,
- count: selectedIds.length,
- toggle,
- clear,
- };
-}
diff --git a/src/store/comparisonHistoryStore.ts b/src/store/comparisonHistoryStore.ts
deleted file mode 100644
index 3ab7c91d..00000000
--- a/src/store/comparisonHistoryStore.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-'use client';
-
-import { create } from 'zustand';
-import { persist } from 'zustand/middleware';
-import { genId } from '@/utils/genId';
-import { generateSecureId } from '@/utils/secureId';
-
-export interface ComparisonHistory {
- id: string;
- propertyIds: string[];
- timestamp: number;
- shareUrl: string;
-}
-
-interface ComparisonHistoryStore {
- history: ComparisonHistory[];
- addComparison: (propertyIds: string[]) => void;
- removeComparison: (id: string) => void;
- clearHistory: () => void;
- getHistory: () => ComparisonHistory[];
-}
-
-const MAX_HISTORY = 5;
-
-export const useComparisonHistoryStore = create()(
- persist(
- (set, get) => ({
- history: [],
-
- addComparison: (propertyIds: string[]) => {
- if (propertyIds.length === 0) return;
-
- const id = generateSecureId('comp');
- const shareUrl = `/compare?ids=${propertyIds.join(',')}`;
-
- const newComparison: ComparisonHistory = {
- id,
- propertyIds,
- timestamp: Date.now(),
- shareUrl,
- };
-
- set((state) => {
- const updatedHistory = [newComparison, ...state.history].slice(0, MAX_HISTORY);
- return { history: updatedHistory };
- });
- },
-
- removeComparison: (id: string) => {
- set((state) => ({
- history: state.history.filter((item) => item.id !== id),
- }));
- },
-
- clearHistory: () => {
- set({ history: [] });
- },
-
- getHistory: () => {
- return get().history;
- },
- }),
- {
- name: 'propchain-comparison-history',
- partialize: (state) => ({ history: state.history }),
- }
- )
-);
diff --git a/src/store/comparisonStore.ts b/src/store/comparisonStore.ts
deleted file mode 100644
index 721b179e..00000000
--- a/src/store/comparisonStore.ts
+++ /dev/null
@@ -1,61 +0,0 @@
-import { create } from 'zustand';
-import { persist } from 'zustand/middleware';
-import type { Property } from '@/types/property';
-
-interface ComparisonState {
- selectedProperties: Property[];
- maxProperties: number;
- addProperty: (property: Property) => void;
- removeProperty: (property: Property) => void;
- clearProperties: () => void;
- isPropertySelected: (propertyId: string) => boolean;
- toggleProperty: (property: Property) => void;
-}
-
-export const useComparisonStore = create()(
- persist(
- (set, get) => ({
- selectedProperties: [],
- maxProperties: 3,
-
- addProperty: (property: Property) => {
- const { selectedProperties, maxProperties } = get();
- if (selectedProperties.length >= maxProperties) return;
-
- // Check if property is already selected
- if (selectedProperties.some(p => p.id === property.id)) return;
-
- set({ selectedProperties: [...selectedProperties, property] });
- },
-
- removeProperty: (property: Property) => {
- const { selectedProperties } = get();
- set({
- selectedProperties: selectedProperties.filter(p => p.id !== property.id)
- });
- },
-
- clearProperties: () => {
- set({ selectedProperties: [] });
- },
-
- isPropertySelected: (propertyId: string) => {
- const { selectedProperties } = get();
- return selectedProperties.some(p => p.id === propertyId);
- },
-
- toggleProperty: (property: Property) => {
- const { isPropertySelected, addProperty, removeProperty } = get();
- if (isPropertySelected(property.id)) {
- removeProperty(property);
- } else {
- addProperty(property);
- }
- },
- }),
- {
- name: 'property-comparison-storage',
- partialize: (state) => ({ selectedProperties: state.selectedProperties }),
- }
- )
-);
\ No newline at end of file
diff --git a/src/store/domains/property/index.ts b/src/store/domains/property/index.ts
index 497330eb..c996cf32 100644
--- a/src/store/domains/property/index.ts
+++ b/src/store/domains/property/index.ts
@@ -1,8 +1,8 @@
// Property domain - combines property, portfolio, favorites, comparison stores
export { usePortfolioStore } from '../portfolioStore';
export { useFavoritesStore } from '../favoritesStore';
-export { useCompareStore } from '../compareStore';
-export { useComparisonStore } from '../comparisonStore';
-export { useComparisonHistoryStore } from '../comparisonHistoryStore';
+// Selection and history now live in one store (#1090).
+export { useCompareStore, MAX_COMPARE, MAX_HISTORY } from '../compareStore';
+export type { ComparisonHistory } from '../compareStore';
export { useRecentlyViewedStore } from '../recentlyViewedStore';
export { useCertificateStore } from '../certificateStore';