diff --git a/src/app/commitments/page.tsx b/src/app/commitments/page.tsx index 5bdd246..ffdd172 100644 --- a/src/app/commitments/page.tsx +++ b/src/app/commitments/page.tsx @@ -8,6 +8,8 @@ import MyCommitmentsFilters from '@/components/MyCommitmentsFilters/MyCommitment import MyCommitmentsGrid from '@/components/MyCommitmentsGrid' import MyCommitmentsGridSkeleton from '@/components/MyCommitmentsGridSkeleton' import CommitmentEarlyExitModal from '@/components/CommitmentEarlyExitModal/CommitmentEarlyExitModal' +import ExportCommitmentsModal from '@/components/export/ExportCommitmentsModal' +import { useWallet } from '@/hooks/useWallet' import { Commitment, CommitmentStats } from '@/types/commitment' import { listCommitments } from '@/lib/backend/mocks/contracts' import { fetchProtocolConstants, ProtocolConstants } from '@/utils/protocol' @@ -131,6 +133,7 @@ function getEarlyExitValues(originalAmount: string, asset: string, penaltyPercen export default function MyCommitments() { const router = useRouter() + const { address } = useWallet() // State const [searchQuery, setSearchQuery] = useState('') @@ -139,12 +142,13 @@ export default function MyCommitments() { 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 [isLoadingConstants, setIsLoadingConstants] = useState(true) + const [, setIsLoadingConstants] = useState(true) useEffect(() => { fetchProtocolConstants() @@ -189,7 +193,7 @@ export default function MyCommitments() { } return filtered - }, [searchQuery, statusFilter, typeFilter, sortBy]) + }, [commitmentsList, searchQuery, statusFilter, typeFilter, sortBy]) const commitmentForEarlyExit = commitmentsList.find((c) => c.id === earlyExitCommitmentId) const earlyExitSummary = useMemo(() => { @@ -256,6 +260,7 @@ export default function MyCommitments() { router.push('/')} onCreateNew={() => router.push('/create')} + onExport={() => setIsExportOpen(true)} /> {successMessage && ( @@ -288,7 +293,7 @@ export default function MyCommitments() { @@ -328,6 +333,12 @@ export default function MyCommitments() { onClose={closeEarlyExitModal} /> )} + + setIsExportOpen(false)} + ownerAddress={address} + /> ) } diff --git a/src/components/MyCommitmentsHeader.tsx b/src/components/MyCommitmentsHeader.tsx index 291081f..acc9545 100644 --- a/src/components/MyCommitmentsHeader.tsx +++ b/src/components/MyCommitmentsHeader.tsx @@ -2,13 +2,14 @@ import React from 'react'; import Link from 'next/link'; -import { ArrowLeft, Plus } from 'lucide-react'; +import { ArrowLeft, Download, Plus } from 'lucide-react'; interface MyCommitmentsHeaderProps { title?: string; subtitle?: string; onBack?: () => void; onCreateNew?: () => void; + onExport?: () => void; backHref?: string; createHref?: string; } @@ -18,6 +19,7 @@ const MyCommitmentsHeader: React.FC = ({ subtitle = 'View and manage all your liquidity commitments', onBack, onCreateNew, + onExport, backHref = '/', createHref = '/create', }) => { @@ -57,17 +59,31 @@ const MyCommitmentsHeader: React.FC = ({ - - - - + Create New Commitment - - +
+ {onExport ? ( + + ) : null} + + + + + + Create New Commitment + + +
); diff --git a/src/components/export/ExportCommitmentsModal.tsx b/src/components/export/ExportCommitmentsModal.tsx new file mode 100644 index 0000000..d9638af --- /dev/null +++ b/src/components/export/ExportCommitmentsModal.tsx @@ -0,0 +1,324 @@ +'use client'; + +import React, { useCallback, useEffect, useId, useRef, useState } from 'react'; +import { AlertCircle, CheckCircle2, Download, Loader2, X } from 'lucide-react'; + +type ExportStatus = 'idle' | 'loading' | 'success' | 'error'; + +interface ExportCommitmentsModalProps { + isOpen: boolean; + onClose: () => void; + ownerAddress?: string; + sessionToken?: string; + endpoint?: string; +} + +const STORED_TOKEN_KEYS = [ + 'commitlabs.sessionToken', + 'commitlabs:sessionToken', + 'sessionToken', +]; + +function getStoredSessionToken(): string | undefined { + if (typeof window === 'undefined') return undefined; + + for (const key of STORED_TOKEN_KEYS) { + const value = + window.sessionStorage.getItem(key) ?? + window.localStorage.getItem(key); + + if (value?.trim()) { + return value.trim(); + } + } + + return undefined; +} + +function getFilename(response: Response): string { + const contentDisposition = response.headers.get('content-disposition') ?? ''; + const match = contentDisposition.match(/filename="?([^";]+)"?/i); + return match?.[1] ?? 'commitments.csv'; +} + +function countDataRows(csv: string): number { + const lines = csv + .replace(/\r\n/g, '\n') + .split('\n') + .filter((line) => line.trim().length > 0); + + return Math.max(0, lines.length - 1); +} + +async function downloadCsv(blob: Blob, filename: string): Promise { + const href = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = href; + link.download = filename; + document.body.appendChild(link); + link.click(); + link.remove(); + window.URL.revokeObjectURL(href); +} + +function getExportErrorMessage(status: number): string { + if (status === 401) return 'Sign in again before exporting your commitments.'; + if (status === 403) return 'This export is only available for the connected owner address.'; + if (status === 429) return 'Too many export attempts. Wait a moment and try again.'; + return 'Export failed. Try again in a moment.'; +} + +const focusableSelector = [ + 'button:not([disabled])', + 'a[href]', + 'input:not([disabled])', + 'select:not([disabled])', + 'textarea:not([disabled])', + '[tabindex]:not([tabindex="-1"])', +].join(','); + +export default function ExportCommitmentsModal({ + isOpen, + onClose, + ownerAddress, + sessionToken, + endpoint = '/api/commitments/export', +}: ExportCommitmentsModalProps) { + const titleId = useId(); + const descriptionId = useId(); + const dialogRef = useRef(null); + const previousFocusRef = useRef(null); + const [status, setStatus] = useState('idle'); + const [message, setMessage] = useState(''); + + useEffect(() => { + if (!isOpen) return undefined; + + previousFocusRef.current = document.activeElement as HTMLElement | null; + setStatus('idle'); + setMessage(''); + + const focusDialog = () => { + dialogRef.current?.focus(); + }; + + if (typeof window.requestAnimationFrame === 'function') { + window.requestAnimationFrame(focusDialog); + } else { + window.setTimeout(focusDialog, 0); + } + + return () => { + previousFocusRef.current?.focus?.(); + }; + }, [isOpen]); + + const handleExport = useCallback(async () => { + const normalizedAddress = ownerAddress?.trim(); + const resolvedToken = sessionToken?.trim() || getStoredSessionToken(); + + if (!normalizedAddress) { + setStatus('error'); + setMessage('Connect a wallet before exporting commitments.'); + return; + } + + if (!resolvedToken) { + setStatus('error'); + setMessage('Sign in again before exporting your commitments.'); + return; + } + + setStatus('loading'); + setMessage(''); + + try { + const url = new URL(endpoint, window.location.origin); + url.searchParams.set('ownerAddress', normalizedAddress); + + const response = await fetch(url.toString(), { + method: 'GET', + headers: { + Authorization: `Bearer ${resolvedToken}`, + }, + }); + + if (!response.ok) { + setStatus('error'); + setMessage(getExportErrorMessage(response.status)); + return; + } + + const filename = getFilename(response); + const blob = await response.blob(); + const csv = await blob.text(); + const recordCount = countDataRows(csv); + const downloadableBlob = new Blob([csv], { type: 'text/csv;charset=utf-8' }); + + await downloadCsv(downloadableBlob, filename); + + setStatus('success'); + setMessage( + recordCount === 0 + ? 'Export ready. No commitment rows found, so a header-only CSV was downloaded.' + : `Export ready. ${recordCount} commitment${recordCount === 1 ? '' : 's'} downloaded as CSV.` + ); + } catch { + setStatus('error'); + setMessage('Export failed. Try again in a moment.'); + } + }, [endpoint, ownerAddress, sessionToken]); + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Escape' && status !== 'loading') { + onClose(); + return; + } + + if (event.key !== 'Tab') return; + + const dialog = dialogRef.current; + if (!dialog) return; + + const focusableElements = Array.from( + dialog.querySelectorAll(focusableSelector) + ); + + if (focusableElements.length === 0) return; + + const first = focusableElements[0]; + const last = focusableElements[focusableElements.length - 1]; + + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + }; + + if (!isOpen) return null; + + const isLoading = status === 'loading'; + + return ( +
+
+
+
+

