From eec8a2ae484d5d0c31b2da4ad8ef5477b806b960 Mon Sep 17 00:00:00 2001
From: Opulence Chuks
Date: Sat, 27 Jun 2026 12:44:27 +0100
Subject: [PATCH 1/3] feat: unsaved-changes guard and dirty indicator for
settings
---
src/app/settings/__tests__/page.test.tsx | 32 +++++++++
src/app/settings/page.tsx | 6 +-
.../__tests__/useUnsavedChangesGuard.test.ts | 24 +++++++
src/hooks/useUnsavedChangesGuard.ts | 66 +++++++++++++++++++
4 files changed, 127 insertions(+), 1 deletion(-)
create mode 100644 src/app/settings/__tests__/page.test.tsx
create mode 100644 src/hooks/__tests__/useUnsavedChangesGuard.test.ts
create mode 100644 src/hooks/useUnsavedChangesGuard.ts
diff --git a/src/app/settings/__tests__/page.test.tsx b/src/app/settings/__tests__/page.test.tsx
new file mode 100644
index 00000000..8e1430fc
--- /dev/null
+++ b/src/app/settings/__tests__/page.test.tsx
@@ -0,0 +1,32 @@
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import SettingsPage from '@/app/settings/page';
+import '@testing-library/jest-dom';
+
+// Mock the useUnsavedChangesGuard hook to control isDirty state.
+jest.mock('@/hooks/useUnsavedChangesGuard', () => ({
+ useUnsavedChangesGuard: jest.fn(() => ({ isDirty: false, resetBaseline: jest.fn() })),
+}));
+
+describe('SettingsPage unsaved changes UI', () => {
+ test('shows unsaved changes badge when dirty', async () => {
+ // Re-mock to return dirty after toggling.
+ const mockReset = jest.fn();
+ const useUnsavedChangesGuard = require('@/hooks/useUnsavedChangesGuard').useUnsavedChangesGuard;
+ useUnsavedChangesGuard.mockImplementation(() => ({ isDirty: true, resetBaseline: mockReset }));
+
+ render();
+ // The badge should be visible.
+ expect(screen.getByText('Unsaved changes')).toBeInTheDocument();
+ // Save button should be enabled.
+ const saveBtn = screen.getByRole('button', { name: /Save Preferences/i });
+ expect(saveBtn).toBeEnabled();
+ });
+
+ test('disables Save button when no changes', () => {
+ const useUnsavedChangesGuard = require('@/hooks/useUnsavedChangesGuard').useUnsavedChangesGuard;
+ useUnsavedChangesGuard.mockImplementation(() => ({ isDirty: false, resetBaseline: jest.fn() }));
+ render();
+ const saveBtn = screen.getByRole('button', { name: /Save Preferences/i });
+ expect(saveBtn).toBeDisabled();
+ });
+});
diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx
index 4c313398..55b5cdf2 100644
--- a/src/app/settings/page.tsx
+++ b/src/app/settings/page.tsx
@@ -3,6 +3,7 @@
import React, { useState } from 'react'
import { NotificationSection } from '@/components/settings/NotificationSection'
import { NotificationToggle } from '@/components/settings/NotificationToggle'
+import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
import { AppShellLayout } from '@/components/shell/AppShellLayout'
import { AccountWalletSection } from '@/components/settings/AccountWalletSection'
import {
@@ -28,6 +29,7 @@ export default function SettingsPage() {
})
const [isSaving, setIsSaving] = useState(false)
+ const { isDirty, resetBaseline } = useUnsavedChangesGuard(preferences);
const [showSuccess, setShowSuccess] = useState(false)
const handleToggle = (key: keyof typeof preferences) => {
@@ -40,6 +42,7 @@ export default function SettingsPage() {
setTimeout(() => {
setIsSaving(false)
setShowSuccess(true)
+ resetBaseline();
setTimeout(() => setShowSuccess(false), 3000)
}, 1000)
}
@@ -69,6 +72,7 @@ export default function SettingsPage() {
Settings
+ {isDirty && Unsaved changes}
Manage your account, wallet, and notification preferences.
@@ -199,7 +203,7 @@ export default function SettingsPage() {
-
- {commitmentTypes.map((type) => {
- const Icon = type.icon;
- const isSelected = selectedType === type.id;
-
- return (
-
onSelectType(type.id)}
- role="radio"
- aria-checked={isSelected}
- tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
diff --git a/src/components/create/RiskProfileComparison.module.css b/src/components/create/RiskProfileComparison.module.css
new file mode 100644
index 00000000..ec4fa88b
--- /dev/null
+++ b/src/components/create/RiskProfileComparison.module.css
@@ -0,0 +1,26 @@
+.container {
+ display: flex;
+ gap: 1rem;
+ justify-content: center;
+ flex-wrap: wrap;
+}
+.column {
+ outline: none;
+ cursor: pointer;
+}
+.column:focus {
+ outline: 2px solid #0ff0fc;
+}
+.selected {
+ box-shadow: 0 0 0 2px #0ff0fc;
+}
+.srOnly {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
diff --git a/src/components/create/RiskProfileComparison.test.tsx b/src/components/create/RiskProfileComparison.test.tsx
new file mode 100644
index 00000000..1cf576c7
--- /dev/null
+++ b/src/components/create/RiskProfileComparison.test.tsx
@@ -0,0 +1,53 @@
+import { render, screen, fireEvent } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import RiskProfileComparison from './RiskProfileComparison';
+
+describe('RiskProfileComparison', () => {
+ const mockConfig = {
+ riskProfiles: [
+ { id: 'conservative', name: 'Conservative', description: 'Low risk', maxLossBps: 1000 },
+ { id: 'balanced', name: 'Balanced', description: 'Medium risk', maxLossBps: 5000 },
+ { id: 'aggressive', name: 'Aggressive', description: 'High risk', maxLossBps: 10000 },
+ ],
+ };
+
+ beforeEach(() => {
+ // @ts-ignore
+ global.fetch = jest.fn().mockResolvedValue({ json: async () => mockConfig });
+ });
+
+ afterEach(() => {
+ jest.resetAllMocks();
+ });
+
+ test('renders columns based on fetched profiles', async () => {
+ const onSelect = jest.fn();
+ render(
);
+ // Wait for fetch
+ expect(await screen.findByText('Conservative')).toBeInTheDocument();
+ expect(screen.getByText('Balanced')).toBeInTheDocument();
+ expect(screen.getByText('Aggressive')).toBeInTheDocument();
+ });
+
+ test('clicking a column calls onSelectType with correct mapped type', async () => {
+ const onSelect = jest.fn();
+ render(
);
+ const conservativeTitle = await screen.findByText('Conservative');
+ fireEvent.click(conservativeTitle.closest('div')!);
+ expect(onSelect).toHaveBeenCalledWith('safe');
+ });
+
+ test('keyboard navigation and selection', async () => {
+ const onSelect = jest.fn();
+ render(
);
+ const firstColumn = await screen.findByText('Conservative');
+ const firstDiv = firstColumn.closest('div')!;
+ firstDiv.focus();
+ fireEvent.keyDown(firstDiv, { key: 'ArrowRight' });
+ // Move focus to next column
+ const secondDiv = screen.getByText('Balanced').closest('div')!;
+ expect(document.activeElement).toBe(secondDiv);
+ fireEvent.keyDown(secondDiv, { key: 'Enter' });
+ expect(onSelect).toHaveBeenCalledWith('balanced');
+ });
+});
diff --git a/src/components/create/RiskProfileComparison.tsx b/src/components/create/RiskProfileComparison.tsx
new file mode 100644
index 00000000..3be0768b
--- /dev/null
+++ b/src/components/create/RiskProfileComparison.tsx
@@ -0,0 +1,89 @@
+import { useEffect, useState, KeyboardEvent } from 'react';
+import ComparisonPanel from '../ComparisonPanel';
+import styles from './RiskProfileComparison.module.css';
+
+interface RiskProfile {
+ id: string;
+ name: string;
+ description: string;
+ maxLossBps: number; // basis points
+}
+
+interface SupportedConfig {
+ riskProfiles: RiskProfile[];
+}
+
+interface Props {
+ selectedType: 'safe' | 'balanced' | 'aggressive' | null;
+ onSelectType: (type: 'safe' | 'balanced' | 'aggressive') => void;
+}
+
+// Map config IDs to wizard commitment IDs
+const ID_MAP: Record
= {
+ conservative: 'safe',
+ balanced: 'balanced',
+ aggressive: 'aggressive',
+};
+
+export default function RiskProfileComparison({ selectedType, onSelectType }: Props) {
+ const [profiles, setProfiles] = useState([]);
+ const [focusedIdx, setFocusedIdx] = useState(-1);
+
+ useEffect(() => {
+ fetch('/api/config/supported')
+ .then((res) => res.json())
+ .then((data: SupportedConfig) => setProfiles(data.riskProfiles))
+ .catch(() => setProfiles([]));
+ }, []);
+
+ const buildItems = (profile: RiskProfile) => [
+ `Yield: ${profile.name}`,
+ `Penalty Exposure: ${profile.maxLossBps / 100}% loss`,
+ `Lock Duration: ${profile.id === 'conservative' ? '30d' : profile.id === 'balanced' ? '60d' : '90d'}`,
+ ];
+
+ const handleKeyDown = (e: KeyboardEvent, idx: number) => {
+ if (e.key === 'ArrowRight') {
+ const next = (idx + 1) % profiles.length;
+ setFocusedIdx(next);
+ (e.currentTarget.parentElement?.children[next] as HTMLElement)?.focus();
+ } else if (e.key === 'ArrowLeft') {
+ const prev = (idx - 1 + profiles.length) % profiles.length;
+ setFocusedIdx(prev);
+ (e.currentTarget.parentElement?.children[prev] as HTMLElement)?.focus();
+ } else if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ const type = ID_MAP[profiles[idx].id];
+ if (type) onSelectType(type);
+ }
+ };
+
+ return (
+
+
+ {selectedType ? `Selected ${selectedType} profile` : 'No profile selected'}
+
+ {profiles.map((profile, idx) => {
+ const type = ID_MAP[profile.id];
+ const isSelected = selectedType === type;
+ return (
+
type && onSelectType(type)}
+ onKeyDown={(e) => handleKeyDown(e, idx)}
+ >
+
+
+ );
+ })}
+
+ );
+}