Skip to content

Commit 2bed118

Browse files
committed
feature/Frontend: Add persistent user preferences via localStorage
1 parent 6952506 commit 2bed118

9 files changed

Lines changed: 200 additions & 113 deletions

File tree

frontend/src/App.tsx

Lines changed: 64 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { queryClient } from "./lib/queryClient";
1515
import { clearWalletSessionState } from "./lib/sessionCleanup";
1616
import ErrorFallback from "./components/ErrorFallback";
1717
import RouteLoadingFallback from "./components/RouteLoadingFallback";
18+
import { PreferencesProvider } from "./context/PreferencesContext";
1819

1920
const SentryRoutes = Sentry.withSentryReactRouterV6Routing(Routes);
2021

@@ -61,68 +62,70 @@ function AppContent() {
6162
}, [dismissSessionWarning]);
6263

6364
return (
64-
<KeyboardShortcutProvider>
65-
<a className="skip-link" href="#main-content">
66-
Skip to main content
67-
</a>
68-
<div className="app-container">
69-
<Navbar
70-
walletAddress={walletAddress}
71-
usdcBalance={usdcBalance}
72-
onConnect={handleConnect}
73-
onDisconnect={handleDisconnect}
74-
/>
75-
<main id="main-content" className="container app-main" style={{ marginTop: "100px", paddingBottom: "60px" }}>
76-
<Suspense fallback={<RouteLoadingFallback />}>
77-
<SentryRoutes>
78-
<Route
79-
path="/"
80-
element={
81-
<Home
82-
walletAddress={walletAddress}
83-
usdcBalance={usdcBalance}
84-
/>
85-
}
86-
/>
87-
<Route
88-
path="/portfolio"
89-
element={
90-
<Portfolio
91-
walletAddress={walletAddress}
92-
/>
93-
}
94-
/>
95-
<Route
96-
path="/analytics"
97-
element={
98-
<FeatureGate flag="ANALYTICS_PAGE">
99-
<Analytics />
100-
</FeatureGate>
101-
}
102-
/>
103-
<Route path="/transactions" element={<TransactionHistory walletAddress={walletAddress} />} />
104-
<Route path="/settings" element={<Settings />} />
105-
<Route path="/ui-kit" element={<UIPreview />} />
106-
<Route path="*" element={<Navigate to="/" replace />} />
107-
</SentryRoutes>
108-
</Suspense>
109-
</main>
110-
<ShortcutHelpModal />
111-
{sessionState === "warning" && walletAddress && (
112-
<SessionExpiryWarning
113-
onReconnect={handleReconnect}
114-
onDismiss={handleDismissWarning}
115-
/>
116-
)}
117-
{sessionState === "expired" && (
118-
<SessionExpiredModal
119-
intendedPath={intendedPath}
120-
onReconnect={handleReconnect}
121-
onDismiss={() => handleDisconnect("manual")}
65+
<PreferencesProvider walletAddress={walletAddress}>
66+
<KeyboardShortcutProvider>
67+
<a className="skip-link" href="#main-content">
68+
Skip to main content
69+
</a>
70+
<div className="app-container">
71+
<Navbar
72+
walletAddress={walletAddress}
73+
usdcBalance={usdcBalance}
74+
onConnect={handleConnect}
75+
onDisconnect={handleDisconnect}
12276
/>
123-
)}
124-
</div>
125-
</KeyboardShortcutProvider>
77+
<main id="main-content" className="container app-main" style={{ marginTop: "100px", paddingBottom: "60px" }}>
78+
<Suspense fallback={<RouteLoadingFallback />}>
79+
<SentryRoutes>
80+
<Route
81+
path="/"
82+
element={
83+
<Home
84+
walletAddress={walletAddress}
85+
usdcBalance={usdcBalance}
86+
/>
87+
}
88+
/>
89+
<Route
90+
path="/portfolio"
91+
element={
92+
<Portfolio
93+
walletAddress={walletAddress}
94+
/>
95+
}
96+
/>
97+
<Route
98+
path="/analytics"
99+
element={
100+
<FeatureGate flag="ANALYTICS_PAGE">
101+
<Analytics />
102+
</FeatureGate>
103+
}
104+
/>
105+
<Route path="/transactions" element={<TransactionHistory walletAddress={walletAddress} />} />
106+
<Route path="/settings" element={<Settings />} />
107+
<Route path="/ui-kit" element={<UIPreview />} />
108+
<Route path="*" element={<Navigate to="/" replace />} />
109+
</SentryRoutes>
110+
</Suspense>
111+
</main>
112+
<ShortcutHelpModal />
113+
{sessionState === "warning" && walletAddress && (
114+
<SessionExpiryWarning
115+
onReconnect={handleReconnect}
116+
onDismiss={handleDismissWarning}
117+
/>
118+
)}
119+
{sessionState === "expired" && (
120+
<SessionExpiredModal
121+
intendedPath={intendedPath}
122+
onReconnect={handleReconnect}
123+
onDismiss={() => handleDisconnect("manual")}
124+
/>
125+
)}
126+
</div>
127+
</KeyboardShortcutProvider>
128+
</PreferencesProvider>
126129
);
127130
}
128131

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,35 @@
11
import { render, screen, fireEvent } from '@testing-library/react';
22
import { describe, it, expect } from 'vitest';
33
import ThemeToggle from './ThemeToggle';
4-
import { ThemeProvider } from '../context/ThemeContext';
4+
import { PreferencesProvider } from "../context/PreferencesContext";
55

