From d86c6e0a2960e1e13eadf43a08ab0f6c1759dd1f Mon Sep 17 00:00:00 2001 From: xx7412421-cloud Date: Sun, 21 Jun 2026 19:31:36 +0800 Subject: [PATCH 01/10] feat: wire early exit modal to live preview --- src/app/commitments/page.tsx | 721 +++++++++++++++++++---------------- 1 file changed, 384 insertions(+), 337 deletions(-) diff --git a/src/app/commitments/page.tsx b/src/app/commitments/page.tsx index ffdd172..d1b1d13 100644 --- a/src/app/commitments/page.tsx +++ b/src/app/commitments/page.tsx @@ -1,344 +1,391 @@ -'use client' - -import { useRouter } from 'next/navigation' -import { useState, useCallback, useMemo, useEffect } from 'react' -import MyCommitmentsHeader from '@/components/MyCommitmentsHeader' -import MyCommitmentsStats from '@/components/MyCommitmentsStats/MyCommitmentsStats' -import MyCommitmentsFilters from '@/components/MyCommitmentsFilters/MyCommitmentsFilters' +'use client' + +import { useRouter } from 'next/navigation' +import { useState, useCallback, useMemo, useEffect } from 'react' +import MyCommitmentsHeader from '@/components/MyCommitmentsHeader' +import MyCommitmentsStats from '@/components/MyCommitmentsStats/MyCommitmentsStats' +import MyCommitmentsFilters from '@/components/MyCommitmentsFilters/MyCommitmentsFilters' 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' -import { fetchProtocolConstants, ProtocolConstants } from '@/utils/protocol' - -const mockCommitments: Commitment[] = [ - { - id: 'CMT-ABC123', - type: 'Safe', - status: 'Active', - asset: 'XLM', - amount: '50,000', - currentValue: '52,600', - changePercent: 5.2, - durationProgress: 75, - daysRemaining: 15, - complianceScore: 95, - maxLoss: '2%', - currentDrawdown: '0.8%', - createdDate: 'Jan 10, 2026', - expiryDate: 'Feb 9, 2026', - }, - { - id: 'CMT-XYZ789', - type: 'Balanced', - status: 'Active', - asset: 'USDC', - amount: '100,000', - currentValue: '112,500', - changePercent: 12.5, - durationProgress: 30, - daysRemaining: 42, - complianceScore: 88, - maxLoss: '8%', - currentDrawdown: '3.2%', - createdDate: 'Dec 15, 2025', - expiryDate: 'Feb 13, 2026', - }, - { - id: 'CMT-DEF456', - type: 'Aggressive', - status: 'Active', - asset: 'XLM', - amount: '250,000', - currentValue: '296,750', - changePercent: 18.7, - durationProgress: 17, - daysRemaining: 75, - complianceScore: 76, - maxLoss: 'No limit', - currentDrawdown: '12.5%', - createdDate: 'Nov 20, 2025', - expiryDate: 'Feb 10, 2026', - }, - { - id: 'CMT-GHI012', - type: 'Safe', - status: 'Settled', - asset: 'XLM', - amount: '75,000', - currentValue: '78,750', - changePercent: 5.0, - durationProgress: 100, - daysRemaining: 0, - complianceScore: 97, - maxLoss: '2%', - currentDrawdown: '0%', - createdDate: 'Dec 1, 2025', - expiryDate: 'Dec 31, 2025', - }, - { - id: 'CMT-JKL345', - type: 'Balanced', - status: 'Early Exit', - asset: 'USDC', - amount: '150,000', - currentValue: '145,500', - changePercent: -3.0, - durationProgress: 100, - daysRemaining: 0, - complianceScore: 72, - maxLoss: '8%', - currentDrawdown: '3%', - createdDate: 'Nov 1, 2025', - expiryDate: 'Dec 30, 2025', - }, - { - id: 'CMT-MN0678', - type: 'Aggressive', - status: 'Violated', - asset: 'XLM', - amount: '200,000', - currentValue: '160,000', - changePercent: -20.0, - durationProgress: 100, - daysRemaining: 0, - complianceScore: 45, - maxLoss: 'No limit', - currentDrawdown: '20%', - createdDate: 'Oct 15, 2025', - expiryDate: 'Jan 13, 2026', - }, -] - -const mockStats: CommitmentStats = { - totalActive: 3, - totalCommittedValue: '$461,850', - avgComplianceScore: 86, - totalFeesGenerated: '$1,250', -} - -function getEarlyExitValues(originalAmount: string, asset: string, penaltyPercent: number) { - const amount = Number(originalAmount.replace(/,/g, '')) - const penaltyAmount = (amount * (penaltyPercent / 100)).toFixed(0) - const netReceive = (amount - Number(penaltyAmount)).toFixed(0) - return { - penaltyPercent: `${penaltyPercent}%`, - penaltyAmount: `${Number(penaltyAmount).toLocaleString()} ${asset}`, - netReceiveAmount: `${Number(netReceive).toLocaleString()} ${asset}`, - } -} - -export default function MyCommitments() { - const router = useRouter() - const { address } = useWallet() - - // State - const [searchQuery, setSearchQuery] = useState('') - const [statusFilter, setStatusFilter] = useState('All') - const [typeFilter, setTypeFilter] = useState('All') - const [sortBy, setSortBy] = useState('Newest') - - const [earlyExitCommitmentId, setEarlyExitCommitmentId] = useState(null) - const [isExportOpen, setIsExportOpen] = useState(false) - const [hasAcknowledged, setHasAcknowledged] = useState(false) - const [commitmentsList, setCommitmentsList] = useState(mockCommitments) - const [successMessage, setSuccessMessage] = useState(null) - const [isLoading, setIsLoading] = useState(true) +import { Commitment, CommitmentStats } from '@/types/commitment' +import { listCommitments } from '@/lib/backend/mocks/contracts' +import { fetchProtocolConstants, ProtocolConstants } from '@/utils/protocol' + +const mockCommitments: Commitment[] = [ + { + id: 'CMT-ABC123', + type: 'Safe', + status: 'Active', + asset: 'XLM', + amount: '50,000', + currentValue: '52,600', + changePercent: 5.2, + durationProgress: 75, + daysRemaining: 15, + complianceScore: 95, + maxLoss: '2%', + currentDrawdown: '0.8%', + createdDate: 'Jan 10, 2026', + expiryDate: 'Feb 9, 2026', + }, + { + id: 'CMT-XYZ789', + type: 'Balanced', + status: 'Active', + asset: 'USDC', + amount: '100,000', + currentValue: '112,500', + changePercent: 12.5, + durationProgress: 30, + daysRemaining: 42, + complianceScore: 88, + maxLoss: '8%', + currentDrawdown: '3.2%', + createdDate: 'Dec 15, 2025', + expiryDate: 'Feb 13, 2026', + }, + { + id: 'CMT-DEF456', + type: 'Aggressive', + status: 'Active', + asset: 'XLM', + amount: '250,000', + currentValue: '296,750', + changePercent: 18.7, + durationProgress: 17, + daysRemaining: 75, + complianceScore: 76, + maxLoss: 'No limit', + currentDrawdown: '12.5%', + createdDate: 'Nov 20, 2025', + expiryDate: 'Feb 10, 2026', + }, + { + id: 'CMT-GHI012', + type: 'Safe', + status: 'Settled', + asset: 'XLM', + amount: '75,000', + currentValue: '78,750', + changePercent: 5.0, + durationProgress: 100, + daysRemaining: 0, + complianceScore: 97, + maxLoss: '2%', + currentDrawdown: '0%', + createdDate: 'Dec 1, 2025', + expiryDate: 'Dec 31, 2025', + }, + { + id: 'CMT-JKL345', + type: 'Balanced', + status: 'Early Exit', + asset: 'USDC', + amount: '150,000', + currentValue: '145,500', + changePercent: -3.0, + durationProgress: 100, + daysRemaining: 0, + complianceScore: 72, + maxLoss: '8%', + currentDrawdown: '3%', + createdDate: 'Nov 1, 2025', + expiryDate: 'Dec 30, 2025', + }, + { + id: 'CMT-MN0678', + type: 'Aggressive', + status: 'Violated', + asset: 'XLM', + amount: '200,000', + currentValue: '160,000', + changePercent: -20.0, + durationProgress: 100, + daysRemaining: 0, + complianceScore: 45, + maxLoss: 'No limit', + currentDrawdown: '20%', + createdDate: 'Oct 15, 2025', + expiryDate: 'Jan 13, 2026', + }, +] + +const mockStats: CommitmentStats = { + totalActive: 3, + totalCommittedValue: '$461,850', + avgComplianceScore: 86, + totalFeesGenerated: '$1,250', +} + +function getEarlyExitValues(originalAmount: string, asset: string, penaltyPercent: number) { + const amount = Number(originalAmount.replace(/,/g, '')) + const penaltyAmount = (amount * (penaltyPercent / 100)).toFixed(0) + const netReceive = (amount - Number(penaltyAmount)).toFixed(0) + return { + penaltyPercent: `${penaltyPercent}%`, + penaltyAmount: `${Number(penaltyAmount).toLocaleString()} ${asset}`, + netReceiveAmount: `${Number(netReceive).toLocaleString()} ${asset}`, + } +} + +type EarlyExitPreviewState = + | { status: 'idle' } + | { status: 'loading' } + | { status: 'success'; summary: EarlyExitPreviewSummary } + | { status: 'error'; error: string } + +export default function MyCommitments() { + const router = useRouter() + const { address } = useWallet() + + // State + const [searchQuery, setSearchQuery] = useState('') + const [statusFilter, setStatusFilter] = useState('All') + const [typeFilter, setTypeFilter] = useState('All') + const [sortBy, setSortBy] = useState('Newest') + + const [earlyExitCommitmentId, setEarlyExitCommitmentId] = useState(null) + const [isExportOpen, setIsExportOpen] = useState(false) + const [hasAcknowledged, setHasAcknowledged] = useState(false) + const [commitmentsList, setCommitmentsList] = useState(mockCommitments) + const [successMessage, setSuccessMessage] = useState(null) + const [isLoading, setIsLoading] = useState(true) const [protocolConstants, setProtocolConstants] = useState(null) const [, setIsLoadingConstants] = useState(true) - - useEffect(() => { - fetchProtocolConstants() - .then(setProtocolConstants) - .catch((err) => console.error('Failed to fetch protocol constants:', err)) - .finally(() => setIsLoadingConstants(false)) - }, []) - - useEffect(() => { - if (process.env.NEXT_PUBLIC_USE_MOCKS === 'true') { - setIsLoading(true) - listCommitments() - .then(setCommitmentsList) - .finally(() => setIsLoading(false)) - } else { - // Simulate loading for demo purposes - const timer = setTimeout(() => { - setIsLoading(false) - }, 1000) - return () => clearTimeout(timer) - } - }, []) - - // Derived State - const filteredCommitments = useMemo(() => { - const filtered = commitmentsList.filter((c) => { - const matchesSearch = c.id.toLowerCase().includes(searchQuery.toLowerCase()) - const matchesStatus = statusFilter === 'All' || c.status.toLowerCase() === statusFilter.toLowerCase() - const matchesType = typeFilter === 'All' || c.type.toLowerCase() === typeFilter.toLowerCase() - return matchesSearch && matchesStatus && matchesType - }) - - // Basic Sorting Logic - if (sortBy === 'ValueHighLow') { - filtered.sort((a, b) => Number(b.amount.replace(/,/g, '')) - Number(a.amount.replace(/,/g, ''))) - } else if (sortBy === 'ValueLowHigh') { - filtered.sort((a, b) => Number(a.amount.replace(/,/g, '')) - Number(b.amount.replace(/,/g, ''))) - } else if (sortBy === 'Newest') { - filtered.sort((a, b) => new Date(b.createdDate).getTime() - new Date(a.createdDate).getTime()) - } else if (sortBy === 'Oldest') { - filtered.sort((a, b) => new Date(a.createdDate).getTime() - new Date(b.createdDate).getTime()) - } - - return filtered - }, [commitmentsList, searchQuery, statusFilter, typeFilter, sortBy]) - - const commitmentForEarlyExit = commitmentsList.find((c) => c.id === earlyExitCommitmentId) - const earlyExitSummary = useMemo(() => { - if (!commitmentForEarlyExit) return null - - let penaltyPercent = 10 - if (protocolConstants?.penalties) { - const tier = protocolConstants.penalties.find( - (p) => p.type.toLowerCase() === commitmentForEarlyExit.type.toLowerCase() - ) - if (tier) { - penaltyPercent = tier.earlyExitPenaltyPercent - } - } else { - // Fallback local calculations in case loading or error - const lowerType = commitmentForEarlyExit.type.toLowerCase() - if (lowerType === 'safe') penaltyPercent = 2 - else if (lowerType === 'balanced') penaltyPercent = 3 - else if (lowerType === 'aggressive') penaltyPercent = 5 - } - - return getEarlyExitValues( - commitmentForEarlyExit.amount, - commitmentForEarlyExit.asset, - penaltyPercent - ) - }, [commitmentForEarlyExit, protocolConstants]) - - // Callbacks - const openEarlyExitModal = useCallback((id: string) => { - setSuccessMessage(null) - setEarlyExitCommitmentId(id) - setHasAcknowledged(false) - }, []) - - const closeEarlyExitModal = useCallback(() => { - setEarlyExitCommitmentId(null) - setHasAcknowledged(false) - }, []) - - const handleConfirmEarlyExit = useCallback(() => { - if (!earlyExitCommitmentId || !earlyExitSummary) return - - const committed = commitmentsList.find((c) => c.id === earlyExitCommitmentId) - if (!committed) return - - setCommitmentsList((current) => - current.map((commitment) => - commitment.id === earlyExitCommitmentId - ? { ...commitment, status: 'Early Exit' } - : commitment - ) - ) - - setSuccessMessage( - `Early exit confirmed for ${committed.id}. ${earlyExitSummary.penaltyPercent} penalty applied; you will receive ${earlyExitSummary.netReceiveAmount}.` - ) - - closeEarlyExitModal() - }, [earlyExitCommitmentId, earlyExitSummary, commitmentsList, closeEarlyExitModal]) - - return ( -
- router.push('/')} - onCreateNew={() => router.push('/create')} - onExport={() => setIsExportOpen(true)} - /> - - {successMessage && ( -
-
-

{successMessage}

- -
-

- This commitment has been updated to Early Exit status in your portfolio. Check the list for the new status and confirm any remaining settlement details. -

-
- )} - -
- {isLoading ? ( - - ) : ( - <> - - - - - router.push(`/commitments/${id}`)} - onAttestations={(id) => console.log('Attestations for', id)} - onEarlyExit={openEarlyExitModal} - /> - - )} -
- - {commitmentForEarlyExit && earlyExitSummary && ( - - )} - - setIsExportOpen(false)} - ownerAddress={address} - /> -
- ) -} + const [earlyExitPreview, setEarlyExitPreview] = useState({ status: 'idle' }) + + useEffect(() => { + fetchProtocolConstants() + .then(setProtocolConstants) + .catch((err) => console.error('Failed to fetch protocol constants:', err)) + .finally(() => setIsLoadingConstants(false)) + }, []) + + useEffect(() => { + if (process.env.NEXT_PUBLIC_USE_MOCKS === 'true') { + setIsLoading(true) + listCommitments() + .then(setCommitmentsList) + .finally(() => setIsLoading(false)) + } else { + // Simulate loading for demo purposes + const timer = setTimeout(() => { + setIsLoading(false) + }, 1000) + return () => clearTimeout(timer) + } + }, []) + + // Derived State + const filteredCommitments = useMemo(() => { + const filtered = commitmentsList.filter((c) => { + const matchesSearch = c.id.toLowerCase().includes(searchQuery.toLowerCase()) + const matchesStatus = statusFilter === 'All' || c.status.toLowerCase() === statusFilter.toLowerCase() + const matchesType = typeFilter === 'All' || c.type.toLowerCase() === typeFilter.toLowerCase() + return matchesSearch && matchesStatus && matchesType + }) + + // Basic Sorting Logic + if (sortBy === 'ValueHighLow') { + filtered.sort((a, b) => Number(b.amount.replace(/,/g, '')) - Number(a.amount.replace(/,/g, ''))) + } else if (sortBy === 'ValueLowHigh') { + filtered.sort((a, b) => Number(a.amount.replace(/,/g, '')) - Number(b.amount.replace(/,/g, ''))) + } else if (sortBy === 'Newest') { + filtered.sort((a, b) => new Date(b.createdDate).getTime() - new Date(a.createdDate).getTime()) + } else if (sortBy === 'Oldest') { + filtered.sort((a, b) => new Date(a.createdDate).getTime() - new Date(b.createdDate).getTime()) + } + + return filtered + }, [commitmentsList, searchQuery, statusFilter, typeFilter, sortBy]) + + const commitmentForEarlyExit = commitmentsList.find((c) => c.id === earlyExitCommitmentId) + const earlyExitPreviewCommitmentId = commitmentForEarlyExit?.id + const earlyExitPreviewAsset = commitmentForEarlyExit?.asset + const estimatedEarlyExitSummary = useMemo(() => { + if (!commitmentForEarlyExit) return null + + let penaltyPercent = 10 + if (protocolConstants?.penalties) { + const tier = protocolConstants.penalties.find( + (p) => p.type.toLowerCase() === commitmentForEarlyExit.type.toLowerCase() + ) + if (tier) { + penaltyPercent = tier.earlyExitPenaltyPercent + } + } else { + // Fallback local calculations in case loading or error + const lowerType = commitmentForEarlyExit.type.toLowerCase() + if (lowerType === 'safe') penaltyPercent = 2 + else if (lowerType === 'balanced') penaltyPercent = 3 + else if (lowerType === 'aggressive') penaltyPercent = 5 + } + + return getEarlyExitValues( + commitmentForEarlyExit.amount, + commitmentForEarlyExit.asset, + penaltyPercent + ) + }, [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) + setEarlyExitCommitmentId(id) + setHasAcknowledged(false) + }, []) + + const closeEarlyExitModal = useCallback(() => { + setEarlyExitCommitmentId(null) + setHasAcknowledged(false) + }, []) + + const handleConfirmEarlyExit = useCallback(() => { + if (!earlyExitCommitmentId || !earlyExitSummary) return + + const committed = commitmentsList.find((c) => c.id === earlyExitCommitmentId) + if (!committed) return + + setCommitmentsList((current) => + current.map((commitment) => + commitment.id === earlyExitCommitmentId + ? { ...commitment, status: 'Early Exit' } + : commitment + ) + ) + + setSuccessMessage( + `Early exit confirmed for ${committed.id}. ${earlyExitSummary.penaltyPercent} penalty applied; you will receive ${earlyExitSummary.netReceiveAmount}.` + ) + + closeEarlyExitModal() + }, [earlyExitCommitmentId, earlyExitSummary, commitmentsList, closeEarlyExitModal]) + + return ( +
+ router.push('/')} + onCreateNew={() => router.push('/create')} + onExport={() => setIsExportOpen(true)} + /> + + {successMessage && ( +
+
+

{successMessage}

+ +
+

+ This commitment has been updated to Early Exit status in your portfolio. Check the list for the new status and confirm any remaining settlement details. +

+
+ )} + +
+ {isLoading ? ( + + ) : ( + <> + + + + + router.push(`/commitments/${id}`)} + onAttestations={(id) => console.log('Attestations for', id)} + onEarlyExit={openEarlyExitModal} + /> + + )} +
+ + {commitmentForEarlyExit && earlyExitSummary && ( + + )} + + setIsExportOpen(false)} + ownerAddress={address} + /> +
+ ) +} From 37a18ce868ca8c75a3cbfb63ed2fcc30b543bec1 Mon Sep 17 00:00:00 2001 From: xx7412421-cloud Date: Sun, 21 Jun 2026 19:31:37 +0800 Subject: [PATCH 02/10] feat: wire early exit modal to live preview --- .../CommitmentEarlyExitModal.tsx | 533 +++++++++--------- 1 file changed, 278 insertions(+), 255 deletions(-) diff --git a/src/components/CommitmentEarlyExitModal/CommitmentEarlyExitModal.tsx b/src/components/CommitmentEarlyExitModal/CommitmentEarlyExitModal.tsx index f626412..8bca1d5 100644 --- a/src/components/CommitmentEarlyExitModal/CommitmentEarlyExitModal.tsx +++ b/src/components/CommitmentEarlyExitModal/CommitmentEarlyExitModal.tsx @@ -1,255 +1,278 @@ -'use client'; - -import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { createPortal } from 'react-dom'; -import { AlertTriangle, X, Info } from 'lucide-react'; - -export interface CommitmentEarlyExitModalProps { - isOpen: boolean; - commitmentId: string; - originalAmount: string; - penaltyPercent: string; - penaltyAmount: string; - netReceiveAmount: string; - hasAcknowledged: boolean; - onChangeAcknowledged: (value: boolean) => void; - onCancel: () => void; - onConfirm: () => void; - onClose?: () => void; -} - -function formatScreenReaderText(val: string): string { - return val - .replace(/\bXLM\b/gi, 'Stellar Lumens') - .replace(/\bUSDC\b/gi, 'USD Coin') - .replace(/%/g, ' percent') - .replace(/-/g, 'minus ') -} - -export default function CommitmentEarlyExitModal({ - isOpen, - commitmentId, - originalAmount, - penaltyPercent, - penaltyAmount, - netReceiveAmount, - hasAcknowledged, - onChangeAcknowledged, - onCancel, - onConfirm, - onClose, -}: CommitmentEarlyExitModalProps) { - const modalRef = useRef(null); - const [mounted, setMounted] = useState(false); - - useEffect(() => { - setMounted(true); - return () => setMounted(false); - }, []); - - const [confirmationInput, setConfirmationInput] = useState('') - const hasTypedConfirmation = confirmationInput.trim() === commitmentId - const canConfirm = hasAcknowledged && hasTypedConfirmation - - const handleClose = useCallback(() => { - (onClose ?? onCancel)(); - }, [onClose, onCancel]); - - useEffect(() => { - if (!isOpen) return; - - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') handleClose(); - - // Focus trap - if (e.key === 'Tab' && modalRef.current) { - const focusableElements = modalRef.current.querySelectorAll( - 'button:not([disabled]), [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' - ); - const first = focusableElements[0] as HTMLElement; - const last = focusableElements[focusableElements.length - 1] as HTMLElement; - - if (e.shiftKey && document.activeElement === first) { - e.preventDefault(); - last.focus(); - } else if (!e.shiftKey && document.activeElement === last) { - e.preventDefault(); - first.focus(); - } - } - }; - - document.addEventListener('keydown', handleKeyDown); - document.body.style.overflow = 'hidden'; - - return () => { - document.removeEventListener('keydown', handleKeyDown); - document.body.style.overflow = ''; - }; - }, [isOpen, handleClose]); - - if (!isOpen || !mounted) return null; - - return createPortal( -
e.target === e.currentTarget && handleClose()} - role="dialog" - aria-modal="true" - aria-labelledby="early-exit-title" - > -
- {/* Header - Consistency with Details modal */} -
-
-
- -
-
-

- Early Exit Warning -

-

- This action is irreversible and carries penalties. -

-
-
- -
- - {/* Content Body */} -
- {/* Summary Table - Semantic financial breakdown for accessibility */} - - - - - - - - - - {/* Commitment ID */} - - - - - {/* Before Early Exit */} - - - - - {/* Penalty Rate */} - - - - - {/* Penalty Deduction */} - - - - - {/* After Early Exit */} - - - - - -
Financial breakdown of early exit penalty and final refund amount
ItemValue
Commitment ID - {commitmentId.slice(0, 8)}...{commitmentId.slice(-6)} -
Before Early Exit (Committed Amount) - {originalAmount} -
Penalty Rate - {penaltyPercent} -
Penalty Deduction - -{penaltyAmount} -
After Early Exit (Net Refund) - {netReceiveAmount} -
- - {/* Important Notice Block */} -
-
- - Important consequences -
-
    - {[ - 'You will lose the penalty amount shown above immediately.', - 'The commitment will be recorded as an Early Exit on-chain.', - 'This action cannot be reversed or modified.', - 'You forfeit future yield and continue to hold reduced value.' - ].map((text, i) => ( -
  • -
    - {text} -
  • - ))} -
-
- - {/* Acknowledgment Checkbox */} - - -
- - setConfirmationInput(e.target.value)} - className="w-full rounded-2xl border border-white/10 bg-[#050505] px-4 py-3 text-white placeholder:text-white/30 outline-none focus:border-[#FF8A04] focus:ring-2 focus:ring-[#FF8A04]/20" - placeholder="Enter commitment ID exactly" - autoComplete="off" - /> -

- Confirming this action requires the exact commitment ID: {commitmentId} -

-
- - {/* Action Buttons - Standardized placement */} -
- - -
-
-
-
, - document.body - ) -} +'use client'; + +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { AlertTriangle, X, Info } from 'lucide-react'; + +export interface CommitmentEarlyExitModalProps { + isOpen: boolean; + commitmentId: string; + originalAmount: string; + penaltyPercent: string; + penaltyAmount: string; + netReceiveAmount: string; + hasAcknowledged: boolean; + isPreviewLoading?: boolean; + previewError?: string | null; + onChangeAcknowledged: (value: boolean) => void; + onCancel: () => void; + onConfirm: () => void; + onClose?: () => void; +} + +function formatScreenReaderText(val: string): string { + return val + .replace(/\bXLM\b/gi, 'Stellar Lumens') + .replace(/\bUSDC\b/gi, 'USD Coin') + .replace(/%/g, ' percent') + .replace(/-/g, 'minus ') +} + +export default function CommitmentEarlyExitModal({ + isOpen, + commitmentId, + originalAmount, + penaltyPercent, + penaltyAmount, + netReceiveAmount, + hasAcknowledged, + isPreviewLoading = false, + previewError = null, + onChangeAcknowledged, + onCancel, + onConfirm, + onClose, +}: CommitmentEarlyExitModalProps) { + const modalRef = useRef(null); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + return () => setMounted(false); + }, []); + + const [confirmationInput, setConfirmationInput] = useState('') + const hasTypedConfirmation = confirmationInput.trim() === commitmentId + const canConfirm = hasAcknowledged && hasTypedConfirmation && !isPreviewLoading + + const handleClose = useCallback(() => { + (onClose ?? onCancel)(); + }, [onClose, onCancel]); + + useEffect(() => { + if (!isOpen) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') handleClose(); + + // Focus trap + if (e.key === 'Tab' && modalRef.current) { + const focusableElements = modalRef.current.querySelectorAll( + 'button:not([disabled]), [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ); + const first = focusableElements[0] as HTMLElement; + const last = focusableElements[focusableElements.length - 1] as HTMLElement; + + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + } + }; + + document.addEventListener('keydown', handleKeyDown); + document.body.style.overflow = 'hidden'; + + return () => { + document.removeEventListener('keydown', handleKeyDown); + document.body.style.overflow = ''; + }; + }, [isOpen, handleClose]); + + if (!isOpen || !mounted) return null; + + return createPortal( +
e.target === e.currentTarget && handleClose()} + role="dialog" + aria-modal="true" + aria-labelledby="early-exit-title" + > +
+ {/* Header - Consistency with Details modal */} +
+
+
+ +
+
+

