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 */} +
+
+

Settings

+ +
+ +
+ {/* 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; +}