66

77
describe('ThemeToggle', () => {
88
it('renders the theme toggle button', () => {
99
render(
10-
<ThemeProvider>
10+
<PreferencesProvider>
1111
<ThemeToggle />
12-
</ThemeProvider>
12+
</PreferencesProvider>
1313
);
1414
const button = screen.getByRole('button');
1515
expect(button).toBeInTheDocument();
1616
});
1717

1818
it('changes the theme when clicked', () => {
1919
render(
20-
<ThemeProvider>
20+
<PreferencesProvider>
2121
<ThemeToggle />
22-
</ThemeProvider>
22+
</PreferencesProvider>
2323
);
2424
const button = screen.getByRole('button');
25-
26-
// Initial state (likely 'light' due to matchMedia mock)
27-
expect(button).toHaveAttribute('aria-label', 'Toggle to dark mode');
2825

29-
// Click to toggle to dark
26+
const initialAriaLabel = button.getAttribute("aria-label");
27+
expect(initialAriaLabel).toMatch(/Toggle to (dark|light) mode/);
28+
3029
fireEvent.click(button);
31-
expect(button).toHaveAttribute('aria-label', 'Toggle to light mode');
30+
expect(button.getAttribute("aria-label")).not.toBe(initialAriaLabel);
3231

33-
// Click again to toggle back to light
3432
fireEvent.click(button);
35-
expect(button).toHaveAttribute('aria-label', 'Toggle to dark mode');
33+
expect(button.getAttribute("aria-label")).toBe(initialAriaLabel);
3634
});
3735
});

frontend/src/components/ThemeToggle.tsx

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
import React from 'react';
22
import { Sun, Moon } from './icons';
3-
import { useTheme } from '../context/ThemeContext';
3+
import { usePreferencesContext } from "../context/PreferencesContext";
44
import { useTranslation } from '../i18n';
55

66
const ThemeToggle: React.FC = () => {
7-
const { theme, toggleTheme } = useTheme();
7+
const { resolvedTheme, setTheme } = usePreferencesContext();
88
const { t } = useTranslation();
99

10+
const isLight = resolvedTheme === "light";
11+
const toggleTheme = () => {
12+
setTheme(isLight ? "dark" : "light");
13+
};
14+
1015
return (
1116
<button
1217
onClick={toggleTheme}
@@ -26,7 +31,7 @@ const ThemeToggle: React.FC = () => {
2631
boxShadow: 'var(--shadow-glass)'
2732
}}
2833
aria-label={
29-
theme === "light"
34+
isLight
3035
? t("theme.toggleToDark")
3136
: t("theme.toggleToLight")
3237
}
@@ -36,8 +41,8 @@ const ThemeToggle: React.FC = () => {
3641
position: 'absolute',
3742
top: 0,
3843
left: 0,
39-
transform: theme === 'light' ? 'rotate(0deg) scale(1)' : 'rotate(90deg) scale(0)',
40-
opacity: theme === 'light' ? 1 : 0,
44+
transform: isLight ? 'rotate(0deg) scale(1)' : 'rotate(90deg) scale(0)',
45+
opacity: isLight ? 1 : 0,
4146
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)'
4247
}}>
4348
<Sun size={20} />
@@ -46,8 +51,8 @@ const ThemeToggle: React.FC = () => {
4651
position: 'absolute',
4752
top: 0,
4853
left: 0,
49-
transform: theme === 'dark' ? 'rotate(0deg) scale(1)' : 'rotate(-90deg) scale(0)',
50-
opacity: theme === 'dark' ? 1 : 0,
54+
transform: !isLight ? 'rotate(0deg) scale(1)' : 'rotate(-90deg) scale(0)',
55+
opacity: !isLight ? 1 : 0,
5156
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)'
5257
}}>
5358
<Moon size={20} />