+ Early Exit Warning +

+

+ This action is irreversible and carries penalties. +

+
+
+ +
+ + {/* 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} +
+ )} + + + + + + + + + + + {/* Commitment ID */} + + + + + {/* Before Early Exit */} + + + + + {/* Penalty Rate */} + + + + + {/* Penalty Deduction */} + + + + + {/* After Early Exit */} + + + + + +
Financial breakdown of early exit penalty and final refund amount
ItemValue
Commitment ID + {commitmentId.slice(0, 8)}...{commitmentId.slice(-6)} +
Before Early Exit (Committed Amount) + {originalAmount} +
Penalty Rate + {penaltyPercent} +
Penalty Deduction + -{penaltyAmount} +
After Early Exit (Net Refund) + {netReceiveAmount} +
+ + {/* Important Notice Block */} +
+
+ + Important consequences +
+
    + {[ + 'You will lose the penalty amount shown above immediately.', + 'The commitment will be recorded as an Early Exit on-chain.', + 'This action cannot be reversed or modified.', + 'You forfeit future yield and continue to hold reduced value.' + ].map((text, i) => ( +
  • +
    + {text} +
  • + ))} +
+
+ + {/* Acknowledgment Checkbox */} + + +
+ + setConfirmationInput(e.target.value)} + className="w-full rounded-2xl border border-white/10 bg-[#050505] px-4 py-3 text-white placeholder:text-white/30 outline-none focus:border-[#FF8A04] focus:ring-2 focus:ring-[#FF8A04]/20" + placeholder="Enter commitment ID exactly" + autoComplete="off" + /> +

+ Confirming this action requires the exact commitment ID: {commitmentId} +

+
+ + {/* Action Buttons - Standardized placement */} +
+ + +
+
+
+
, + document.body + ) +} From 3b92711d18555fe770e8682aef1c3003a5e8d935 Mon Sep 17 00:00:00 2001 From: xx7412421-cloud Date: Sun, 21 Jun 2026 19:31:38 +0800 Subject: [PATCH 03/10] feat: wire early exit modal to live preview --- .../earlyExitPreview.ts | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 src/components/CommitmentEarlyExitModal/earlyExitPreview.ts diff --git a/src/components/CommitmentEarlyExitModal/earlyExitPreview.ts b/src/components/CommitmentEarlyExitModal/earlyExitPreview.ts new file mode 100644 index 0000000..14efa49 --- /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); +} From f49a320cc8d56202f62f916769cde4e094fbe333 Mon Sep 17 00:00:00 2001 From: xx7412421-cloud Date: Sun, 21 Jun 2026 19:31:40 +0800 Subject: [PATCH 04/10] feat: wire early exit modal to live preview --- .../CommitmentEarlyExitModal.test.tsx | 557 ++++++++++-------- 1 file changed, 303 insertions(+), 254 deletions(-) diff --git a/tests/components/CommitmentEarlyExitModal.test.tsx b/tests/components/CommitmentEarlyExitModal.test.tsx index 51e1484..1b17ae5 100644 --- a/tests/components/CommitmentEarlyExitModal.test.tsx +++ b/tests/components/CommitmentEarlyExitModal.test.tsx @@ -1,254 +1,303 @@ -// @vitest-environment happy-dom -import React from 'react'; -import { describe, it, expect, vi } from 'vitest'; -import { render, screen, fireEvent } from '@testing-library/react'; -import '@testing-library/jest-dom'; -import CommitmentEarlyExitModal from '@/components/CommitmentEarlyExitModal/CommitmentEarlyExitModal'; - -describe('CommitmentEarlyExitModal', () => { - const defaultProps = { - isOpen: true, - commitmentId: 'CMT-TEST123', - originalAmount: '50,000 XLM', - penaltyPercent: '2%', - penaltyAmount: '1,000 XLM', - netReceiveAmount: '49,000 XLM', - hasAcknowledged: false, - onChangeAcknowledged: vi.fn(), - onCancel: vi.fn(), - onConfirm: vi.fn(), - onClose: vi.fn(), - }; - - it('renders nothing when isOpen is false', () => { - const { container } = render( - - ); - expect(container.firstChild).toBeNull(); - }); - - it('renders the modal header and informational notice when open', () => { - render(); - - // Header check - expect(screen.getByText('Early Exit Warning')).toBeInTheDocument(); - expect(screen.getByText('This action is irreversible and carries penalties.')).toBeInTheDocument(); - - // Notice check - expect(screen.getByText('Important consequences')).toBeInTheDocument(); - expect(screen.getByText('You will lose the penalty amount shown above immediately.')).toBeInTheDocument(); - }); - - describe('accessibility and semantic markup', () => { - it('contains a semantic table with a descriptive screen-reader caption', () => { - render(); - - const table = screen.getByRole('table'); - expect(table).toBeInTheDocument(); - - // Caption check - const caption = table.querySelector('caption'); - expect(caption).toBeInTheDocument(); - expect(caption).toHaveTextContent('Financial breakdown of early exit penalty and final refund amount'); - }); - - it('has proper scope attributes on table headers for row and col structure', () => { - render(); - - const table = screen.getByRole('table'); - - // Column headers - const colHeaders = table.querySelectorAll('th[scope="col"]'); - expect(colHeaders.length).toBe(2); - expect(colHeaders[0]).toHaveTextContent('Item'); - expect(colHeaders[1]).toHaveTextContent('Value'); - - // Row headers - const rowHeaders = table.querySelectorAll('th[scope="row"]'); - expect(rowHeaders.length).toBe(5); - expect(rowHeaders[0]).toHaveTextContent('Commitment ID'); - expect(rowHeaders[1]).toHaveTextContent('Before Early Exit (Committed Amount)'); - expect(rowHeaders[2]).toHaveTextContent('Penalty Rate'); - expect(rowHeaders[3]).toHaveTextContent('Penalty Deduction'); - expect(rowHeaders[4]).toHaveTextContent('After Early Exit (Net Refund)'); - }); - - it('applies accessible screen reader labels spelling out units and currency codes', () => { - render(); - - // Check original amount cell has aria-label spelling out XLM - const originalAmountCell = screen.getByLabelText('Committed amount: 50,000 Stellar Lumens'); - expect(originalAmountCell).toBeInTheDocument(); - expect(originalAmountCell).toHaveTextContent('50,000 XLM'); - - // Check penalty percentage cell has aria-label spelling out percent - const penaltyPercentCell = screen.getByLabelText('Penalty rate: 2 percent'); - expect(penaltyPercentCell).toBeInTheDocument(); - expect(penaltyPercentCell).toHaveTextContent('2%'); - - // Check penalty amount cell has aria-label spelling out negative prefix and XLM - const penaltyAmountCell = screen.getByLabelText('Penalty deduction: minus 1,000 Stellar Lumens'); - expect(penaltyAmountCell).toBeInTheDocument(); - expect(penaltyAmountCell).toHaveTextContent('-1,000 XLM'); - - // Check net refund cell has aria-label spelling out XLM - const netRefundCell = screen.getByLabelText('Net refund amount: 49,000 Stellar Lumens'); - expect(netRefundCell).toBeInTheDocument(); - expect(netRefundCell).toHaveTextContent('49,000 XLM'); - }); - }); - - describe('user interactions and confirmation validation', () => { - it('disables the confirm button by default', () => { - render(); - - const confirmButton = screen.getByRole('button', { name: /Confirm Early Exit/i }); - expect(confirmButton).toBeDisabled(); - }); - - it('triggers onChangeAcknowledged callback when clicking acknowledgment checkbox', () => { - const onChangeAcknowledged = vi.fn(); - render( - - ); - - const checkbox = screen.getByRole('checkbox'); - fireEvent.click(checkbox); - - expect(onChangeAcknowledged).toHaveBeenCalledWith(true); - }); - - it('enables the confirm button when both acknowledgement and commitment ID typing are satisfied', () => { - const { rerender } = render( - - ); - - const input = screen.getByPlaceholderText(/Enter commitment ID exactly/i); - const confirmButton = screen.getByRole('button', { name: /Confirm Early Exit/i }); - - // Action 1: Type the correct ID - fireEvent.change(input, { target: { value: 'CMT-TEST123' } }); - expect(confirmButton).toBeDisabled(); // still disabled because hasAcknowledged is false - - // Action 2: Receive acknowledgment prop as true - rerender(); - - // Type matching ID again since input value state is local to render lifecycle - fireEvent.change(screen.getByPlaceholderText(/Enter commitment ID exactly/i), { - target: { value: 'CMT-TEST123' }, - }); - - expect(screen.getByRole('button', { name: /Confirm Early Exit/i })).not.toBeDisabled(); - }); - - it('remains disabled if user typed the wrong commitment ID', () => { - render(); - - const input = screen.getByPlaceholderText(/Enter commitment ID exactly/i); - const confirmButton = screen.getByRole('button', { name: /Confirm Early Exit/i }); - - fireEvent.change(input, { target: { value: 'WRONG-ID-999' } }); - - expect(confirmButton).toBeDisabled(); - }); - - it('calls onCancel or onClose when appropriate buttons are clicked', () => { - const onCancel = vi.fn(); - const onClose = vi.fn(); - - render( - - ); - - // Cancel button click - fireEvent.click(screen.getByRole('button', { name: /Cancel/i })); - expect(onCancel).toHaveBeenCalled(); - - // Close button (X) click - fireEvent.click(screen.getByRole('button', { name: /Close modal/i })); - expect(onClose).toHaveBeenCalled(); - }); - - it('calls onConfirm when confirm button is clicked', () => { - const onConfirm = vi.fn(); - render( - - ); - - const input = screen.getByPlaceholderText(/Enter commitment ID exactly/i); - fireEvent.change(input, { target: { value: 'CMT-TEST123' } }); - - const confirmButton = screen.getByRole('button', { name: /Confirm Early Exit/i }); - fireEvent.click(confirmButton); - - expect(onConfirm).toHaveBeenCalled(); - }); - }); - - describe('penalty preview calculations per risk tier', () => { - it('asserts correct rendering and labels for Safe (2%) tier calculations', () => { - render( - - ); - - // Value text matchers - expect(screen.getByLabelText('Committed amount: 50,000 Stellar Lumens')).toHaveTextContent('50,000 XLM'); - expect(screen.getByLabelText('Penalty rate: 2 percent')).toHaveTextContent('2%'); - expect(screen.getByLabelText('Penalty deduction: minus 1,000 Stellar Lumens')).toHaveTextContent('-1,000 XLM'); - expect(screen.getByLabelText('Net refund amount: 49,000 Stellar Lumens')).toHaveTextContent('49,000 XLM'); - }); - - it('asserts correct rendering and labels for Balanced (3%) tier calculations', () => { - render( - - ); - - // Value text matchers - expect(screen.getByLabelText('Committed amount: 100,000 USD Coin')).toHaveTextContent('100,000 USDC'); - expect(screen.getByLabelText('Penalty rate: 3 percent')).toHaveTextContent('3%'); - expect(screen.getByLabelText('Penalty deduction: minus 3,000 USD Coin')).toHaveTextContent('-3,000 USDC'); - expect(screen.getByLabelText('Net refund amount: 97,000 USD Coin')).toHaveTextContent('97,000 USDC'); - }); - - it('asserts correct rendering and labels for Aggressive (5%) tier calculations', () => { - render( - - ); - - // Value text matchers - expect(screen.getByLabelText('Committed amount: 250,000 Stellar Lumens')).toHaveTextContent('250,000 XLM'); - expect(screen.getByLabelText('Penalty rate: 5 percent')).toHaveTextContent('5%'); - 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'); - }); - }); -}); +// @vitest-environment happy-dom +import React from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import CommitmentEarlyExitModal from '@/components/CommitmentEarlyExitModal/CommitmentEarlyExitModal'; + +describe('CommitmentEarlyExitModal', () => { + const defaultProps = { + isOpen: true, + commitmentId: 'CMT-TEST123', + originalAmount: '50,000 XLM', + penaltyPercent: '2%', + penaltyAmount: '1,000 XLM', + netReceiveAmount: '49,000 XLM', + hasAcknowledged: false, + onChangeAcknowledged: vi.fn(), + onCancel: vi.fn(), + onConfirm: vi.fn(), + onClose: vi.fn(), + }; + + it('renders nothing when isOpen is false', () => { + const { container } = render( + + ); + expect(container.firstChild).toBeNull(); + }); + + it('renders the modal header and informational notice when open', () => { + render(); + + // Header check + expect(screen.getByText('Early Exit Warning')).toBeInTheDocument(); + expect(screen.getByText('This action is irreversible and carries penalties.')).toBeInTheDocument(); + + // Notice check + expect(screen.getByText('Important consequences')).toBeInTheDocument(); + expect(screen.getByText('You will lose the penalty amount shown above immediately.')).toBeInTheDocument(); + }); + + describe('accessibility and semantic markup', () => { + it('contains a semantic table with a descriptive screen-reader caption', () => { + render(); + + const table = screen.getByRole('table'); + expect(table).toBeInTheDocument(); + + // Caption check + const caption = table.querySelector('caption'); + expect(caption).toBeInTheDocument(); + expect(caption).toHaveTextContent('Financial breakdown of early exit penalty and final refund amount'); + }); + + it('has proper scope attributes on table headers for row and col structure', () => { + render(); + + const table = screen.getByRole('table'); + + // Column headers + const colHeaders = table.querySelectorAll('th[scope="col"]'); + expect(colHeaders.length).toBe(2); + expect(colHeaders[0]).toHaveTextContent('Item'); + expect(colHeaders[1]).toHaveTextContent('Value'); + + // Row headers + const rowHeaders = table.querySelectorAll('th[scope="row"]'); + expect(rowHeaders.length).toBe(5); + expect(rowHeaders[0]).toHaveTextContent('Commitment ID'); + expect(rowHeaders[1]).toHaveTextContent('Before Early Exit (Committed Amount)'); + expect(rowHeaders[2]).toHaveTextContent('Penalty Rate'); + expect(rowHeaders[3]).toHaveTextContent('Penalty Deduction'); + expect(rowHeaders[4]).toHaveTextContent('After Early Exit (Net Refund)'); + }); + + it('applies accessible screen reader labels spelling out units and currency codes', () => { + render(); + + // Check original amount cell has aria-label spelling out XLM + const originalAmountCell = screen.getByLabelText('Committed amount: 50,000 Stellar Lumens'); + expect(originalAmountCell).toBeInTheDocument(); + expect(originalAmountCell).toHaveTextContent('50,000 XLM'); + + // Check penalty percentage cell has aria-label spelling out percent + const penaltyPercentCell = screen.getByLabelText('Penalty rate: 2 percent'); + expect(penaltyPercentCell).toBeInTheDocument(); + expect(penaltyPercentCell).toHaveTextContent('2%'); + + // Check penalty amount cell has aria-label spelling out negative prefix and XLM + const penaltyAmountCell = screen.getByLabelText('Penalty deduction: minus 1,000 Stellar Lumens'); + expect(penaltyAmountCell).toBeInTheDocument(); + expect(penaltyAmountCell).toHaveTextContent('-1,000 XLM'); + + // Check net refund cell has aria-label spelling out XLM + const netRefundCell = screen.getByLabelText('Net refund amount: 49,000 Stellar Lumens'); + expect(netRefundCell).toBeInTheDocument(); + expect(netRefundCell).toHaveTextContent('49,000 XLM'); + }); + }); + + describe('user interactions and confirmation validation', () => { + it('disables the confirm button by default', () => { + render(); + + const confirmButton = screen.getByRole('button', { name: /Confirm Early Exit/i }); + expect(confirmButton).toBeDisabled(); + }); + + it('triggers onChangeAcknowledged callback when clicking acknowledgment checkbox', () => { + const onChangeAcknowledged = vi.fn(); + render( + + ); + + const checkbox = screen.getByRole('checkbox'); + fireEvent.click(checkbox); + + expect(onChangeAcknowledged).toHaveBeenCalledWith(true); + }); + + it('enables the confirm button when both acknowledgement and commitment ID typing are satisfied', () => { + const { rerender } = render( + + ); + + const input = screen.getByPlaceholderText(/Enter commitment ID exactly/i); + const confirmButton = screen.getByRole('button', { name: /Confirm Early Exit/i }); + + // Action 1: Type the correct ID + fireEvent.change(input, { target: { value: 'CMT-TEST123' } }); + expect(confirmButton).toBeDisabled(); // still disabled because hasAcknowledged is false + + // Action 2: Receive acknowledgment prop as true + rerender(); + + // Type matching ID again since input value state is local to render lifecycle + fireEvent.change(screen.getByPlaceholderText(/Enter commitment ID exactly/i), { + target: { value: 'CMT-TEST123' }, + }); + + expect(screen.getByRole('button', { name: /Confirm Early Exit/i })).not.toBeDisabled(); + }); + + it('remains disabled if user typed the wrong commitment ID', () => { + render(); + + const input = screen.getByPlaceholderText(/Enter commitment ID exactly/i); + const confirmButton = screen.getByRole('button', { name: /Confirm Early Exit/i }); + + fireEvent.change(input, { target: { value: 'WRONG-ID-999' } }); + + expect(confirmButton).toBeDisabled(); + }); + + it('calls onCancel or onClose when appropriate buttons are clicked', () => { + const onCancel = vi.fn(); + const onClose = vi.fn(); + + render( + + ); + + // Cancel button click + fireEvent.click(screen.getByRole('button', { name: /Cancel/i })); + expect(onCancel).toHaveBeenCalled(); + + // Close button (X) click + fireEvent.click(screen.getByRole('button', { name: /Close modal/i })); + expect(onClose).toHaveBeenCalled(); + }); + + it('calls onConfirm when confirm button is clicked', () => { + const onConfirm = vi.fn(); + render( + + ); + + const input = screen.getByPlaceholderText(/Enter commitment ID exactly/i); + fireEvent.change(input, { target: { value: 'CMT-TEST123' } }); + + const confirmButton = screen.getByRole('button', { name: /Confirm Early Exit/i }); + fireEvent.click(confirmButton); + + 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', () => { + it('asserts correct rendering and labels for Safe (2%) tier calculations', () => { + render( + + ); + + // Value text matchers + expect(screen.getByLabelText('Committed amount: 50,000 Stellar Lumens')).toHaveTextContent('50,000 XLM'); + expect(screen.getByLabelText('Penalty rate: 2 percent')).toHaveTextContent('2%'); + expect(screen.getByLabelText('Penalty deduction: minus 1,000 Stellar Lumens')).toHaveTextContent('-1,000 XLM'); + expect(screen.getByLabelText('Net refund amount: 49,000 Stellar Lumens')).toHaveTextContent('49,000 XLM'); + }); + + it('asserts correct rendering and labels for Balanced (3%) tier calculations', () => { + render( + + ); + + // Value text matchers + expect(screen.getByLabelText('Committed amount: 100,000 USD Coin')).toHaveTextContent('100,000 USDC'); + expect(screen.getByLabelText('Penalty rate: 3 percent')).toHaveTextContent('3%'); + expect(screen.getByLabelText('Penalty deduction: minus 3,000 USD Coin')).toHaveTextContent('-3,000 USDC'); + expect(screen.getByLabelText('Net refund amount: 97,000 USD Coin')).toHaveTextContent('97,000 USDC'); + }); + + it('asserts correct rendering and labels for Aggressive (5%) tier calculations', () => { + render( + + ); + + // Value text matchers + expect(screen.getByLabelText('Committed amount: 250,000 Stellar Lumens')).toHaveTextContent('250,000 XLM'); + expect(screen.getByLabelText('Penalty rate: 5 percent')).toHaveTextContent('5%'); + 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'); + }); + }); +}); From d2afe78d4a14dd22ce3d4a4786d4f029e6700e3c Mon Sep 17 00:00:00 2001 From: xx7412421-cloud Date: Sun, 21 Jun 2026 19:31:41 +0800 Subject: [PATCH 05/10] feat: wire early exit modal to live preview --- tests/components/earlyExitPreview.test.ts | 99 +++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 tests/components/earlyExitPreview.test.ts diff --git a/tests/components/earlyExitPreview.test.ts b/tests/components/earlyExitPreview.test.ts new file mode 100644 index 0000000..af55b12 --- /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"); + }); +}); From ba6bd53c572a6c49286233c5a0910a2910bf7f79 Mon Sep 17 00:00:00 2001 From: xx7412421-cloud Date: Sun, 21 Jun 2026 19:32:15 +0800 Subject: [PATCH 06/10] chore: normalize early exit preview file endings --- src/app/commitments/page.tsx | 758 +++++++++++++++++------------------ 1 file changed, 379 insertions(+), 379 deletions(-) diff --git a/src/app/commitments/page.tsx b/src/app/commitments/page.tsx index d1b1d13..05863c3 100644 --- a/src/app/commitments/page.tsx +++ b/src/app/commitments/page.tsx @@ -1,10 +1,10 @@ -'use client' - -import { useRouter } from 'next/navigation' -import { useState, useCallback, useMemo, useEffect } from 'react' -import MyCommitmentsHeader from '@/components/MyCommitmentsHeader' -import MyCommitmentsStats from '@/components/MyCommitmentsStats/MyCommitmentsStats' -import MyCommitmentsFilters from '@/components/MyCommitmentsFilters/MyCommitmentsFilters' +'use client' + +import { useRouter } from 'next/navigation' +import { useState, useCallback, useMemo, useEffect } from 'react' +import MyCommitmentsHeader from '@/components/MyCommitmentsHeader' +import MyCommitmentsStats from '@/components/MyCommitmentsStats/MyCommitmentsStats' +import MyCommitmentsFilters from '@/components/MyCommitmentsFilters/MyCommitmentsFilters' import MyCommitmentsGrid from '@/components/MyCommitmentsGrid' import MyCommitmentsGridSkeleton from '@/components/MyCommitmentsGridSkeleton' import CommitmentEarlyExitModal from '@/components/CommitmentEarlyExitModal/CommitmentEarlyExitModal' @@ -14,378 +14,378 @@ import { fetchEarlyExitPreviewSummary, } from '@/components/CommitmentEarlyExitModal/earlyExitPreview' import { useWallet } from '@/hooks/useWallet' -import { Commitment, CommitmentStats } from '@/types/commitment' -import { listCommitments } from '@/lib/backend/mocks/contracts' -import { fetchProtocolConstants, ProtocolConstants } from '@/utils/protocol' - -const mockCommitments: Commitment[] = [ - { - id: 'CMT-ABC123', - type: 'Safe', - status: 'Active', - asset: 'XLM', - amount: '50,000', - currentValue: '52,600', - changePercent: 5.2, - durationProgress: 75, - daysRemaining: 15, - complianceScore: 95, - maxLoss: '2%', - currentDrawdown: '0.8%', - createdDate: 'Jan 10, 2026', - expiryDate: 'Feb 9, 2026', - }, - { - id: 'CMT-XYZ789', - type: 'Balanced', - status: 'Active', - asset: 'USDC', - amount: '100,000', - currentValue: '112,500', - changePercent: 12.5, - durationProgress: 30, - daysRemaining: 42, - complianceScore: 88, - maxLoss: '8%', - currentDrawdown: '3.2%', - createdDate: 'Dec 15, 2025', - expiryDate: 'Feb 13, 2026', - }, - { - id: 'CMT-DEF456', - type: 'Aggressive', - status: 'Active', - asset: 'XLM', - amount: '250,000', - currentValue: '296,750', - changePercent: 18.7, - durationProgress: 17, - daysRemaining: 75, - complianceScore: 76, - maxLoss: 'No limit', - currentDrawdown: '12.5%', - createdDate: 'Nov 20, 2025', - expiryDate: 'Feb 10, 2026', - }, - { - id: 'CMT-GHI012', - type: 'Safe', - status: 'Settled', - asset: 'XLM', - amount: '75,000', - currentValue: '78,750', - changePercent: 5.0, - durationProgress: 100, - daysRemaining: 0, - complianceScore: 97, - maxLoss: '2%', - currentDrawdown: '0%', - createdDate: 'Dec 1, 2025', - expiryDate: 'Dec 31, 2025', - }, - { - id: 'CMT-JKL345', - type: 'Balanced', - status: 'Early Exit', - asset: 'USDC', - amount: '150,000', - currentValue: '145,500', - changePercent: -3.0, - durationProgress: 100, - daysRemaining: 0, - complianceScore: 72, - maxLoss: '8%', - currentDrawdown: '3%', - createdDate: 'Nov 1, 2025', - expiryDate: 'Dec 30, 2025', - }, - { - id: 'CMT-MN0678', - type: 'Aggressive', - status: 'Violated', - asset: 'XLM', - amount: '200,000', - currentValue: '160,000', - changePercent: -20.0, - durationProgress: 100, - daysRemaining: 0, - complianceScore: 45, - maxLoss: 'No limit', - currentDrawdown: '20%', - createdDate: 'Oct 15, 2025', - expiryDate: 'Jan 13, 2026', - }, -] - -const mockStats: CommitmentStats = { - totalActive: 3, - totalCommittedValue: '$461,850', - avgComplianceScore: 86, - totalFeesGenerated: '$1,250', -} - -function getEarlyExitValues(originalAmount: string, asset: string, penaltyPercent: number) { - const amount = Number(originalAmount.replace(/,/g, '')) - const penaltyAmount = (amount * (penaltyPercent / 100)).toFixed(0) - const netReceive = (amount - Number(penaltyAmount)).toFixed(0) - return { - penaltyPercent: `${penaltyPercent}%`, - penaltyAmount: `${Number(penaltyAmount).toLocaleString()} ${asset}`, - netReceiveAmount: `${Number(netReceive).toLocaleString()} ${asset}`, - } -} - -type EarlyExitPreviewState = - | { status: 'idle' } - | { status: 'loading' } - | { status: 'success'; summary: EarlyExitPreviewSummary } - | { status: 'error'; error: string } - -export default function MyCommitments() { - const router = useRouter() - const { address } = useWallet() - - // State - const [searchQuery, setSearchQuery] = useState('') - const [statusFilter, setStatusFilter] = useState('All') - const [typeFilter, setTypeFilter] = useState('All') - const [sortBy, setSortBy] = useState('Newest') - - const [earlyExitCommitmentId, setEarlyExitCommitmentId] = useState(null) - const [isExportOpen, setIsExportOpen] = useState(false) - const [hasAcknowledged, setHasAcknowledged] = useState(false) - const [commitmentsList, setCommitmentsList] = useState(mockCommitments) - const [successMessage, setSuccessMessage] = useState(null) - const [isLoading, setIsLoading] = useState(true) +import { Commitment, CommitmentStats } from '@/types/commitment' +import { listCommitments } from '@/lib/backend/mocks/contracts' +import { fetchProtocolConstants, ProtocolConstants } from '@/utils/protocol' + +const mockCommitments: Commitment[] = [ + { + id: 'CMT-ABC123', + type: 'Safe', + status: 'Active', + asset: 'XLM', + amount: '50,000', + currentValue: '52,600', + changePercent: 5.2, + durationProgress: 75, + daysRemaining: 15, + complianceScore: 95, + maxLoss: '2%', + currentDrawdown: '0.8%', + createdDate: 'Jan 10, 2026', + expiryDate: 'Feb 9, 2026', + }, + { + id: 'CMT-XYZ789', + type: 'Balanced', + status: 'Active', + asset: 'USDC', + amount: '100,000', + currentValue: '112,500', + changePercent: 12.5, + durationProgress: 30, + daysRemaining: 42, + complianceScore: 88, + maxLoss: '8%', + currentDrawdown: '3.2%', + createdDate: 'Dec 15, 2025', + expiryDate: 'Feb 13, 2026', + }, + { + id: 'CMT-DEF456', + type: 'Aggressive', + status: 'Active', + asset: 'XLM', + amount: '250,000', + currentValue: '296,750', + changePercent: 18.7, + durationProgress: 17, + daysRemaining: 75, + complianceScore: 76, + maxLoss: 'No limit', + currentDrawdown: '12.5%', + createdDate: 'Nov 20, 2025', + expiryDate: 'Feb 10, 2026', + }, + { + id: 'CMT-GHI012', + type: 'Safe', + status: 'Settled', + asset: 'XLM', + amount: '75,000', + currentValue: '78,750', + changePercent: 5.0, + durationProgress: 100, + daysRemaining: 0, + complianceScore: 97, + maxLoss: '2%', + currentDrawdown: '0%', + createdDate: 'Dec 1, 2025', + expiryDate: 'Dec 31, 2025', + }, + { + id: 'CMT-JKL345', + type: 'Balanced', + status: 'Early Exit', + asset: 'USDC', + amount: '150,000', + currentValue: '145,500', + changePercent: -3.0, + durationProgress: 100, + daysRemaining: 0, + complianceScore: 72, + maxLoss: '8%', + currentDrawdown: '3%', + createdDate: 'Nov 1, 2025', + expiryDate: 'Dec 30, 2025', + }, + { + id: 'CMT-MN0678', + type: 'Aggressive', + status: 'Violated', + asset: 'XLM', + amount: '200,000', + currentValue: '160,000', + changePercent: -20.0, + durationProgress: 100, + daysRemaining: 0, + complianceScore: 45, + maxLoss: 'No limit', + currentDrawdown: '20%', + createdDate: 'Oct 15, 2025', + expiryDate: 'Jan 13, 2026', + }, +] + +const mockStats: CommitmentStats = { + totalActive: 3, + totalCommittedValue: '$461,850', + avgComplianceScore: 86, + totalFeesGenerated: '$1,250', +} + +function getEarlyExitValues(originalAmount: string, asset: string, penaltyPercent: number) { + const amount = Number(originalAmount.replace(/,/g, '')) + const penaltyAmount = (amount * (penaltyPercent / 100)).toFixed(0) + const netReceive = (amount - Number(penaltyAmount)).toFixed(0) + return { + penaltyPercent: `${penaltyPercent}%`, + penaltyAmount: `${Number(penaltyAmount).toLocaleString()} ${asset}`, + netReceiveAmount: `${Number(netReceive).toLocaleString()} ${asset}`, + } +} + +type EarlyExitPreviewState = + | { status: 'idle' } + | { status: 'loading' } + | { status: 'success'; summary: EarlyExitPreviewSummary } + | { status: 'error'; error: string } + +export default function MyCommitments() { + const router = useRouter() + const { address } = useWallet() + + // State + const [searchQuery, setSearchQuery] = useState('') + const [statusFilter, setStatusFilter] = useState('All') + const [typeFilter, setTypeFilter] = useState('All') + const [sortBy, setSortBy] = useState('Newest') + + const [earlyExitCommitmentId, setEarlyExitCommitmentId] = useState(null) + const [isExportOpen, setIsExportOpen] = useState(false) + const [hasAcknowledged, setHasAcknowledged] = useState(false) + const [commitmentsList, setCommitmentsList] = useState(mockCommitments) + const [successMessage, setSuccessMessage] = useState(null) + const [isLoading, setIsLoading] = useState(true) const [protocolConstants, setProtocolConstants] = useState(null) const [, setIsLoadingConstants] = useState(true) const [earlyExitPreview, setEarlyExitPreview] = useState({ status: 'idle' }) - - useEffect(() => { - fetchProtocolConstants() - .then(setProtocolConstants) - .catch((err) => console.error('Failed to fetch protocol constants:', err)) - .finally(() => setIsLoadingConstants(false)) - }, []) - - useEffect(() => { - if (process.env.NEXT_PUBLIC_USE_MOCKS === 'true') { - setIsLoading(true) - listCommitments() - .then(setCommitmentsList) - .finally(() => setIsLoading(false)) - } else { - // Simulate loading for demo purposes - const timer = setTimeout(() => { - setIsLoading(false) - }, 1000) - return () => clearTimeout(timer) - } - }, []) - - // Derived State - const filteredCommitments = useMemo(() => { - const filtered = commitmentsList.filter((c) => { - const matchesSearch = c.id.toLowerCase().includes(searchQuery.toLowerCase()) - const matchesStatus = statusFilter === 'All' || c.status.toLowerCase() === statusFilter.toLowerCase() - const matchesType = typeFilter === 'All' || c.type.toLowerCase() === typeFilter.toLowerCase() - return matchesSearch && matchesStatus && matchesType - }) - - // Basic Sorting Logic - if (sortBy === 'ValueHighLow') { - filtered.sort((a, b) => Number(b.amount.replace(/,/g, '')) - Number(a.amount.replace(/,/g, ''))) - } else if (sortBy === 'ValueLowHigh') { - filtered.sort((a, b) => Number(a.amount.replace(/,/g, '')) - Number(b.amount.replace(/,/g, ''))) - } else if (sortBy === 'Newest') { - filtered.sort((a, b) => new Date(b.createdDate).getTime() - new Date(a.createdDate).getTime()) - } else if (sortBy === 'Oldest') { - filtered.sort((a, b) => new Date(a.createdDate).getTime() - new Date(b.createdDate).getTime()) - } - - return filtered - }, [commitmentsList, searchQuery, statusFilter, typeFilter, sortBy]) - - const commitmentForEarlyExit = commitmentsList.find((c) => c.id === earlyExitCommitmentId) - const earlyExitPreviewCommitmentId = commitmentForEarlyExit?.id - const earlyExitPreviewAsset = commitmentForEarlyExit?.asset - const estimatedEarlyExitSummary = useMemo(() => { - if (!commitmentForEarlyExit) return null - - let penaltyPercent = 10 - if (protocolConstants?.penalties) { - const tier = protocolConstants.penalties.find( - (p) => p.type.toLowerCase() === commitmentForEarlyExit.type.toLowerCase() - ) - if (tier) { - penaltyPercent = tier.earlyExitPenaltyPercent - } - } else { - // Fallback local calculations in case loading or error - const lowerType = commitmentForEarlyExit.type.toLowerCase() - if (lowerType === 'safe') penaltyPercent = 2 - else if (lowerType === 'balanced') penaltyPercent = 3 - else if (lowerType === 'aggressive') penaltyPercent = 5 - } - - return getEarlyExitValues( - commitmentForEarlyExit.amount, - commitmentForEarlyExit.asset, - penaltyPercent - ) - }, [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) - setEarlyExitCommitmentId(id) - setHasAcknowledged(false) - }, []) - - const closeEarlyExitModal = useCallback(() => { - setEarlyExitCommitmentId(null) - setHasAcknowledged(false) - }, []) - - const handleConfirmEarlyExit = useCallback(() => { - if (!earlyExitCommitmentId || !earlyExitSummary) return - - const committed = commitmentsList.find((c) => c.id === earlyExitCommitmentId) - if (!committed) return - - setCommitmentsList((current) => - current.map((commitment) => - commitment.id === earlyExitCommitmentId - ? { ...commitment, status: 'Early Exit' } - : commitment - ) - ) - - setSuccessMessage( - `Early exit confirmed for ${committed.id}. ${earlyExitSummary.penaltyPercent} penalty applied; you will receive ${earlyExitSummary.netReceiveAmount}.` - ) - - closeEarlyExitModal() - }, [earlyExitCommitmentId, earlyExitSummary, commitmentsList, closeEarlyExitModal]) - - return ( -
- router.push('/')} - onCreateNew={() => router.push('/create')} - onExport={() => setIsExportOpen(true)} - /> - - {successMessage && ( -
-
-

{successMessage}

- -
-

- This commitment has been updated to Early Exit status in your portfolio. Check the list for the new status and confirm any remaining settlement details. -

-
- )} - -
- {isLoading ? ( - - ) : ( - <> - - - - - router.push(`/commitments/${id}`)} - onAttestations={(id) => console.log('Attestations for', id)} - onEarlyExit={openEarlyExitModal} - /> - - )} -
- - {commitmentForEarlyExit && earlyExitSummary && ( - - )} - - setIsExportOpen(false)} - ownerAddress={address} - /> -
- ) -} + + useEffect(() => { + fetchProtocolConstants() + .then(setProtocolConstants) + .catch((err) => console.error('Failed to fetch protocol constants:', err)) + .finally(() => setIsLoadingConstants(false)) + }, []) + + useEffect(() => { + if (process.env.NEXT_PUBLIC_USE_MOCKS === 'true') { + setIsLoading(true) + listCommitments() + .then(setCommitmentsList) + .finally(() => setIsLoading(false)) + } else { + // Simulate loading for demo purposes + const timer = setTimeout(() => { + setIsLoading(false) + }, 1000) + return () => clearTimeout(timer) + } + }, []) + + // Derived State + const filteredCommitments = useMemo(() => { + const filtered = commitmentsList.filter((c) => { + const matchesSearch = c.id.toLowerCase().includes(searchQuery.toLowerCase()) + const matchesStatus = statusFilter === 'All' || c.status.toLowerCase() === statusFilter.toLowerCase() + const matchesType = typeFilter === 'All' || c.type.toLowerCase() === typeFilter.toLowerCase() + return matchesSearch && matchesStatus && matchesType + }) + + // Basic Sorting Logic + if (sortBy === 'ValueHighLow') { + filtered.sort((a, b) => Number(b.amount.replace(/,/g, '')) - Number(a.amount.replace(/,/g, ''))) + } else if (sortBy === 'ValueLowHigh') { + filtered.sort((a, b) => Number(a.amount.replace(/,/g, '')) - Number(b.amount.replace(/,/g, ''))) + } else if (sortBy === 'Newest') { + filtered.sort((a, b) => new Date(b.createdDate).getTime() - new Date(a.createdDate).getTime()) + } else if (sortBy === 'Oldest') { + filtered.sort((a, b) => new Date(a.createdDate).getTime() - new Date(b.createdDate).getTime()) + } + + return filtered + }, [commitmentsList, searchQuery, statusFilter, typeFilter, sortBy]) + + const commitmentForEarlyExit = commitmentsList.find((c) => c.id === earlyExitCommitmentId) + const earlyExitPreviewCommitmentId = commitmentForEarlyExit?.id + const earlyExitPreviewAsset = commitmentForEarlyExit?.asset + const estimatedEarlyExitSummary = useMemo(() => { + if (!commitmentForEarlyExit) return null + + let penaltyPercent = 10 + if (protocolConstants?.penalties) { + const tier = protocolConstants.penalties.find( + (p) => p.type.toLowerCase() === commitmentForEarlyExit.type.toLowerCase() + ) + if (tier) { + penaltyPercent = tier.earlyExitPenaltyPercent + } + } else { + // Fallback local calculations in case loading or error + const lowerType = commitmentForEarlyExit.type.toLowerCase() + if (lowerType === 'safe') penaltyPercent = 2 + else if (lowerType === 'balanced') penaltyPercent = 3 + else if (lowerType === 'aggressive') penaltyPercent = 5 + } + + return getEarlyExitValues( + commitmentForEarlyExit.amount, + commitmentForEarlyExit.asset, + penaltyPercent + ) + }, [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) + setEarlyExitCommitmentId(id) + setHasAcknowledged(false) + }, []) + + const closeEarlyExitModal = useCallback(() => { + setEarlyExitCommitmentId(null) + setHasAcknowledged(false) + }, []) + + const handleConfirmEarlyExit = useCallback(() => { + if (!earlyExitCommitmentId || !earlyExitSummary) return + + const committed = commitmentsList.find((c) => c.id === earlyExitCommitmentId) + if (!committed) return + + setCommitmentsList((current) => + current.map((commitment) => + commitment.id === earlyExitCommitmentId + ? { ...commitment, status: 'Early Exit' } + : commitment + ) + ) + + setSuccessMessage( + `Early exit confirmed for ${committed.id}. ${earlyExitSummary.penaltyPercent} penalty applied; you will receive ${earlyExitSummary.netReceiveAmount}.` + ) + + closeEarlyExitModal() + }, [earlyExitCommitmentId, earlyExitSummary, commitmentsList, closeEarlyExitModal]) + + return ( +
+ router.push('/')} + onCreateNew={() => router.push('/create')} + onExport={() => setIsExportOpen(true)} + /> + + {successMessage && ( +
+
+

{successMessage}

+ +
+

+ This commitment has been updated to Early Exit status in your portfolio. Check the list for the new status and confirm any remaining settlement details. +

+
+ )} + +
+ {isLoading ? ( + + ) : ( + <> + + + + + router.push(`/commitments/${id}`)} + onAttestations={(id) => console.log('Attestations for', id)} + onEarlyExit={openEarlyExitModal} + /> + + )} +
+ + {commitmentForEarlyExit && earlyExitSummary && ( + + )} + + setIsExportOpen(false)} + ownerAddress={address} + /> +
+ ) +} From 42dbda29764d6d9ce5d6b8eaf8d0eb89c152ce93 Mon Sep 17 00:00:00 2001 From: xx7412421-cloud Date: Sun, 21 Jun 2026 19:32:17 +0800 Subject: [PATCH 07/10] chore: normalize early exit preview file endings --- .../CommitmentEarlyExitModal.tsx | 556 +++++++++--------- 1 file changed, 278 insertions(+), 278 deletions(-) diff --git a/src/components/CommitmentEarlyExitModal/CommitmentEarlyExitModal.tsx b/src/components/CommitmentEarlyExitModal/CommitmentEarlyExitModal.tsx index 8bca1d5..f062b04 100644 --- a/src/components/CommitmentEarlyExitModal/CommitmentEarlyExitModal.tsx +++ b/src/components/CommitmentEarlyExitModal/CommitmentEarlyExitModal.tsx @@ -1,278 +1,278 @@ -'use client'; - -import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { createPortal } from 'react-dom'; -import { AlertTriangle, X, Info } from 'lucide-react'; - -export interface CommitmentEarlyExitModalProps { - isOpen: boolean; - commitmentId: string; - originalAmount: string; - penaltyPercent: string; - penaltyAmount: string; - netReceiveAmount: string; - hasAcknowledged: boolean; - isPreviewLoading?: boolean; - previewError?: string | null; - onChangeAcknowledged: (value: boolean) => void; - onCancel: () => void; - onConfirm: () => void; - onClose?: () => void; -} - -function formatScreenReaderText(val: string): string { - return val - .replace(/\bXLM\b/gi, 'Stellar Lumens') - .replace(/\bUSDC\b/gi, 'USD Coin') - .replace(/%/g, ' percent') - .replace(/-/g, 'minus ') -} - -export default function CommitmentEarlyExitModal({ - isOpen, - commitmentId, - originalAmount, - penaltyPercent, - penaltyAmount, - netReceiveAmount, - hasAcknowledged, - isPreviewLoading = false, - previewError = null, - onChangeAcknowledged, - onCancel, - onConfirm, - onClose, -}: CommitmentEarlyExitModalProps) { - const modalRef = useRef(null); - const [mounted, setMounted] = useState(false); - - useEffect(() => { - setMounted(true); - return () => setMounted(false); - }, []); - - const [confirmationInput, setConfirmationInput] = useState('') - const hasTypedConfirmation = confirmationInput.trim() === commitmentId - const canConfirm = hasAcknowledged && hasTypedConfirmation && !isPreviewLoading - - const handleClose = useCallback(() => { - (onClose ?? onCancel)(); - }, [onClose, onCancel]); - - useEffect(() => { - if (!isOpen) return; - - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') handleClose(); - - // Focus trap - if (e.key === 'Tab' && modalRef.current) { - const focusableElements = modalRef.current.querySelectorAll( - 'button:not([disabled]), [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' - ); - const first = focusableElements[0] as HTMLElement; - const last = focusableElements[focusableElements.length - 1] as HTMLElement; - - if (e.shiftKey && document.activeElement === first) { - e.preventDefault(); - last.focus(); - } else if (!e.shiftKey && document.activeElement === last) { - e.preventDefault(); - first.focus(); - } - } - }; - - document.addEventListener('keydown', handleKeyDown); - document.body.style.overflow = 'hidden'; - - return () => { - document.removeEventListener('keydown', handleKeyDown); - document.body.style.overflow = ''; - }; - }, [isOpen, handleClose]); - - if (!isOpen || !mounted) return null; - - return createPortal( -
e.target === e.currentTarget && handleClose()} - role="dialog" - aria-modal="true" - aria-labelledby="early-exit-title" - > -
- {/* Header - Consistency with Details modal */} -
-
-
- -
-
-

- Early Exit Warning -

-

- This action is irreversible and carries penalties. -

-
-
- -
- - {/* 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} -
- )} - - - - - - - - - - - {/* Commitment ID */} - - - - - {/* Before Early Exit */} - - - - - {/* Penalty Rate */} - - - - - {/* Penalty Deduction */} - - - - - {/* After Early Exit */} - - - - - -
Financial breakdown of early exit penalty and final refund amount
ItemValue
Commitment ID - {commitmentId.slice(0, 8)}...{commitmentId.slice(-6)} -
Before Early Exit (Committed Amount) - {originalAmount} -
Penalty Rate - {penaltyPercent} -
Penalty Deduction - -{penaltyAmount} -
After Early Exit (Net Refund) - {netReceiveAmount} -
- - {/* Important Notice Block */} -
-
- - Important consequences -
-
    - {[ - 'You will lose the penalty amount shown above immediately.', - 'The commitment will be recorded as an Early Exit on-chain.', - 'This action cannot be reversed or modified.', - 'You forfeit future yield and continue to hold reduced value.' - ].map((text, i) => ( -
  • -
    - {text} -
  • - ))} -
-
- - {/* Acknowledgment Checkbox */} - - -
- - setConfirmationInput(e.target.value)} - className="w-full rounded-2xl border border-white/10 bg-[#050505] px-4 py-3 text-white placeholder:text-white/30 outline-none focus:border-[#FF8A04] focus:ring-2 focus:ring-[#FF8A04]/20" - placeholder="Enter commitment ID exactly" - autoComplete="off" - /> -

- Confirming this action requires the exact commitment ID: {commitmentId} -

-
- - {/* Action Buttons - Standardized placement */} -
- - -
-
-
-
, - document.body - ) -} +'use client'; + +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { AlertTriangle, X, Info } from 'lucide-react'; + +export interface CommitmentEarlyExitModalProps { + isOpen: boolean; + commitmentId: string; + originalAmount: string; + penaltyPercent: string; + penaltyAmount: string; + netReceiveAmount: string; + hasAcknowledged: boolean; + isPreviewLoading?: boolean; + previewError?: string | null; + onChangeAcknowledged: (value: boolean) => void; + onCancel: () => void; + onConfirm: () => void; + onClose?: () => void; +} + +function formatScreenReaderText(val: string): string { + return val + .replace(/\bXLM\b/gi, 'Stellar Lumens') + .replace(/\bUSDC\b/gi, 'USD Coin') + .replace(/%/g, ' percent') + .replace(/-/g, 'minus ') +} + +export default function CommitmentEarlyExitModal({ + isOpen, + commitmentId, + originalAmount, + penaltyPercent, + penaltyAmount, + netReceiveAmount, + hasAcknowledged, + isPreviewLoading = false, + previewError = null, + onChangeAcknowledged, + onCancel, + onConfirm, + onClose, +}: CommitmentEarlyExitModalProps) { + const modalRef = useRef(null); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + return () => setMounted(false); + }, []); + + const [confirmationInput, setConfirmationInput] = useState('') + const hasTypedConfirmation = confirmationInput.trim() === commitmentId + const canConfirm = hasAcknowledged && hasTypedConfirmation && !isPreviewLoading + + const handleClose = useCallback(() => { + (onClose ?? onCancel)(); + }, [onClose, onCancel]); + + useEffect(() => { + if (!isOpen) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') handleClose(); + + // Focus trap + if (e.key === 'Tab' && modalRef.current) { + const focusableElements = modalRef.current.querySelectorAll( + 'button:not([disabled]), [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ); + const first = focusableElements[0] as HTMLElement; + const last = focusableElements[focusableElements.length - 1] as HTMLElement; + + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + } + }; + + document.addEventListener('keydown', handleKeyDown); + document.body.style.overflow = 'hidden'; + + return () => { + document.removeEventListener('keydown', handleKeyDown); + document.body.style.overflow = ''; + }; + }, [isOpen, handleClose]); + + if (!isOpen || !mounted) return null; + + return createPortal( +
e.target === e.currentTarget && handleClose()} + role="dialog" + aria-modal="true" + aria-labelledby="early-exit-title" + > +
+ {/* Header - Consistency with Details modal */} +
+
+
+ +
+
+

