Skip to content
Closed
49 changes: 48 additions & 1 deletion src/app/commitments/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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()
Expand All @@ -149,6 +159,7 @@ export default function MyCommitments() {
const [isLoading, setIsLoading] = useState(true)
const [protocolConstants, setProtocolConstants] = useState<ProtocolConstants | null>(null)
const [, setIsLoadingConstants] = useState(true)
const [earlyExitPreview, setEarlyExitPreview] = useState<EarlyExitPreviewState>({ status: 'idle' })

useEffect(() => {
fetchProtocolConstants()
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -34,6 +36,8 @@ export default function CommitmentEarlyExitModal({
penaltyAmount,
netReceiveAmount,
hasAcknowledged,
isPreviewLoading = false,
previewError = null,
onChangeAcknowledged,
onCancel,
onConfirm,
Expand All @@ -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)();
Expand Down Expand Up @@ -129,6 +133,25 @@ export default function CommitmentEarlyExitModal({
{/* Content Body */}
<div className="px-6 sm:px-10 pb-8">
{/* Summary Table - Semantic financial breakdown for accessibility */}
{isPreviewLoading && (
<div
role="status"
aria-live="polite"
className="mb-4 rounded-2xl border border-[#0FF0FC]/20 bg-[#0FF0FC]/10 px-4 py-3 text-[13px] font-semibold text-[#0FF0FC]"
>
Fetching live early-exit preview...
</div>
)}

{previewError && (
<div
role="alert"
className="mb-4 rounded-2xl border border-[#FF8A04]/25 bg-[#FF8A04]/10 px-4 py-3 text-[13px] font-semibold text-[#FFB15A]"
>
Could not refresh the live preview. Showing estimated local figures instead. {previewError}
</div>
)}

<table className="w-full text-left border-collapse mb-8" aria-label="Early exit penalty breakdown">
<caption className="sr-only">Financial breakdown of early exit penalty and final refund amount</caption>
<thead>
Expand Down
84 changes: 84 additions & 0 deletions src/components/CommitmentEarlyExitModal/earlyExitPreview.ts
Original file line number Diff line number Diff line change
@@ -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<T> {
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<EarlyExitPreviewSummary> {
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>
| 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);
}
49 changes: 49 additions & 0 deletions tests/components/CommitmentEarlyExitModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,39 @@ describe('CommitmentEarlyExitModal', () => {

expect(onConfirm).toHaveBeenCalled();
});

it('keeps confirmation disabled while live preview is loading', () => {
const onConfirm = vi.fn();

render(
<CommitmentEarlyExitModal
{...defaultProps}
hasAcknowledged={true}
isPreviewLoading={true}
onConfirm={onConfirm}
/>
);

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(
<CommitmentEarlyExitModal
{...defaultProps}
previewError="Live preview failed with status 503"
/>
);

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', () => {
Expand Down Expand Up @@ -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(
<CommitmentEarlyExitModal
{...defaultProps}
originalAmount="50,000 XLM"
penaltyPercent="0%"
penaltyAmount="0 XLM"
netReceiveAmount="50,000 XLM"
/>
);

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');
});
});
});
99 changes: 99 additions & 0 deletions tests/components/earlyExitPreview.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading