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
23 changes: 23 additions & 0 deletions docs/SETTINGS_UNSAVED_GUARD.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Settings Unsaved Changes Guard

This feature adds a **dirty‑state indicator** and a **navigation guard** to the Settings page.

## What it does
- Tracks the initial snapshot of the notification preferences.
- Shows an inline **"Unsaved changes"** badge when the current form differs from the snapshot.
- Disables the **Save** button until there are changes.
- Prompts the user if they attempt to leave the page (via back/forward navigation or browser refresh/close) while unsaved changes exist.
- Resets the baseline after a successful save or a manual reset.

## Implementation details
- New hook: `useUnsavedChangesGuard` located at `src/hooks/useUnsavedChangesGuard.ts`.
- Settings page (`src/app/settings/page.tsx`) now imports and uses the hook.
- UI updates include the badge and conditional disabling of the Save button.
- Tests added for the hook and the Settings page UI.

## How to use
The hook can be reused for any form that needs dirty‑state tracking:
```tsx
const { isDirty, resetBaseline } = useUnsavedChangesGuard(formState);
```
Call `resetBaseline()` after persisting the changes.
32 changes: 32 additions & 0 deletions src/app/settings/__tests__/page.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<SettingsPage />);
// 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(<SettingsPage />);
const saveBtn = screen.getByRole('button', { name: /Save Preferences/i });
expect(saveBtn).toBeDisabled();
});
});
6 changes: 5 additions & 1 deletion src/app/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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) => {
Expand All @@ -40,6 +42,7 @@ export default function SettingsPage() {
setTimeout(() => {
setIsSaving(false)
setShowSuccess(true)
resetBaseline();
setTimeout(() => setShowSuccess(false), 3000)
}, 1000)
}
Expand Down Expand Up @@ -69,6 +72,7 @@ export default function SettingsPage() {
<h1 className="text-4xl font-extrabold tracking-tight sm:text-5xl mb-4 bg-gradient-to-r from-white to-white/60 bg-clip-text text-transparent">
Settings
</h1>
{isDirty && <span className="ml-2 px-2 py-1 bg-yellow-500 text-black text-sm rounded">Unsaved changes</span>}
<p className="text-lg text-white/50 max-w-2xl leading-relaxed">
Manage your account, wallet, and notification preferences.
</p>
Expand Down Expand Up @@ -199,7 +203,7 @@ export default function SettingsPage() {
</button>
<button
onClick={handleSave}
disabled={isSaving}
disabled={isSaving || !isDirty}
className={`
w-full sm:w-auto flex items-center justify-center gap-2 px-10 py-3.5 rounded-xl font-bold transition-all active:scale-[0.98]
${isSaving
Expand Down
19 changes: 0 additions & 19 deletions src/components/CreateCommitmentStepSelectType.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,25 +117,6 @@ export default function CreateCommitmentStepSelectType({
</p>
</div>

<div
id="commitment-type-container"
className={styles.cardsContainer}
role="radiogroup"
aria-label="Commitment type"
tabIndex={-1}
style={{ outline: 'none' }}
>
{commitmentTypes.map((type) => {
const Icon = type.icon;
const isSelected = selectedType === type.id;

return (
<div
key={type.id}
onClick={() => onSelectType(type.id)}
role="radio"
aria-checked={isSelected}
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
Expand Down
31 changes: 25 additions & 6 deletions src/components/MarketplaceCard.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"use client";
import { ReputationDisplay } from "./ReputationDisplay";

import { memo, useState } from "react";
import { CommitmentDetailsModal } from "./modals/CommitmentDetailsModal";
Expand All @@ -10,7 +11,9 @@ export type CommitmentType = "Safe" | "Balanced" | "Aggressive";
export interface MarketplaceCardProps {
id: string;
type: CommitmentType;
score: number;
score: number; // reputation score (0-100)
totalCommitments?: number; // optional seller total commitments
successRate?: number; // optional seller success rate percentage
amount: string;
duration: string;
yield: string;
Expand Down Expand Up @@ -167,6 +170,8 @@ function MarketplaceCardComponent({
id,
type,
score,
totalCommitments,
successRate,
amount,
duration,
yield: apy,
Expand Down Expand Up @@ -222,11 +227,12 @@ function MarketplaceCardComponent({
>
{type}
</span>
<span
className={`text-[12px] font-bold px-3 py-2 rounded-[10px] border border-[rgba(255,255,255,0.12)] ${scoreColorClass}`}
>
{clampedScore}%
</span>
{/* Compact reputation display */}
{typeof totalCommitments !== 'undefined' && typeof successRate !== 'undefined' ? (
<span className={`text-[12px] font-bold px-3 py-2 rounded-[10px] border border-[rgba(255,255,255,0.12)] ${scoreColorClass}`}>"{clampedScore}%"</span>
) : (
<span className="text-[12px] font-bold px-3 py-2 rounded-[10px] border border-gray-500 text-gray-400">New seller</span>
)}
</div>
</header>

Expand Down Expand Up @@ -268,6 +274,15 @@ function MarketplaceCardComponent({
level={trustLevel ?? "unverified"}
showTooltip={false}
/>
{/* Render ReputationDisplay compactly when data available */}
{typeof totalCommitments !== 'undefined' && typeof successRate !== 'undefined' && (
<ReputationDisplay
score={clampedScore}
totalCommitments={totalCommitments}
successRate={successRate}
className="mt-2"
/>
)}
</dd>
</div>
</dl>
Expand Down Expand Up @@ -357,6 +372,10 @@ function MarketplaceCardComponent({
statusVariant: "ok",
},
]}
// Pass reputation data to modal
reputationScore={clampedScore}
totalCommitments={totalCommitments}
successRate={successRate}
TypeIcon={TypeIcon}
/>
</article>
Expand Down
26 changes: 26 additions & 0 deletions src/components/create/RiskProfileComparison.module.css
Original file line number Diff line number Diff line change
@@ -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;
}
53 changes: 53 additions & 0 deletions src/components/create/RiskProfileComparison.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<RiskProfileComparison selectedType={null} onSelectType={onSelect} />);
// 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(<RiskProfileComparison selectedType={null} onSelectType={onSelect} />);
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(<RiskProfileComparison selectedType={null} onSelectType={onSelect} />);
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');
});
});
89 changes: 89 additions & 0 deletions src/components/create/RiskProfileComparison.tsx
Original file line number Diff line number Diff line change
@@ -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<string, 'safe' | 'balanced' | 'aggressive'> = {
conservative: 'safe',
balanced: 'balanced',
aggressive: 'aggressive',
};

export default function RiskProfileComparison({ selectedType, onSelectType }: Props) {
const [profiles, setProfiles] = useState<RiskProfile[]>([]);
const [focusedIdx, setFocusedIdx] = useState<number>(-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<HTMLDivElement>, 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 (
<div className={styles.container} role="radiogroup" aria-label="Risk profile selection">
<div aria-live="polite" className={styles.srOnly}>
{selectedType ? `Selected ${selectedType} profile` : 'No profile selected'}
</div>
{profiles.map((profile, idx) => {
const type = ID_MAP[profile.id];
const isSelected = selectedType === type;
return (
<div
key={profile.id}
role="radio"
aria-checked={isSelected}
tabIndex={0}
className={`${styles.column} ${isSelected ? styles.selected : ''}`}
onClick={() => type && onSelectType(type)}
onKeyDown={(e) => handleKeyDown(e, idx)}
>
<ComparisonPanel
title={profile.name}
items={buildItems(profile)}
variant={isSelected ? 'positive' : 'negative'}
/>
</div>
);
})}
</div>
);
}
3 changes: 3 additions & 0 deletions src/components/modals/CommitmentDetailsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ interface CommitmentDetailsModalProps {
complianceItems: ComplianceItem[];
onSelectComplianceItem?: (id: string) => void;
TypeIcon: React.ComponentType<{ type: "Safe" | "Balanced" | "Aggressive" }>;
reputationScore?: number;
totalCommitments?: number;
successRate?: number;
}

function capitalizeType(
Expand Down
Loading
Loading