+ Early Exit Warning +

+

+ This action is irreversible and carries penalties. +

+
+
+ +
+ + {/* 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} +
+ )} + + + + + + + + + + + {/* Commitment ID */} + + + + + {/* Before Early Exit */} + + + + + {/* Penalty Rate */} + + + + + {/* Penalty Deduction */} + + + + + {/* After Early Exit */} + + + + + +
Financial breakdown of early exit penalty and final refund amount
ItemValue
Commitment ID + {commitmentId.slice(0, 8)}...{commitmentId.slice(-6)} +
Before Early Exit (Committed Amount) + {originalAmount} +
Penalty Rate + {penaltyPercent} +
Penalty Deduction + -{penaltyAmount} +
After Early Exit (Net Refund) + {netReceiveAmount} +
+ + {/* Important Notice Block */} +
+
+ + Important consequences +
+
    + {[ + 'You will lose the penalty amount shown above immediately.', + 'The commitment will be recorded as an Early Exit on-chain.', + 'This action cannot be reversed or modified.', + 'You forfeit future yield and continue to hold reduced value.' + ].map((text, i) => ( +
  • +
    + {text} +
  • + ))} +
+
+ + {/* Acknowledgment Checkbox */} + + +
+ + setConfirmationInput(e.target.value)} + className="w-full rounded-2xl border border-white/10 bg-[#050505] px-4 py-3 text-white placeholder:text-white/30 outline-none focus:border-[#FF8A04] focus:ring-2 focus:ring-[#FF8A04]/20" + placeholder="Enter commitment ID exactly" + autoComplete="off" + /> +

