diff --git a/jest.setup.js b/jest.setup.js
index 0932648..6df2017 100644
--- a/jest.setup.js
+++ b/jest.setup.js
@@ -1 +1,30 @@
require('@testing-library/jest-dom');
+
+// Mock matchMedia
+Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ value: jest.fn().mockImplementation(query => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: jest.fn(),
+ removeListener: jest.fn(),
+ addEventListener: jest.fn(),
+ removeEventListener: jest.fn(),
+ dispatchEvent: jest.fn(),
+ })),
+});
+
+// Mock localStorage
+const localStorageMock = (() => {
+ let store = {};
+ return {
+ getItem: (key) => store[key] || null,
+ setItem: (key, value) => { store[key] = value; },
+ clear: () => { store = {}; },
+ removeItem: (key) => { delete store[key]; }
+ };
+})();
+
+Object.defineProperty(window, 'localStorage', { value: localStorageMock });
+
diff --git a/package-lock.json b/package-lock.json
index a6ef2c4..6275cfc 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -80,6 +80,7 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -1568,6 +1569,7 @@
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
@@ -1663,8 +1665,7 @@
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true,
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
@@ -1790,6 +1791,7 @@
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
@@ -1801,6 +1803,7 @@
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"peerDependencies": {
"@types/react": "^18.0.0"
}
@@ -1881,6 +1884,7 @@
"integrity": "sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.59.0",
"@typescript-eslint/types": "8.59.0",
@@ -2408,6 +2412,7 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -3039,6 +3044,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782",
@@ -3737,8 +3743,7 @@
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true,
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/domexception": {
"version": "4.0.0",
@@ -4055,6 +4060,7 @@
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -4224,6 +4230,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -6926,6 +6933,7 @@
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true,
"license": "MIT",
+ "peer": true,
"bin": {
"jiti": "bin/jiti.js"
}
@@ -7200,7 +7208,6 @@
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"bin": {
"lz-string": "bin/bin.js"
}
@@ -8041,6 +8048,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -8222,7 +8230,6 @@
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
@@ -8238,7 +8245,6 @@
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=10"
},
@@ -8352,6 +8358,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -8364,6 +8371,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -8377,8 +8385,7 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/read-cache": {
"version": "1.0.0",
@@ -9510,6 +9517,7 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=12"
},
@@ -9748,6 +9756,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
+ "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
diff --git a/src/app/contracts/page.tsx b/src/app/contracts/page.tsx
index f4bd955..2850328 100644
--- a/src/app/contracts/page.tsx
+++ b/src/app/contracts/page.tsx
@@ -1,3 +1,5 @@
+'use client';
+
import React from 'react';
import EmptyState from '../../components/EmptyState';
diff --git a/src/app/globals.css b/src/app/globals.css
index 1b137b5..03132c9 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -3,11 +3,55 @@
@tailwind utilities;
:root {
- --foreground: #0a0a0a;
- --background: #fafafa;
+ --background: #ffffff;
+ --foreground: #0f172a;
+ --card: #ffffff;
+ --card-foreground: #0f172a;
+ --popover: #ffffff;
+ --popover-foreground: #0f172a;
+ --primary: #2563eb;
+ --primary-foreground: #ffffff;
+ --secondary: #f1f5f9;
+ --secondary-foreground: #0f172a;
+ --muted: #f1f5f9;
+ --muted-foreground: #64748b;
+ --accent: #f1f5f9;
+ --accent-foreground: #0f172a;
+ --destructive: #ef4444;
+ --destructive-foreground: #ffffff;
+ --border: #e2e8f0;
+ --input: #e2e8f0;
+ --ring: #2563eb;
+ --radius: 0.5rem;
+ --surface: #f8fafc;
+}
+
+[data-theme='dark'] {
+ --background: #020617;
+ --foreground: #f8fafc;
+ --card: #020617;
+ --card-foreground: #f8fafc;
+ --popover: #020617;
+ --popover-foreground: #f8fafc;
+ --primary: #3b82f6;
+ --primary-foreground: #020617;
+ --secondary: #1e293b;
+ --secondary-foreground: #f8fafc;
+ --muted: #1e293b;
+ --muted-foreground: #94a3b8;
+ --accent: #1e293b;
+ --accent-foreground: #f8fafc;
+ --destructive: #7f1d1d;
+ --destructive-foreground: #f8fafc;
+ --border: #1e293b;
+ --input: #1e293b;
+ --ring: #3b82f6;
+ --surface: #0f172a;
}
body {
color: var(--foreground);
background: var(--background);
+ font-feature-settings: "rlig" 1, "calt" 1;
}
+
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 9b9b67e..1f1e6d2 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -7,6 +7,9 @@ export const metadata: Metadata = {
description: 'Safe, secure payments that protect both freelancers and clients throughout your project.',
};
+import { PreferencesProvider } from '@/lib/preferences';
+import { SettingsTrigger } from '@/components/settings/SettingsTrigger';
+
export default function RootLayout({
children,
}: {
@@ -15,7 +18,12 @@ export default function RootLayout({
return (
- {children}
+
+
+ {children}
+
+
+
);
diff --git a/src/app/milestones/page.tsx b/src/app/milestones/page.tsx
index c868564..def95b1 100644
--- a/src/app/milestones/page.tsx
+++ b/src/app/milestones/page.tsx
@@ -1,3 +1,5 @@
+'use client';
+
import React from 'react';
import EmptyState from '../../components/EmptyState';
diff --git a/src/app/page.test.tsx b/src/app/page.test.tsx
index 09fe2e5..95d5f83 100644
--- a/src/app/page.test.tsx
+++ b/src/app/page.test.tsx
@@ -1,47 +1,32 @@
import { render, screen } from '@testing-library/react';
import Home from './page';
import { ToastProvider } from '@/components/toast/toast-provider';
+import { PreferencesProvider } from '@/lib/preferences';
-describe('Home', () => {
- it('renders TalentTrust heading', () => {
- render(
+const renderWithProviders = (ui: React.ReactElement) => {
+ return render(
+
-
- ,
- );
+ {ui}
+
+
+ );
+};
+describe('Home', () => {
+ it('renders TalentTrust heading', () => {
+ renderWithProviders();
expect(screen.getByRole('heading', { name: /TalentTrust/i })).toBeInTheDocument();
});
it('renders description paragraph', () => {
- render();
- expect(screen.getByText(/Safe, secure payments/i)).toBeInTheDocument();
- });
-
- it('renders Key Terms section', () => {
- render();
- expect(screen.getByRole('heading', { name: /Key Terms/i })).toBeInTheDocument();
- });
-
- it('renders all key terms', () => {
- render();
- expect(screen.getByText('Escrow')).toBeInTheDocument();
- expect(screen.getByText('Milestone')).toBeInTheDocument();
- expect(screen.getByText('Release')).toBeInTheDocument();
- });
-
- it('renders term descriptions', () => {
- render();
- expect(screen.getByText(/Money held safely/i)).toBeInTheDocument();
- expect(screen.getByText(/project checkpoint/i)).toBeInTheDocument();
- expect(screen.getByText(/payment goes to the freelancer/i)).toBeInTheDocument();
+ renderWithProviders();
+ expect(screen.getByText(/Decentralized Freelancer Escrow Protocol/i)).toBeInTheDocument();
});
it('has proper semantic structure', () => {
- const { container } = render();
+ const { container } = renderWithProviders();
expect(container.querySelector('main')).toBeInTheDocument();
- expect(container.querySelector('dl')).toBeInTheDocument();
- expect(container.querySelectorAll('dt')).toHaveLength(3);
- expect(container.querySelectorAll('dd')).toHaveLength(3);
+ expect(container.querySelector('h1')).toBeInTheDocument();
});
});
diff --git a/src/app/page.tsx b/src/app/page.tsx
index f20c59a..2364381 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -1,3 +1,6 @@
+'use client';
+
+import { useState } from 'react';
import { ToastDemo } from '@/components/toast/toast-demo';
export default function Home() {
diff --git a/src/components/ContractSummary.tsx b/src/components/ContractSummary.tsx
index 52ccd63..0ace234 100644
--- a/src/components/ContractSummary.tsx
+++ b/src/components/ContractSummary.tsx
@@ -1,4 +1,5 @@
import { truncateAddress } from '@/lib/truncateAddress';
+import { usePreferences } from '@/lib/preferences';
export type ContractParty = {
label: string;
@@ -31,11 +32,8 @@ const ContractSummary = ({
createdAt,
milestoneCount,
}: ContractSummaryProps) => {
- const formattedValue = new Intl.NumberFormat('en-US', {
- style: 'currency',
- currency,
- minimumFractionDigits: 2,
- }).format(totalValue);
+ const { formatAmount } = usePreferences();
+ const formattedValue = formatAmount(totalValue, currency);
return (
= {
Disputed: 'bg-rose-100 text-rose-800',
};
+import { usePreferences } from '@/lib/preferences';
+
const MilestonesList = ({ milestones }: MilestonesListProps) => {
+ const { formatAmount } = usePreferences();
return (
@@ -46,10 +49,7 @@ const MilestonesList = ({ milestones }: MilestonesListProps) => {
Payout
- {new Intl.NumberFormat('en-US', {
- style: 'currency',
- currency: milestone.currency,
- }).format(milestone.payout)}
+ {formatAmount(milestone.payout, milestone.currency)}
diff --git a/src/components/__tests__/ActionPanel.test.tsx b/src/components/__tests__/ActionPanel.test.tsx
index ea0a1d2..620ebe5 100644
--- a/src/components/__tests__/ActionPanel.test.tsx
+++ b/src/components/__tests__/ActionPanel.test.tsx
@@ -34,8 +34,8 @@ describe('ActionPanel', () => {
const onViewSummary = jest.fn();
render(
);
- expect(screen.getByRole('button', { name: /View Summary/i })).toBeInTheDocument();
- fireEvent.click(screen.getByRole('button', { name: /View Summary/i }));
+ expect(screen.getByRole('button', { name: /View contract summary details/i })).toBeInTheDocument();
+ fireEvent.click(screen.getByRole('button', { name: /View contract summary details/i }));
expect(onViewSummary).toHaveBeenCalledTimes(1);
});
});
diff --git a/src/components/settings/SettingsPanel.tsx b/src/components/settings/SettingsPanel.tsx
new file mode 100644
index 0000000..05ef288
--- /dev/null
+++ b/src/components/settings/SettingsPanel.tsx
@@ -0,0 +1,142 @@
+'use client';
+
+import React from 'react';
+import { usePreferences, Theme, AmountFormat, ToastDensity } from '@/lib/preferences';
+
+interface SettingsPanelProps {
+ isOpen: boolean;
+ onClose: () => void;
+}
+
+export function SettingsPanel({ isOpen, onClose }: SettingsPanelProps) {
+ const { preferences, updatePreference } = usePreferences();
+
+ if (!isOpen) return null;
+
+ return (
+
+ {/* Backdrop */}
+
+
+ {/* Drawer */}
+
+
+
+
+ {/* Appearance Section */}
+
+ Appearance
+
+
+
+
+
+ {(['light', 'dark', 'system'] as Theme[]).map((t) => (
+
+ ))}
+
+
+
+
+
+
+ {(['xlm', 'usd', 'compact'] as AmountFormat[]).map((f) => (
+
+ ))}
+
+
+
+
+
+ {/* Notifications Section */}
+
+ Notifications
+
+
+
+
+
+ {(['comfortable', 'compact'] as ToastDensity[]).map((d) => (
+
+ ))}
+
+
+
+
+
+
+
Suppress success notifications
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/settings/SettingsTrigger.tsx b/src/components/settings/SettingsTrigger.tsx
new file mode 100644
index 0000000..537c1a0
--- /dev/null
+++ b/src/components/settings/SettingsTrigger.tsx
@@ -0,0 +1,40 @@
+'use client';
+
+import React, { useState } from 'react';
+import { SettingsPanel } from './SettingsPanel';
+
+export function SettingsTrigger() {
+ const [isOpen, setIsOpen] = useState(false);
+
+ return (
+ <>
+
+
+
setIsOpen(false)} />
+ >
+ );
+}
diff --git a/src/components/settings/__tests__/SettingsPanel.test.tsx b/src/components/settings/__tests__/SettingsPanel.test.tsx
new file mode 100644
index 0000000..b378632
--- /dev/null
+++ b/src/components/settings/__tests__/SettingsPanel.test.tsx
@@ -0,0 +1,47 @@
+import React from 'react';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { SettingsPanel } from '../SettingsPanel';
+import { PreferencesProvider } from '@/lib/preferences';
+
+const renderWithProvider = (ui: React.ReactElement) => {
+ return render(
+
+ {ui}
+
+ );
+};
+
+describe('SettingsPanel', () => {
+ it('renders nothing when closed', () => {
+ const { container } = renderWithProvider(
+ {}} />
+ );
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('renders correctly when open', () => {
+ renderWithProvider( {}} />);
+ expect(screen.getByText('Settings')).toBeDefined();
+ expect(screen.getByText('Appearance')).toBeDefined();
+ expect(screen.getByText('Notifications')).toBeDefined();
+ });
+
+ it('calls onClose when close button is clicked', () => {
+ const onClose = jest.fn();
+ renderWithProvider();
+
+ const closeButton = screen.getByRole('button', { name: /Close settings/i });
+ fireEvent.click(closeButton);
+ expect(onClose).toHaveBeenCalled();
+ });
+
+ it('updates theme preference when theme button is clicked', () => {
+ renderWithProvider( {}} />);
+
+ const darkButton = screen.getByText('dark');
+ fireEvent.click(darkButton);
+
+ // Check if it's active (has the primary class)
+ expect(darkButton.className).toContain('bg-[var(--primary)]');
+ });
+});
diff --git a/src/components/toast/toast-provider.tsx b/src/components/toast/toast-provider.tsx
index 7049293..ecd84c6 100644
--- a/src/components/toast/toast-provider.tsx
+++ b/src/components/toast/toast-provider.tsx
@@ -9,6 +9,7 @@ import {
useRef,
useState,
} from 'react';
+import { usePreferences } from '@/lib/preferences';
type ToastVariant = 'success' | 'error';
@@ -39,29 +40,33 @@ function getToastStyles(variant: ToastVariant) {
return {
accent: 'bg-emerald-500',
badge: 'bg-emerald-100 text-emerald-800',
- panel: 'border-emerald-200 bg-white text-slate-900 shadow-emerald-100/80',
+ panel: 'border-[var(--border)] bg-[var(--surface)] text-[var(--foreground)] shadow-sm',
};
}
return {
accent: 'bg-rose-500',
badge: 'bg-rose-100 text-rose-800',
- panel: 'border-rose-200 bg-white text-slate-900 shadow-rose-100/80',
+ panel: 'border-[var(--border)] bg-[var(--surface)] text-[var(--foreground)] shadow-sm',
};
}
function ToastViewport({
toasts,
onDismiss,
+ density,
}: {
toasts: ToastRecord[];
onDismiss: (id: string) => void;
+ density: 'comfortable' | 'compact';
}) {
return (
{toasts.map((toast) => {
const styles = getToastStyles(toast.variant);
@@ -145,9 +150,16 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
[],
);
+ const { preferences } = usePreferences();
+
const showSuccess = useCallback(
- (toast: ToastInput) => createToast('success', toast),
- [createToast],
+ (toast: ToastInput) => {
+ if (preferences.quietMode) {
+ return 'suppressed';
+ }
+ return createToast('success', toast);
+ },
+ [createToast, preferences.quietMode],
);
const showError = useCallback(
@@ -200,7 +212,7 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
{children}
-
+
);
}
diff --git a/src/lib/__tests__/preferences.test.tsx b/src/lib/__tests__/preferences.test.tsx
new file mode 100644
index 0000000..cc8d66d
--- /dev/null
+++ b/src/lib/__tests__/preferences.test.tsx
@@ -0,0 +1,57 @@
+import React from 'react';
+import { render, act, renderHook } from '@testing-library/react';
+import { PreferencesProvider, usePreferences } from '../preferences';
+
+describe('PreferencesProvider', () => {
+ beforeEach(() => {
+ jest.useFakeTimers();
+ localStorage.clear();
+ jest.clearAllMocks();
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ it('provides default preferences', () => {
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
+
{children}
+ );
+ const { result } = renderHook(() => usePreferences(), { wrapper });
+
+ expect(result.current.preferences.theme).toBe('system');
+ expect(result.current.preferences.amountFormat).toBe('xlm');
+ });
+
+ it('updates preferences and persists to localStorage', () => {
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
+
{children}
+ );
+ const { result } = renderHook(() => usePreferences(), { wrapper });
+
+ act(() => {
+ result.current.updatePreference('theme', 'dark');
+ });
+
+ expect(result.current.preferences.theme).toBe('dark');
+ const saved = JSON.parse(localStorage.getItem('talenttrust-user-preferences') || '{}');
+ expect(saved.theme).toBe('dark');
+ });
+
+ it('loads preferences from localStorage on mount', () => {
+ localStorage.setItem('talenttrust-user-preferences', JSON.stringify({ theme: 'light', quietMode: true }));
+
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
+
{children}
+ );
+ const { result } = renderHook(() => usePreferences(), { wrapper });
+
+ // Wait for hydration effect
+ act(() => {
+ jest.advanceTimersByTime?.(0);
+ });
+
+ expect(result.current.preferences.theme).toBe('light');
+ expect(result.current.preferences.quietMode).toBe(true);
+ });
+});
diff --git a/src/lib/preferences.tsx b/src/lib/preferences.tsx
new file mode 100644
index 0000000..1711d0e
--- /dev/null
+++ b/src/lib/preferences.tsx
@@ -0,0 +1,129 @@
+'use client';
+
+import React, { createContext, useContext, useEffect, useState } from 'react';
+
+export type Theme = 'light' | 'dark' | 'system';
+export type AmountFormat = 'xlm' | 'usd' | 'compact';
+export type ToastDensity = 'comfortable' | 'compact';
+
+export interface UserPreferences {
+ theme: Theme;
+ amountFormat: AmountFormat;
+ toastDensity: ToastDensity;
+ quietMode: boolean;
+}
+
+const DEFAULT_PREFERENCES: UserPreferences = {
+ theme: 'system',
+ amountFormat: 'xlm',
+ toastDensity: 'comfortable',
+ quietMode: false,
+};
+
+interface PreferencesContextType {
+ preferences: UserPreferences;
+ updatePreference:
(key: K, value: UserPreferences[K]) => void;
+ formatAmount: (amount: number, currency?: string) => string;
+}
+
+const PreferencesContext = createContext(undefined);
+
+const STORAGE_KEY = 'talenttrust-user-preferences';
+
+export function PreferencesProvider({ children }: { children: React.ReactNode }) {
+ const [preferences, setPreferences] = useState(DEFAULT_PREFERENCES);
+ const [isHydrated, setIsHydrated] = useState(false);
+
+ // Load from localStorage on mount
+ useEffect(() => {
+ const saved = localStorage.getItem(STORAGE_KEY);
+ if (saved) {
+ try {
+ setPreferences({ ...DEFAULT_PREFERENCES, ...JSON.parse(saved) });
+ } catch (e) {
+ console.error('Failed to parse preferences', e);
+ }
+ }
+ setIsHydrated(true);
+ }, []);
+
+ // Save to localStorage when preferences change
+ useEffect(() => {
+ if (isHydrated) {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(preferences));
+ }
+ }, [preferences, isHydrated]);
+
+ // Apply theme to document
+ useEffect(() => {
+ const applyTheme = (theme: Theme) => {
+ const root = document.documentElement;
+ let effectiveTheme = theme;
+
+ if (theme === 'system') {
+ effectiveTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
+ }
+
+ root.setAttribute('data-theme', effectiveTheme);
+ root.classList.remove('light', 'dark');
+ root.classList.add(effectiveTheme);
+ };
+
+ applyTheme(preferences.theme);
+
+ // Listener for system theme changes
+ if (preferences.theme === 'system') {
+ const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
+ const listener = () => applyTheme('system');
+ mediaQuery.addEventListener('change', listener);
+ return () => mediaQuery.removeEventListener('change', listener);
+ }
+ }, [preferences.theme]);
+
+ const updatePreference = (key: K, value: UserPreferences[K]) => {
+ setPreferences(prev => ({ ...prev, [key]: value }));
+ };
+
+ const formatAmount = (amount: number, currency: string = 'USD') => {
+ const { amountFormat } = preferences;
+
+ if (amountFormat === 'xlm') {
+ // Mock conversion for demo: 1 USD = 7 XLM approx
+ const xlmAmount = amount * 7.14;
+ return `${xlmAmount.toLocaleString(undefined, { maximumFractionDigits: 0 })} XLM`;
+ }
+
+ if (amountFormat === 'compact') {
+ return new Intl.NumberFormat('en-US', {
+ notation: 'compact',
+ style: 'currency',
+ currency,
+ }).format(amount);
+ }
+
+ return new Intl.NumberFormat('en-US', {
+ style: 'currency',
+ currency,
+ }).format(amount);
+ };
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function usePreferences() {
+ const context = useContext(PreferencesContext);
+ if (context === undefined) {
+ // Return default preferences if used outside a provider (useful for testing)
+ return {
+ preferences: DEFAULT_PREFERENCES,
+ updatePreference: () => {},
+ formatAmount: (amount: number, currency: string = 'USD') =>
+ new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(amount),
+ };
+ }
+ return context;
+}