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
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
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
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
24 changes: 24 additions & 0 deletions src/hooks/__tests__/useUnsavedChangesGuard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { renderHook, act } from '@testing-library/react-hooks'
import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard'

test('detects dirty state and resets baseline', () => {
const initial = { a: 1, b: 2 }
const { result, rerender } = renderHook(({ state }) => useUnsavedChangesGuard(state), {
initialProps: { state: initial },
})

// initially not dirty
expect(result.current.isDirty).toBe(false)

// change state
const changed = { a: 1, b: 3 }
rerender({ state: changed })
expect(result.current.isDirty).toBe(true)

// reset baseline via resetBaseline (simulate after save)
act(() => {
result.current.resetBaseline()
})
// after reset, not dirty
expect(result.current.isDirty).toBe(false)
})
66 changes: 66 additions & 0 deletions src/hooks/useUnsavedChangesGuard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { useEffect, useRef, useState } from 'react';
import { useRouter } from 'next/navigation';

/**
* Hook to track dirty state of a form/object and guard against accidental navigation.
*
* @param currentState The current state object (e.g., preferences).
* @returns {
* isDirty: boolean indicating if changes differ from baseline,
* resetBaseline: () => void to set current state as new baseline (e.g., after save)
* }
*/
export function useUnsavedChangesGuard<T extends Record<string, any>>(currentState: T) {
const baselineRef = useRef<T>(JSON.parse(JSON.stringify(currentState)));
const [isDirty, setIsDirty] = useState(false);
const router = useRouter();

// Compare current state with baseline whenever it changes.
useEffect(() => {
const dirty = JSON.stringify(currentState) !== JSON.stringify(baselineRef.current);
setIsDirty(dirty);
}, [currentState]);

const resetBaseline = () => {
baselineRef.current = JSON.parse(JSON.stringify(currentState));
setIsDirty(false);
};

// Prompt on browser unload (refresh/close)
useEffect(() => {
const handler = (e: BeforeUnloadEvent) => {
if (isDirty) {
e.preventDefault();
e.returnValue = '';
return '';
}
};
window.addEventListener('beforeunload', handler);
return () => window.removeEventListener('beforeunload', handler);
}, [isDirty]);

// Guard against client-side navigation using Next.js router.
useEffect(() => {
const handle = (state: any) => {
if (isDirty) {
const confirmLeave = window.confirm('You have unsaved changes. Are you sure you want to leave?');
if (!confirmLeave) return false;
}
return true;
};
// @ts-ignore – beforePopState may be undefined in some versions.
if (router && typeof router.beforePopState === 'function') {
// @ts-ignore
router.beforePopState(handle);
}
return () => {
// @ts-ignore
if (router && typeof router.beforePopState === 'function') {
// @ts-ignore
router.beforePopState(() => true);
}
};
}, [router, isDirty]);

return { isDirty, resetBaseline };
}
Loading