+ Confirming this action requires the exact commitment ID: {commitmentId} +

+
+ + {/* Action Buttons - Standardized placement */} +
+ + +
+
+
+
, + document.body + ) +} From ee1b13349fb6f089ec0622d98dfc3909da77d761 Mon Sep 17 00:00:00 2001 From: xx7412421-cloud Date: Sun, 21 Jun 2026 19:32:18 +0800 Subject: [PATCH 08/10] chore: normalize early exit preview file endings From b7ae67591f2fb98266d82585d28c12762133b241 Mon Sep 17 00:00:00 2001 From: xx7412421-cloud Date: Sun, 21 Jun 2026 19:32:19 +0800 Subject: [PATCH 09/10] chore: normalize early exit preview file endings --- .../CommitmentEarlyExitModal.test.tsx | 606 +++++++++--------- 1 file changed, 303 insertions(+), 303 deletions(-) diff --git a/tests/components/CommitmentEarlyExitModal.test.tsx b/tests/components/CommitmentEarlyExitModal.test.tsx index 1b17ae5..df65371 100644 --- a/tests/components/CommitmentEarlyExitModal.test.tsx +++ b/tests/components/CommitmentEarlyExitModal.test.tsx @@ -1,303 +1,303 @@ -// @vitest-environment happy-dom -import React from 'react'; -import { describe, it, expect, vi } from 'vitest'; -import { render, screen, fireEvent } from '@testing-library/react'; -import '@testing-library/jest-dom'; -import CommitmentEarlyExitModal from '@/components/CommitmentEarlyExitModal/CommitmentEarlyExitModal'; - -describe('CommitmentEarlyExitModal', () => { - const defaultProps = { - isOpen: true, - commitmentId: 'CMT-TEST123', - originalAmount: '50,000 XLM', - penaltyPercent: '2%', - penaltyAmount: '1,000 XLM', - netReceiveAmount: '49,000 XLM', - hasAcknowledged: false, - onChangeAcknowledged: vi.fn(), - onCancel: vi.fn(), - onConfirm: vi.fn(), - onClose: vi.fn(), - }; - - it('renders nothing when isOpen is false', () => { - const { container } = render( - - ); - expect(container.firstChild).toBeNull(); - }); - - it('renders the modal header and informational notice when open', () => { - render(); - - // Header check - expect(screen.getByText('Early Exit Warning')).toBeInTheDocument(); - expect(screen.getByText('This action is irreversible and carries penalties.')).toBeInTheDocument(); - - // Notice check - expect(screen.getByText('Important consequences')).toBeInTheDocument(); - expect(screen.getByText('You will lose the penalty amount shown above immediately.')).toBeInTheDocument(); - }); - - describe('accessibility and semantic markup', () => { - it('contains a semantic table with a descriptive screen-reader caption', () => { - render(); - - const table = screen.getByRole('table'); - expect(table).toBeInTheDocument(); - - // Caption check - const caption = table.querySelector('caption'); - expect(caption).toBeInTheDocument(); - expect(caption).toHaveTextContent('Financial breakdown of early exit penalty and final refund amount'); - }); - - it('has proper scope attributes on table headers for row and col structure', () => { - render(); - - const table = screen.getByRole('table'); - - // Column headers - const colHeaders = table.querySelectorAll('th[scope="col"]'); - expect(colHeaders.length).toBe(2); - expect(colHeaders[0]).toHaveTextContent('Item'); - expect(colHeaders[1]).toHaveTextContent('Value'); - - // Row headers - const rowHeaders = table.querySelectorAll('th[scope="row"]'); - expect(rowHeaders.length).toBe(5); - expect(rowHeaders[0]).toHaveTextContent('Commitment ID'); - expect(rowHeaders[1]).toHaveTextContent('Before Early Exit (Committed Amount)'); - expect(rowHeaders[2]).toHaveTextContent('Penalty Rate'); - expect(rowHeaders[3]).toHaveTextContent('Penalty Deduction'); - expect(rowHeaders[4]).toHaveTextContent('After Early Exit (Net Refund)'); - }); - - it('applies accessible screen reader labels spelling out units and currency codes', () => { - render(); - - // Check original amount cell has aria-label spelling out XLM - const originalAmountCell = screen.getByLabelText('Committed amount: 50,000 Stellar Lumens'); - expect(originalAmountCell).toBeInTheDocument(); - expect(originalAmountCell).toHaveTextContent('50,000 XLM'); - - // Check penalty percentage cell has aria-label spelling out percent - const penaltyPercentCell = screen.getByLabelText('Penalty rate: 2 percent'); - expect(penaltyPercentCell).toBeInTheDocument(); - expect(penaltyPercentCell).toHaveTextContent('2%'); - - // Check penalty amount cell has aria-label spelling out negative prefix and XLM - const penaltyAmountCell = screen.getByLabelText('Penalty deduction: minus 1,000 Stellar Lumens'); - expect(penaltyAmountCell).toBeInTheDocument(); - expect(penaltyAmountCell).toHaveTextContent('-1,000 XLM'); - - // Check net refund cell has aria-label spelling out XLM - const netRefundCell = screen.getByLabelText('Net refund amount: 49,000 Stellar Lumens'); - expect(netRefundCell).toBeInTheDocument(); - expect(netRefundCell).toHaveTextContent('49,000 XLM'); - }); - }); - - describe('user interactions and confirmation validation', () => { - it('disables the confirm button by default', () => { - render(); - - const confirmButton = screen.getByRole('button', { name: /Confirm Early Exit/i }); - expect(confirmButton).toBeDisabled(); - }); - - it('triggers onChangeAcknowledged callback when clicking acknowledgment checkbox', () => { - const onChangeAcknowledged = vi.fn(); - render( - - ); - - const checkbox = screen.getByRole('checkbox'); - fireEvent.click(checkbox); - - expect(onChangeAcknowledged).toHaveBeenCalledWith(true); - }); - - it('enables the confirm button when both acknowledgement and commitment ID typing are satisfied', () => { - const { rerender } = render( - - ); - - const input = screen.getByPlaceholderText(/Enter commitment ID exactly/i); - const confirmButton = screen.getByRole('button', { name: /Confirm Early Exit/i }); - - // Action 1: Type the correct ID - fireEvent.change(input, { target: { value: 'CMT-TEST123' } }); - expect(confirmButton).toBeDisabled(); // still disabled because hasAcknowledged is false - - // Action 2: Receive acknowledgment prop as true - rerender(); - - // Type matching ID again since input value state is local to render lifecycle - fireEvent.change(screen.getByPlaceholderText(/Enter commitment ID exactly/i), { - target: { value: 'CMT-TEST123' }, - }); - - expect(screen.getByRole('button', { name: /Confirm Early Exit/i })).not.toBeDisabled(); - }); - - it('remains disabled if user typed the wrong commitment ID', () => { - render(); - - const input = screen.getByPlaceholderText(/Enter commitment ID exactly/i); - const confirmButton = screen.getByRole('button', { name: /Confirm Early Exit/i }); - - fireEvent.change(input, { target: { value: 'WRONG-ID-999' } }); - - expect(confirmButton).toBeDisabled(); - }); - - it('calls onCancel or onClose when appropriate buttons are clicked', () => { - const onCancel = vi.fn(); - const onClose = vi.fn(); - - render( - - ); - - // Cancel button click - fireEvent.click(screen.getByRole('button', { name: /Cancel/i })); - expect(onCancel).toHaveBeenCalled(); - - // Close button (X) click - fireEvent.click(screen.getByRole('button', { name: /Close modal/i })); - expect(onClose).toHaveBeenCalled(); - }); - - it('calls onConfirm when confirm button is clicked', () => { - const onConfirm = vi.fn(); - render( - - ); - - const input = screen.getByPlaceholderText(/Enter commitment ID exactly/i); - fireEvent.change(input, { target: { value: 'CMT-TEST123' } }); - - const confirmButton = screen.getByRole('button', { name: /Confirm Early Exit/i }); - fireEvent.click(confirmButton); - - 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', () => { - it('asserts correct rendering and labels for Safe (2%) tier calculations', () => { - render( - - ); - - // Value text matchers - expect(screen.getByLabelText('Committed amount: 50,000 Stellar Lumens')).toHaveTextContent('50,000 XLM'); - expect(screen.getByLabelText('Penalty rate: 2 percent')).toHaveTextContent('2%'); - expect(screen.getByLabelText('Penalty deduction: minus 1,000 Stellar Lumens')).toHaveTextContent('-1,000 XLM'); - expect(screen.getByLabelText('Net refund amount: 49,000 Stellar Lumens')).toHaveTextContent('49,000 XLM'); - }); - - it('asserts correct rendering and labels for Balanced (3%) tier calculations', () => { - render( - - ); - - // Value text matchers - expect(screen.getByLabelText('Committed amount: 100,000 USD Coin')).toHaveTextContent('100,000 USDC'); - expect(screen.getByLabelText('Penalty rate: 3 percent')).toHaveTextContent('3%'); - expect(screen.getByLabelText('Penalty deduction: minus 3,000 USD Coin')).toHaveTextContent('-3,000 USDC'); - expect(screen.getByLabelText('Net refund amount: 97,000 USD Coin')).toHaveTextContent('97,000 USDC'); - }); - - it('asserts correct rendering and labels for Aggressive (5%) tier calculations', () => { - render( - - ); - - // Value text matchers - expect(screen.getByLabelText('Committed amount: 250,000 Stellar Lumens')).toHaveTextContent('250,000 XLM'); - expect(screen.getByLabelText('Penalty rate: 5 percent')).toHaveTextContent('5%'); - 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'); - }); - }); -}); +// @vitest-environment happy-dom +import React from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import CommitmentEarlyExitModal from '@/components/CommitmentEarlyExitModal/CommitmentEarlyExitModal'; + +describe('CommitmentEarlyExitModal', () => { + const defaultProps = { + isOpen: true, + commitmentId: 'CMT-TEST123', + originalAmount: '50,000 XLM', + penaltyPercent: '2%', + penaltyAmount: '1,000 XLM', + netReceiveAmount: '49,000 XLM', + hasAcknowledged: false, + onChangeAcknowledged: vi.fn(), + onCancel: vi.fn(), + onConfirm: vi.fn(), + onClose: vi.fn(), + }; + + it('renders nothing when isOpen is false', () => { + const { container } = render( + + ); + expect(container.firstChild).toBeNull(); + }); + + it('renders the modal header and informational notice when open', () => { + render(); + + // Header check + expect(screen.getByText('Early Exit Warning')).toBeInTheDocument(); + expect(screen.getByText('This action is irreversible and carries penalties.')).toBeInTheDocument(); + + // Notice check + expect(screen.getByText('Important consequences')).toBeInTheDocument(); + expect(screen.getByText('You will lose the penalty amount shown above immediately.')).toBeInTheDocument(); + }); + + describe('accessibility and semantic markup', () => { + it('contains a semantic table with a descriptive screen-reader caption', () => { + render(); + + const table = screen.getByRole('table'); + expect(table).toBeInTheDocument(); + + // Caption check + const caption = table.querySelector('caption'); + expect(caption).toBeInTheDocument(); + expect(caption).toHaveTextContent('Financial breakdown of early exit penalty and final refund amount'); + }); + + it('has proper scope attributes on table headers for row and col structure', () => { + render(); + + const table = screen.getByRole('table'); + + // Column headers + const colHeaders = table.querySelectorAll('th[scope="col"]'); + expect(colHeaders.length).toBe(2); + expect(colHeaders[0]).toHaveTextContent('Item'); + expect(colHeaders[1]).toHaveTextContent('Value'); + + // Row headers + const rowHeaders = table.querySelectorAll('th[scope="row"]'); + expect(rowHeaders.length).toBe(5); + expect(rowHeaders[0]).toHaveTextContent('Commitment ID'); + expect(rowHeaders[1]).toHaveTextContent('Before Early Exit (Committed Amount)'); + expect(rowHeaders[2]).toHaveTextContent('Penalty Rate'); + expect(rowHeaders[3]).toHaveTextContent('Penalty Deduction'); + expect(rowHeaders[4]).toHaveTextContent('After Early Exit (Net Refund)'); + }); + + it('applies accessible screen reader labels spelling out units and currency codes', () => { + render(); + + // Check original amount cell has aria-label spelling out XLM + const originalAmountCell = screen.getByLabelText('Committed amount: 50,000 Stellar Lumens'); + expect(originalAmountCell).toBeInTheDocument(); + expect(originalAmountCell).toHaveTextContent('50,000 XLM'); + + // Check penalty percentage cell has aria-label spelling out percent + const penaltyPercentCell = screen.getByLabelText('Penalty rate: 2 percent'); + expect(penaltyPercentCell).toBeInTheDocument(); + expect(penaltyPercentCell).toHaveTextContent('2%'); + + // Check penalty amount cell has aria-label spelling out negative prefix and XLM + const penaltyAmountCell = screen.getByLabelText('Penalty deduction: minus 1,000 Stellar Lumens'); + expect(penaltyAmountCell).toBeInTheDocument(); + expect(penaltyAmountCell).toHaveTextContent('-1,000 XLM'); + + // Check net refund cell has aria-label spelling out XLM + const netRefundCell = screen.getByLabelText('Net refund amount: 49,000 Stellar Lumens'); + expect(netRefundCell).toBeInTheDocument(); + expect(netRefundCell).toHaveTextContent('49,000 XLM'); + }); + }); + + describe('user interactions and confirmation validation', () => { + it('disables the confirm button by default', () => { + render(); + + const confirmButton = screen.getByRole('button', { name: /Confirm Early Exit/i }); + expect(confirmButton).toBeDisabled(); + }); + + it('triggers onChangeAcknowledged callback when clicking acknowledgment checkbox', () => { + const onChangeAcknowledged = vi.fn(); + render( + + ); + + const checkbox = screen.getByRole('checkbox'); + fireEvent.click(checkbox); + + expect(onChangeAcknowledged).toHaveBeenCalledWith(true); + }); + + it('enables the confirm button when both acknowledgement and commitment ID typing are satisfied', () => { + const { rerender } = render( + + ); + + const input = screen.getByPlaceholderText(/Enter commitment ID exactly/i); + const confirmButton = screen.getByRole('button', { name: /Confirm Early Exit/i }); + + // Action 1: Type the correct ID + fireEvent.change(input, { target: { value: 'CMT-TEST123' } }); + expect(confirmButton).toBeDisabled(); // still disabled because hasAcknowledged is false + + // Action 2: Receive acknowledgment prop as true + rerender(); + + // Type matching ID again since input value state is local to render lifecycle + fireEvent.change(screen.getByPlaceholderText(/Enter commitment ID exactly/i), { + target: { value: 'CMT-TEST123' }, + }); + + expect(screen.getByRole('button', { name: /Confirm Early Exit/i })).not.toBeDisabled(); + }); + + it('remains disabled if user typed the wrong commitment ID', () => { + render(); + + const input = screen.getByPlaceholderText(/Enter commitment ID exactly/i); + const confirmButton = screen.getByRole('button', { name: /Confirm Early Exit/i }); + + fireEvent.change(input, { target: { value: 'WRONG-ID-999' } }); + + expect(confirmButton).toBeDisabled(); + }); + + it('calls onCancel or onClose when appropriate buttons are clicked', () => { + const onCancel = vi.fn(); + const onClose = vi.fn(); + + render( + + ); + + // Cancel button click + fireEvent.click(screen.getByRole('button', { name: /Cancel/i })); + expect(onCancel).toHaveBeenCalled(); + + // Close button (X) click + fireEvent.click(screen.getByRole('button', { name: /Close modal/i })); + expect(onClose).toHaveBeenCalled(); + }); + + it('calls onConfirm when confirm button is clicked', () => { + const onConfirm = vi.fn(); + render( + + ); + + const input = screen.getByPlaceholderText(/Enter commitment ID exactly/i); + fireEvent.change(input, { target: { value: 'CMT-TEST123' } }); + + const confirmButton = screen.getByRole('button', { name: /Confirm Early Exit/i }); + fireEvent.click(confirmButton); + + 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', () => { + it('asserts correct rendering and labels for Safe (2%) tier calculations', () => { + render( + + ); + + // Value text matchers + expect(screen.getByLabelText('Committed amount: 50,000 Stellar Lumens')).toHaveTextContent('50,000 XLM'); + expect(screen.getByLabelText('Penalty rate: 2 percent')).toHaveTextContent('2%'); + expect(screen.getByLabelText('Penalty deduction: minus 1,000 Stellar Lumens')).toHaveTextContent('-1,000 XLM'); + expect(screen.getByLabelText('Net refund amount: 49,000 Stellar Lumens')).toHaveTextContent('49,000 XLM'); + }); + + it('asserts correct rendering and labels for Balanced (3%) tier calculations', () => { + render( + + ); + + // Value text matchers + expect(screen.getByLabelText('Committed amount: 100,000 USD Coin')).toHaveTextContent('100,000 USDC'); + expect(screen.getByLabelText('Penalty rate: 3 percent')).toHaveTextContent('3%'); + expect(screen.getByLabelText('Penalty deduction: minus 3,000 USD Coin')).toHaveTextContent('-3,000 USDC'); + expect(screen.getByLabelText('Net refund amount: 97,000 USD Coin')).toHaveTextContent('97,000 USDC'); + }); + + it('asserts correct rendering and labels for Aggressive (5%) tier calculations', () => { + render( + + ); + + // Value text matchers + expect(screen.getByLabelText('Committed amount: 250,000 Stellar Lumens')).toHaveTextContent('250,000 XLM'); + expect(screen.getByLabelText('Penalty rate: 5 percent')).toHaveTextContent('5%'); + 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'); + }); + }); +}); From 73c0a81d8b0e370826eeb3010f0dee98abcb876f Mon Sep 17 00:00:00 2001 From: xx7412421-cloud Date: Sun, 21 Jun 2026 19:32:20 +0800 Subject: [PATCH 10/10] chore: normalize early exit preview file endings