+ Portfolio export +

+

+ Export commitment data +

+
+ +
+ +

+ Download a CSV snapshot for the connected owner address. Large portfolios may take a moment to prepare. +

+ +
+ + + + +
+ + + +
+
+ + {message ? ( +
+ {status === 'error' ? ( + + ) : ( + + )} + {message} +
+ ) : null} + +
+ + +
+
+
+ ); +} diff --git a/tests/components/ExportCommitmentsModal.test.tsx b/tests/components/ExportCommitmentsModal.test.tsx new file mode 100644 index 0000000..871102c --- /dev/null +++ b/tests/components/ExportCommitmentsModal.test.tsx @@ -0,0 +1,148 @@ +// @vitest-environment happy-dom + +import React from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import ExportCommitmentsModal from '@/components/export/ExportCommitmentsModal'; + +function renderModal(props: Partial> = {}) { + return render( + + ); +} + +describe('ExportCommitmentsModal', () => { + beforeEach(() => { + vi.restoreAllMocks(); + window.sessionStorage.clear(); + window.localStorage.clear(); + + vi.stubGlobal('fetch', vi.fn()); + Object.defineProperty(window.URL, 'createObjectURL', { + configurable: true, + value: vi.fn(() => 'blob:commitments'), + }); + Object.defineProperty(window.URL, 'revokeObjectURL', { + configurable: true, + value: vi.fn(), + }); + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}); + }); + + it('calls the export endpoint with the owner address and session token, then downloads the CSV', async () => { + vi.mocked(fetch).mockResolvedValue( + new Response('Commitment ID,Owner\r\ncommitment-1,GOWNERADDRESS\r\n', { + status: 200, + headers: { + 'content-disposition': 'attachment; filename="commitments.csv"', + 'content-type': 'text/csv; charset=utf-8', + }, + }) + ); + + renderModal(); + + fireEvent.click(screen.getByRole('button', { name: 'Export CSV' })); + + await waitFor(() => { + expect(fetch).toHaveBeenCalledWith( + 'http://localhost:3000/api/commitments/export?ownerAddress=GOWNERADDRESS', + { + method: 'GET', + headers: { + Authorization: 'Bearer session-token', + }, + } + ); + }); + + expect(HTMLAnchorElement.prototype.click).toHaveBeenCalled(); + expect(screen.getByText('Export ready. 1 commitment downloaded as CSV.')).toBeTruthy(); + }); + + it('uses a stored session token when one is available', async () => { + window.sessionStorage.setItem('commitlabs.sessionToken', 'stored-token'); + vi.mocked(fetch).mockResolvedValue( + new Response('Commitment ID,Owner\r\n', { + status: 200, + headers: { + 'content-disposition': 'attachment; filename="commitments.csv"', + 'content-type': 'text/csv; charset=utf-8', + }, + }) + ); + + renderModal({ sessionToken: undefined }); + + fireEvent.click(screen.getByRole('button', { name: 'Export CSV' })); + + await waitFor(() => { + expect(fetch).toHaveBeenCalledWith( + 'http://localhost:3000/api/commitments/export?ownerAddress=GOWNERADDRESS', + expect.objectContaining({ + headers: { + Authorization: 'Bearer stored-token', + }, + }) + ); + }); + }); + + it('reports an empty CSV export without treating it as a failure', async () => { + vi.mocked(fetch).mockResolvedValue( + new Response('Commitment ID,Owner\r\n', { + status: 200, + headers: { + 'content-disposition': 'attachment; filename="commitments.csv"', + 'content-type': 'text/csv; charset=utf-8', + }, + }) + ); + + renderModal(); + + fireEvent.click(screen.getByRole('button', { name: 'Export CSV' })); + + await screen.findByText( + 'Export ready. No commitment rows found, so a header-only CSV was downloaded.' + ); + }); + + it('shows a sign-in error before calling the endpoint when no session token exists', async () => { + renderModal({ sessionToken: undefined }); + + fireEvent.click(screen.getByRole('button', { name: 'Export CSV' })); + + const alert = await screen.findByRole('alert'); + expect(alert.textContent).toContain('Sign in again before exporting your commitments.'); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('closes with Escape when it is not preparing an export', () => { + const onClose = vi.fn(); + renderModal({ onClose }); + + fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' }); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('keeps keyboard focus inside the dialog', () => { + renderModal(); + + const dialog = screen.getByRole('dialog'); + const closeButton = screen.getByRole('button', { name: 'Close export dialog' }); + const exportButton = screen.getByRole('button', { name: 'Export CSV' }); + + exportButton.focus(); + fireEvent.keyDown(dialog, { key: 'Tab' }); + + expect(document.activeElement).toBe(closeButton); + }); +});