diff --git a/src/app/commitments/page.tsx b/src/app/commitments/page.tsx index ffdd1722..05863c3f 100644 --- a/src/app/commitments/page.tsx +++ b/src/app/commitments/page.tsx @@ -9,6 +9,10 @@ import MyCommitmentsGrid from '@/components/MyCommitmentsGrid' import MyCommitmentsGridSkeleton from '@/components/MyCommitmentsGridSkeleton' import CommitmentEarlyExitModal from '@/components/CommitmentEarlyExitModal/CommitmentEarlyExitModal' import ExportCommitmentsModal from '@/components/export/ExportCommitmentsModal' +import { + EarlyExitPreviewSummary, + fetchEarlyExitPreviewSummary, +} from '@/components/CommitmentEarlyExitModal/earlyExitPreview' import { useWallet } from '@/hooks/useWallet' import { Commitment, CommitmentStats } from '@/types/commitment' import { listCommitments } from '@/lib/backend/mocks/contracts' @@ -131,6 +135,12 @@ function getEarlyExitValues(originalAmount: string, asset: string, penaltyPercen } } +type EarlyExitPreviewState = + | { status: 'idle' } + | { status: 'loading' } + | { status: 'success'; summary: EarlyExitPreviewSummary } + | { status: 'error'; error: string } + export default function MyCommitments() { const router = useRouter() const { address } = useWallet() @@ -149,6 +159,7 @@ export default function MyCommitments() { const [isLoading, setIsLoading] = useState(true) const [protocolConstants, setProtocolConstants] = useState(null) const [, setIsLoadingConstants] = useState(true) + const [earlyExitPreview, setEarlyExitPreview] = useState({ status: 'idle' }) useEffect(() => { fetchProtocolConstants() @@ -196,7 +207,9 @@ export default function MyCommitments() { }, [commitmentsList, searchQuery, statusFilter, typeFilter, sortBy]) const commitmentForEarlyExit = commitmentsList.find((c) => c.id === earlyExitCommitmentId) - const earlyExitSummary = useMemo(() => { + const earlyExitPreviewCommitmentId = commitmentForEarlyExit?.id + const earlyExitPreviewAsset = commitmentForEarlyExit?.asset + const estimatedEarlyExitSummary = useMemo(() => { if (!commitmentForEarlyExit) return null let penaltyPercent = 10 @@ -222,6 +235,38 @@ export default function MyCommitments() { ) }, [commitmentForEarlyExit, protocolConstants]) + useEffect(() => { + if (!earlyExitPreviewCommitmentId || !earlyExitPreviewAsset) { + setEarlyExitPreview({ status: 'idle' }) + return + } + + let ignore = false + setEarlyExitPreview({ status: 'loading' }) + + fetchEarlyExitPreviewSummary(earlyExitPreviewCommitmentId, earlyExitPreviewAsset) + .then((summary) => { + if (!ignore) setEarlyExitPreview({ status: 'success', summary }) + }) + .catch((error) => { + if (!ignore) { + setEarlyExitPreview({ + status: 'error', + error: error instanceof Error ? error.message : 'Unable to refresh live preview', + }) + } + }) + + return () => { + ignore = true + } + }, [earlyExitPreviewAsset, earlyExitPreviewCommitmentId]) + + const earlyExitSummary = + earlyExitPreview.status === 'success' + ? earlyExitPreview.summary + : estimatedEarlyExitSummary + // Callbacks const openEarlyExitModal = useCallback((id: string) => { setSuccessMessage(null) @@ -326,6 +371,8 @@ export default function MyCommitments() { penaltyPercent={earlyExitSummary.penaltyPercent} penaltyAmount={earlyExitSummary.penaltyAmount} netReceiveAmount={earlyExitSummary.netReceiveAmount} + isPreviewLoading={earlyExitPreview.status === 'loading'} + previewError={earlyExitPreview.status === 'error' ? earlyExitPreview.error : null} hasAcknowledged={hasAcknowledged} onChangeAcknowledged={setHasAcknowledged} onCancel={closeEarlyExitModal} diff --git a/src/components/CommitmentEarlyExitModal/CommitmentEarlyExitModal.tsx b/src/components/CommitmentEarlyExitModal/CommitmentEarlyExitModal.tsx index f626412c..f062b04e 100644 --- a/src/components/CommitmentEarlyExitModal/CommitmentEarlyExitModal.tsx +++ b/src/components/CommitmentEarlyExitModal/CommitmentEarlyExitModal.tsx @@ -12,6 +12,8 @@ export interface CommitmentEarlyExitModalProps { penaltyAmount: string; netReceiveAmount: string; hasAcknowledged: boolean; + isPreviewLoading?: boolean; + previewError?: string | null; onChangeAcknowledged: (value: boolean) => void; onCancel: () => void; onConfirm: () => void; @@ -34,6 +36,8 @@ export default function CommitmentEarlyExitModal({ penaltyAmount, netReceiveAmount, hasAcknowledged, + isPreviewLoading = false, + previewError = null, onChangeAcknowledged, onCancel, onConfirm, @@ -49,7 +53,7 @@ export default function CommitmentEarlyExitModal({ const [confirmationInput, setConfirmationInput] = useState('') const hasTypedConfirmation = confirmationInput.trim() === commitmentId - const canConfirm = hasAcknowledged && hasTypedConfirmation + const canConfirm = hasAcknowledged && hasTypedConfirmation && !isPreviewLoading const handleClose = useCallback(() => { (onClose ?? onCancel)(); @@ -129,6 +133,25 @@ export default function CommitmentEarlyExitModal({ {/* Content Body */}
{/* Summary Table - Semantic financial breakdown for accessibility */} + {isPreviewLoading && ( +
+ Fetching live early-exit preview... +
+ )} + + {previewError && ( +
+ Could not refresh the live preview. Showing estimated local figures instead. {previewError} +
+ )} + diff --git a/src/components/CommitmentEarlyExitModal/earlyExitPreview.ts b/src/components/CommitmentEarlyExitModal/earlyExitPreview.ts new file mode 100644 index 00000000..14efa49b --- /dev/null +++ b/src/components/CommitmentEarlyExitModal/earlyExitPreview.ts @@ -0,0 +1,84 @@ +export interface EarlyExitPreviewApiData { + principal: number | string; + penaltyPercent: number | string; + penaltyAmount: number | string; + netRefund: number | string; +} + +export interface EarlyExitPreviewSummary { + penaltyPercent: string; + penaltyAmount: string; + netReceiveAmount: string; +} + +interface ApiEnvelope { + success?: boolean; + data?: T; + error?: { + message?: string; + }; +} + +function formatNumber(value: number): string { + return value.toLocaleString("en-US", { + maximumFractionDigits: 6, + }); +} + +function formatAssetAmount(value: number | string, asset: string): string { + const numericValue = Number(value); + if (Number.isFinite(numericValue)) { + return `${formatNumber(numericValue)} ${asset}`; + } + + return `${value} ${asset}`; +} + +export function formatEarlyExitPreview( + preview: EarlyExitPreviewApiData, + asset: string, +): EarlyExitPreviewSummary { + const numericPenaltyPercent = Number(preview.penaltyPercent); + + return { + penaltyPercent: Number.isFinite(numericPenaltyPercent) + ? `${formatNumber(numericPenaltyPercent)}%` + : `${preview.penaltyPercent}%`, + penaltyAmount: formatAssetAmount(preview.penaltyAmount, asset), + netReceiveAmount: formatAssetAmount(preview.netRefund, asset), + }; +} + +export async function fetchEarlyExitPreviewSummary( + commitmentId: string, + asset: string, + fetcher: typeof fetch = fetch, +): Promise { + const response = await fetcher( + `/api/commitments/${encodeURIComponent(commitmentId)}/early-exit/preview`, + ); + + if (!response.ok) { + throw new Error(`Live preview failed with status ${response.status}`); + } + + const payload = (await response.json()) as + | ApiEnvelope + | EarlyExitPreviewApiData; + + if ("success" in payload) { + if (payload.success === false) { + throw new Error( + payload.error?.message ?? "Live preview returned an error", + ); + } + + if (!payload.data) { + throw new Error("Live preview response did not include data"); + } + + return formatEarlyExitPreview(payload.data, asset); + } + + return formatEarlyExitPreview(payload, asset); +} diff --git a/tests/components/CommitmentEarlyExitModal.test.tsx b/tests/components/CommitmentEarlyExitModal.test.tsx index 51e1484c..df653718 100644 --- a/tests/components/CommitmentEarlyExitModal.test.tsx +++ b/tests/components/CommitmentEarlyExitModal.test.tsx @@ -194,6 +194,39 @@ describe('CommitmentEarlyExitModal', () => { expect(onConfirm).toHaveBeenCalled(); }); + + it('keeps confirmation disabled while live preview is loading', () => { + const onConfirm = vi.fn(); + + render( + + ); + + fireEvent.change(screen.getByPlaceholderText(/Enter commitment ID exactly/i), { + target: { value: 'CMT-TEST123' }, + }); + + expect(screen.getByRole('status')).toHaveTextContent('Fetching live early-exit preview'); + expect(screen.getByRole('button', { name: /Confirm Early Exit/i })).toBeDisabled(); + }); + + it('shows a non-blocking live preview error message', () => { + render( + + ); + + expect(screen.getByRole('alert')).toHaveTextContent( + 'Could not refresh the live preview. Showing estimated local figures instead. Live preview failed with status 503' + ); + }); }); describe('penalty preview calculations per risk tier', () => { @@ -250,5 +283,21 @@ describe('CommitmentEarlyExitModal', () => { expect(screen.getByLabelText('Penalty deduction: minus 12,500 Stellar Lumens')).toHaveTextContent('-12,500 XLM'); expect(screen.getByLabelText('Net refund amount: 237,500 Stellar Lumens')).toHaveTextContent('237,500 XLM'); }); + + it('renders a penalty-free grace-period preview when the live preview returns 0%', () => { + render( + + ); + + expect(screen.getByLabelText('Penalty rate: 0 percent')).toHaveTextContent('0%'); + expect(screen.getByLabelText('Penalty deduction: minus 0 Stellar Lumens')).toHaveTextContent('-0 XLM'); + expect(screen.getByLabelText('Net refund amount: 50,000 Stellar Lumens')).toHaveTextContent('50,000 XLM'); + }); }); }); diff --git a/tests/components/earlyExitPreview.test.ts b/tests/components/earlyExitPreview.test.ts new file mode 100644 index 00000000..af55b128 --- /dev/null +++ b/tests/components/earlyExitPreview.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it, vi } from "vitest"; +import { + fetchEarlyExitPreviewSummary, + formatEarlyExitPreview, +} from "@/components/CommitmentEarlyExitModal/earlyExitPreview"; + +function jsonResponse(body: unknown, ok = true, status = 200): Response { + return { + ok, + status, + json: vi.fn().mockResolvedValue(body), + } as unknown as Response; +} + +describe("early exit preview helpers", () => { + it("formats live preview numbers with the commitment asset", () => { + expect( + formatEarlyExitPreview( + { + principal: 50000, + penaltyPercent: 2, + penaltyAmount: 1000, + netRefund: 49000, + }, + "XLM", + ), + ).toEqual({ + penaltyPercent: "2%", + penaltyAmount: "1,000 XLM", + netReceiveAmount: "49,000 XLM", + }); + }); + + it("preserves a 0% grace-period preview", () => { + expect( + formatEarlyExitPreview( + { + principal: 50000, + penaltyPercent: 0, + penaltyAmount: 0, + netRefund: 50000, + }, + "XLM", + ), + ).toEqual({ + penaltyPercent: "0%", + penaltyAmount: "0 XLM", + netReceiveAmount: "50,000 XLM", + }); + }); + + it("fetches and unwraps the preview API envelope", async () => { + const fetcher = vi.fn().mockResolvedValue( + jsonResponse({ + success: true, + data: { + principal: 100000, + penaltyPercent: 3, + penaltyAmount: 3000, + netRefund: 97000, + }, + }), + ); + + await expect( + fetchEarlyExitPreviewSummary("CMT-XYZ789", "USDC", fetcher), + ).resolves.toEqual({ + penaltyPercent: "3%", + penaltyAmount: "3,000 USDC", + netReceiveAmount: "97,000 USDC", + }); + expect(fetcher).toHaveBeenCalledWith( + "/api/commitments/CMT-XYZ789/early-exit/preview", + ); + }); + + it("rejects failed preview envelopes with the server message", async () => { + const fetcher = vi.fn().mockResolvedValue( + jsonResponse({ + success: false, + error: { + message: "Commitment has already been settled", + }, + }), + ); + + await expect( + fetchEarlyExitPreviewSummary("CMT-XYZ789", "USDC", fetcher), + ).rejects.toThrow("Commitment has already been settled"); + }); + + it("rejects non-ok preview responses", async () => { + const fetcher = vi.fn().mockResolvedValue(jsonResponse({}, false, 503)); + + await expect( + fetchEarlyExitPreviewSummary("CMT-XYZ789", "USDC", fetcher), + ).rejects.toThrow("Live preview failed with status 503"); + }); +});
Financial breakdown of early exit penalty and final refund amount