Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion playwright.config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { defineConfig, devices } from '@playwright/test';
import { walletFixture } from './tests/fixtures/wallet-msw';

/**
* @see https://playwright.dev/docs/test-configuration
Expand Down
16 changes: 15 additions & 1 deletion src/app/dashboard/saved-searches/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { logger } from '@/utils/logger';
import React, { useEffect, useState } from "react";
import Link from "next/link";
import { WalletConnector } from "@/components/WalletConnector";
import { SavedSearchCard } from "@/components/SavedSearchCard";
import { useSavedSearchStore } from "@/store/savedSearchStore";
import { useWalletStore } from "@/store/walletStore";
import { SavedSearch, NotificationFrequency } from "@/types/property";
Expand Down Expand Up @@ -278,7 +279,7 @@ function SavedSearchesContent() {
{isLoading && <CardSkeleton count={6} className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6" />}

{!isLoading && filteredAndSortedSearches.length === 0 && (
<Card>
<Card data-testid="empty-saved-searches">
<CardContent className="text-center py-20 space-y-3">
<Bookmark className="w-16 h-16 text-gray-400 mx-auto mb-4" />

Expand All @@ -302,6 +303,19 @@ function SavedSearchesContent() {
</CardContent>
</Card>
)}

{/* Saved Searches Grid */}
{!isLoading && filteredAndSortedSearches.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6" data-testid="saved-searches-grid">
{filteredAndSortedSearches.map((search) => (
<SavedSearchCard
key={search.id}
search={search}
onDelete={() => handleDeleteSearch(search.id)}
/>
))}
</div>
)}
</div>
</div>
);
Expand Down
176 changes: 176 additions & 0 deletions src/store/__tests__/walletPersistence.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import { act } from '@testing-library/react';
import {
createWalletStore,
useWalletStore,
WALLET_STORAGE_KEY,
WALLET_STORE_VERSION,
WALLET_SESSION_TTL_MS,
} from '../walletStore';

jest.mock('@/config/chains', () => ({
DEFAULT_CHAIN_ID: 1,
CHAIN_IDS: { ETHEREUM: 1, POLYGON: 137, BSC: 56, FOUNDRY: 31337 },
}));