frontend/src/context/PreferencesContext.tsx

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { createContext, useContext, useEffect, useMemo } from 'react';
1+
import React, { createContext, useContext, useEffect, useMemo, useLayoutEffect } from "react";
22
import {
33
usePreferences,
44
type UserPreferences,
@@ -23,7 +23,15 @@ interface PreferencesContextType {
2323

2424
const PreferencesContext = createContext<PreferencesContextType | undefined>(undefined);
2525

26-
export const PreferencesProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
26+
interface PreferencesProviderProps {
27+
children: React.ReactNode;
28+
walletAddress?: string | null;
29+
}
30+
31+
export const PreferencesProvider: React.FC<PreferencesProviderProps> = ({
32+
children,
33+
walletAddress,
34+
}) => {
2735
const {
2836
preferences,
2937
setTheme,
@@ -33,7 +41,7 @@ export const PreferencesProvider: React.FC<{ children: React.ReactNode }> = ({ c
3341
toggleCompactMode,
3442
toggleShowBalances,
3543
resetToDefaults,
36-
} = usePreferences();
44+
} = usePreferences(walletAddress);
3745

3846
// Resolve 'system' to an actual light/dark value
3947
const resolvedTheme = useMemo((): 'light' | 'dark' => {
@@ -44,7 +52,7 @@ export const PreferencesProvider: React.FC<{ children: React.ReactNode }> = ({ c
4452
}, [preferences.theme]);
4553

4654
// Apply resolved theme to document root
47-
useEffect(() => {
55+
useLayoutEffect(() => {
4856
document.documentElement.setAttribute('data-theme', resolvedTheme);
4957
// Persist the raw preference so ThemeContext (ThemeToggle) stays in sync
5058
localStorage.setItem('theme', resolvedTheme);

frontend/src/hooks/usePreferences.ts

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import { useState, useCallback } from 'react';
1+
import { useState, useCallback, useEffect } from 'react';
22

33
export type Theme = 'light' | 'dark' | 'system';
44
export type Locale = 'en-US' | 'en-GB' | 'de-DE' | 'fr-FR' | 'ja-JP' | 'zh-CN';
5-
export type Currency = 'USD' | 'EUR' | 'GBP' | 'JPY';
5+
export type Currency = 'USD' | 'XLM';
66

77
export interface NotificationPreferences {
88
depositAlerts: boolean;
@@ -22,7 +22,7 @@ export interface UserPreferences {
2222
showBalances: boolean;
2323
}
2424

25-
const STORAGE_KEY = 'yieldvault-preferences';
25+
const STORAGE_KEY_PREFIX = "yieldvault-preferences";
2626

2727
const DEFAULT_PREFERENCES: UserPreferences = {
2828
theme: 'dark',
@@ -40,9 +40,14 @@ const DEFAULT_PREFERENCES: UserPreferences = {
4040
showBalances: true,
4141
};
4242

43-
function loadPreferences(): UserPreferences {
43+
function getStorageKey(walletAddress?: string | null): string {
44+
const walletScope = walletAddress?.trim() ? walletAddress.trim() : "guest";
45+
return `${STORAGE_KEY_PREFIX}:${walletScope}`;
46+
}
47+
48+
function loadPreferences(walletAddress?: string | null): UserPreferences {
4449
try {
45-
const raw = localStorage.getItem(STORAGE_KEY);
50+
const raw = localStorage.getItem(getStorageKey(walletAddress));
4651
if (!raw) return DEFAULT_PREFERENCES;
4752
const parsed = JSON.parse(raw) as Partial<UserPreferences>;
4853
return {
@@ -58,27 +63,33 @@ function loadPreferences(): UserPreferences {
5863
}
5964
}
6065

61-
function savePreferences(prefs: UserPreferences): void {
66+
function savePreferences(prefs: UserPreferences, walletAddress?: string | null): void {
6267
try {
63-
localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs));
68+
localStorage.setItem(getStorageKey(walletAddress), JSON.stringify(prefs));
6469
} catch {
6570
// storage quota exceeded or unavailable — silently ignore
6671
}
6772
}
6873

69-
export function usePreferences() {
70-
const [preferences, setPreferencesState] = useState<UserPreferences>(loadPreferences);
74+
export function usePreferences(walletAddress?: string | null) {
75+
const [preferences, setPreferencesState] = useState<UserPreferences>(() =>
76+
loadPreferences(walletAddress),
77+
);
78+
79+
useEffect(() => {
80+
setPreferencesState(loadPreferences(walletAddress));
81+
}, [walletAddress]);
7182

7283
const setPreferences = useCallback((updater: Partial<UserPreferences> | ((prev: UserPreferences) => UserPreferences)) => {
7384
setPreferencesState(prev => {
7485
const next =
7586
typeof updater === 'function'
7687
? updater(prev)
7788
: { ...prev, ...updater };
78-
savePreferences(next);
89+
savePreferences(next, walletAddress);
7990
return next;
8091
});
81-
}, []);
92+
}, [walletAddress]);
8293

8394
const setTheme = useCallback((theme: Theme) => {
8495
setPreferences({ theme });
@@ -108,9 +119,9 @@ export function usePreferences() {
108119
}, [setPreferences]);
109120

110121
const resetToDefaults = useCallback(() => {
111-
savePreferences(DEFAULT_PREFERENCES);
122+
savePreferences(DEFAULT_PREFERENCES, walletAddress);
112123
setPreferencesState(DEFAULT_PREFERENCES);
113-
}, []);
124+
}, [walletAddress]);
114125

115126
return {
116127
preferences,

0 commit comments

Comments
 (0)