describe('WalletPersistence and restore behavior', () => {
beforeEach(() => {
localStorage.clear();
useWalletStore.getState().reset();
});

afterEach(() => {
localStorage.clear();
});

describe('persist on connect', () => {
it('persists connected wallet state and metadata to localStorage', () => {
const store = createWalletStore(true);

act(() => {
store.getState().setConnected('0x1234567890123456789012345678901234567890', 'metamask', 137);
});

const raw = localStorage.getItem(WALLET_STORAGE_KEY);
expect(raw).not.toBeNull();

const parsed = JSON.parse(raw!);
expect(parsed.version).toBe(WALLET_STORE_VERSION);
expect(parsed.state.isConnected).toBe(true);
expect(parsed.state.address).toBe('0x1234567890123456789012345678901234567890');
expect(parsed.state.walletType).toBe('metamask');
expect(parsed.state.chainId).toBe(137);
expect(typeof parsed.state.lastUpdated).toBe('number');
});

it('persists account switch when connecting with a different address', () => {
const store = createWalletStore(true);

act(() => {
store.getState().setConnected('0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'metamask', 1);
});

let raw = localStorage.getItem(WALLET_STORAGE_KEY);
expect(JSON.parse(raw!).state.address).toBe('0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa');

act(() => {
store.getState().setConnected('0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', 'metamask', 1);
});

raw = localStorage.getItem(WALLET_STORAGE_KEY);
expect(JSON.parse(raw!).state.address).toBe('0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb');
});
});

describe('restore on init', () => {
it('restores connected state from localStorage across reload/re-init', () => {
const existingState = {
state: {
isConnected: true,
address: '0x1111111111111111111111111111111111111111',
walletType: 'walletconnect',
chainId: 56,
lastUpdated: Date.now(),
},
version: WALLET_STORE_VERSION,
};
localStorage.setItem(WALLET_STORAGE_KEY, JSON.stringify(existingState));

const newStore = createWalletStore(true);
const state = newStore.getState();

expect(state.isConnected).toBe(true);
expect(state.address).toBe('0x1111111111111111111111111111111111111111');
expect(state.walletType).toBe('walletconnect');
expect(state.chainId).toBe(56);
});

it('clears state on init if session has expired beyond TTL', () => {
const expiredTimestamp = Date.now() - (WALLET_SESSION_TTL_MS + 60000); // Expired 1 min ago
const expiredState = {
state: {
isConnected: true,
address: '0xexpired111111111111111111111111111111111',
walletType: 'metamask',
chainId: 1,
lastUpdated: expiredTimestamp,
},
version: WALLET_STORE_VERSION,
};
localStorage.setItem(WALLET_STORAGE_KEY, JSON.stringify(expiredState));

const newStore = createWalletStore(true);
const state = newStore.getState();

expect(state.isConnected).toBe(false);
expect(state.address).toBeNull();
});
});

describe('version and compatibility failure handling', () => {
it('resets state and clears storage if version mismatch is encountered', () => {
const outdatedState = {
state: {
isConnected: true,
address: '0xoldversion111111111111111111111111111111',
walletType: 'metamask',
chainId: 1,
lastUpdated: Date.now(),
},
version: 999, // Incompatible version
};
localStorage.setItem(WALLET_STORAGE_KEY, JSON.stringify(outdatedState));

const newStore = createWalletStore(true);
const state = newStore.getState();

expect(state.isConnected).toBe(false);
expect(state.address).toBeNull();
expect(localStorage.getItem(WALLET_STORAGE_KEY)).toBeNull();
});

it('handles corrupted JSON payload in localStorage gracefully', () => {
localStorage.setItem(WALLET_STORAGE_KEY, 'not-valid-json{[[[');

expect(() => {
const store = createWalletStore(true);
expect(store.getState().isConnected).toBe(false);
}).not.toThrow();
});
});

describe('disconnect and clear paths', () => {
it('clears storage item on setDisconnected', () => {
const store = createWalletStore(true);

act(() => {
store.getState().setConnected('0x1234567890123456789012345678901234567890', 'metamask');
});
expect(localStorage.getItem(WALLET_STORAGE_KEY)).not.toBeNull();

act(() => {
store.getState().setDisconnected();
});

expect(store.getState().isConnected).toBe(false);
expect(store.getState().address).toBeNull();
expect(localStorage.getItem(WALLET_STORAGE_KEY)).toBeNull();
});

it('clears storage item on reset', () => {
const store = createWalletStore(true);

act(() => {
store.getState().setConnected('0x1234567890123456789012345678901234567890', 'metamask');
});
expect(localStorage.getItem(WALLET_STORAGE_KEY)).not.toBeNull();

act(() => {
store.getState().reset();
});

expect(store.getState().isConnected).toBe(false);
expect(store.getState().address).toBeNull();
expect(localStorage.getItem(WALLET_STORAGE_KEY)).toBeNull();
});
});
});
127 changes: 112 additions & 15 deletions src/store/walletStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,15 @@
* Long-term: migrate components to use wagmi hooks directly and remove this store.
*/

import { create } from 'zustand';
import { create, type StateCreator } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { DEFAULT_CHAIN_ID } from '@/config/chains';
import type { ChainId } from '@/config/chains';

export const WALLET_STORAGE_KEY = 'propchain-wallet-state';
export const WALLET_STORE_VERSION = 1;
export const WALLET_SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours

export type WalletType = 'metamask' | 'walletconnect' | 'coinbase' | null;

export interface WalletState {
Expand Down Expand Up @@ -43,7 +48,7 @@ export interface WalletActions {

export type WalletStore = WalletState & WalletActions;

export const useWalletStore = create<WalletStore>()((set) => ({
const storeCreator: StateCreator<WalletStore> = (set) => ({
isConnected: false,
address: null,
walletType: null,
Expand All @@ -68,6 +73,13 @@ export const useWalletStore = create<WalletStore>()((set) => ({
},

setDisconnected: () => {
if (typeof window !== 'undefined' && window.localStorage) {
try {
window.localStorage.removeItem(WALLET_STORAGE_KEY);
} catch {
// Ignore storage errors in restricted contexts
}
}
set({
isConnected: false,
address: null,
Expand Down Expand Up @@ -110,16 +122,101 @@ export const useWalletStore = create<WalletStore>()((set) => ({

setLastUpdated: (timestamp: number) => set({ lastUpdated: timestamp }),

reset: () => set({
isConnected: false,
address: null,
walletType: null,
chainId: DEFAULT_CHAIN_ID,
isConnecting: false,
isSwitchingNetwork: false,
error: null,
balance: null,
isLoading: false,
lastUpdated: null,
}),
}));
reset: () => {
if (typeof window !== 'undefined' && window.localStorage) {
try {
window.localStorage.removeItem(WALLET_STORAGE_KEY);
} catch {
// Ignore storage errors in restricted contexts
}
}
set({
isConnected: false,
address: null,
walletType: null,
chainId: DEFAULT_CHAIN_ID,
isConnecting: false,
isSwitchingNetwork: false,
error: null,
balance: null,
isLoading: false,
lastUpdated: null,
});
},
});

export const createWalletStore = (shouldPersist = true) => {
if (shouldPersist && typeof window !== 'undefined' && typeof window.localStorage !== 'undefined') {
return create<WalletStore>()(
persist(storeCreator, {
name: WALLET_STORAGE_KEY,
version: WALLET_STORE_VERSION,
storage: {
getItem: (name: string) => {
try {
const str = window.localStorage.getItem(name);
return str ? JSON.parse(str) : null;
} catch {
return null;
}
},
setItem: (name: string, value: unknown) => {
try {
const val = value as { state?: { isConnected?: boolean } } | null;
if (!val?.state?.isConnected) {
window.localStorage.removeItem(name);
} else {
window.localStorage.setItem(name, JSON.stringify(value));
}
} catch {
// Ignore storage write errors
}
},
removeItem: (name: string) => {
try {
window.localStorage.removeItem(name);
} catch {
// Ignore storage removal errors
}
},
},
partialize: (state) => ({
isConnected: state.isConnected,
address: state.address,
walletType: state.walletType,
chainId: state.chainId,
lastUpdated: state.lastUpdated,
}),
migrate: (persistedState: unknown, version: number) => {
if (version !== WALLET_STORE_VERSION || !persistedState) {
if (typeof window !== 'undefined' && window.localStorage) {
try {
window.localStorage.removeItem(WALLET_STORAGE_KEY);
} catch {
// Ignore storage errors
}
}
return {
isConnected: false,
address: null,
walletType: null,
chainId: DEFAULT_CHAIN_ID,
lastUpdated: null,
};
}
return persistedState;
},
onRehydrateStorage: () => (state) => {
if (!state) return;
if (state.lastUpdated && Date.now() - state.lastUpdated > WALLET_SESSION_TTL_MS) {
state.setDisconnected();
}
},
})
);
}
return create<WalletStore>()(storeCreator);
};

export const useWalletStore = createWalletStore(true);

Loading
Loading