diff --git a/docs/uiux/governance-vote-receipt.md b/docs/uiux/governance-vote-receipt.md new file mode 100644 index 0000000..2ae17d5 --- /dev/null +++ b/docs/uiux/governance-vote-receipt.md @@ -0,0 +1,36 @@ +# Governance Vote Receipt Component + +## Overview +A confirmation modal displayed after a user successfully casts a vote on a governance proposal. + +## Purpose +Provides the user with a permanent receipt of their action, including essential on-chain information for verification and sharing. + +## Props +* `isOpen` (boolean) +* `onClose` (fn) +* `proposalTitle` (string) +* `voteChoice` ('For' | 'Against' | 'Abstain') +* `timestamp` (string) +* `txHash` (string) +* `status` ('pending' | 'confirmed' | 'failed') + +## Accessibility +* Implements WAI-ARIA `dialog` pattern. +* Keyboard focus trapping and escape-key handling are managed. +* Proper labeling (`aria-labelledby`, `aria-describedby`). +* Focus states on copy and explorer link. + +## Usage +Import from `src/components/GovernanceVoteReceiptModal.tsx`. +```tsx + setIsModalOpen(false)} + proposalTitle="Proposal Title" + voteChoice="For" + timestamp="2026-08-07 10:00:00 UTC" + txHash="0x..." + status="confirmed" +/> +``` diff --git a/docs/uiux/ux254-redemption-post-close-banner.md b/docs/uiux/ux254-redemption-post-close-banner.md new file mode 100644 index 0000000..849c834 --- /dev/null +++ b/docs/uiux/ux254-redemption-post-close-banner.md @@ -0,0 +1,30 @@ +# Redemption Window Post-Close Summary Banner + +This component provides a summary banner for investors after a redemption window has closed. + +## Design + +The banner includes: +- **Heading**: "Redemption Window Closed" +- **Summary Chips**: Displays total redeemed and the user's specific share. +- **CTA**: "View Detailed Report" link. +- **Auto-dismissal**: The banner automatically dismisses after 30 days from the closure date. + +## Accessibility + +- WCAG 2.1 AA compliant. +- Responsive layout: chips stack on mobile. +- `role="region"` with `aria-labelledby` for screen reader announcement. +- Keyboard accessible dismissal. + +## Usage + +```tsx + ...} + closedAt="2026-07-28T12:00:00Z" +/> +``` diff --git a/src/components/GovernanceVoteReceiptModal.test.tsx b/src/components/GovernanceVoteReceiptModal.test.tsx new file mode 100644 index 0000000..1396967 --- /dev/null +++ b/src/components/GovernanceVoteReceiptModal.test.tsx @@ -0,0 +1,45 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import { GovernanceVoteReceiptModal } from './GovernanceVoteReceiptModal'; + +describe('GovernanceVoteReceiptModal', () => { + const defaultProps = { + isOpen: true, + onClose: vi.fn(), + proposalTitle: 'Test Proposal', + voteChoice: 'For' as const, + timestamp: '2026-08-07 10:00:00 UTC', + txHash: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + status: 'confirmed' as const, + }; + + it('renders receipt details in an accessible dialog', () => { + render(); + + const dialog = screen.getByRole('dialog', { name: /vote cast successfully/i }); + expect(dialog).toBeInTheDocument(); + expect(dialog).toHaveAttribute('aria-modal', 'true'); + expect(screen.getByText('Test Proposal')).toBeInTheDocument(); + expect(screen.getByText('For')).toBeInTheDocument(); + expect(screen.getByText('2026-08-07 10:00:00 UTC')).toBeInTheDocument(); + expect(screen.getByText(/0x1234...5678/)).toBeInTheDocument(); + }); + + it('copies the transaction hash when the copy button is clicked', async () => { + const user = userEvent.setup(); + const writeTextMock = vi.fn().mockResolvedValue(undefined); + Object.assign(navigator, { + clipboard: { + writeText: writeTextMock, + }, + }); + + render(); + + await user.click(screen.getByRole('button', { name: /copy transaction hash/i })); + + expect(writeTextMock).toHaveBeenCalledWith(defaultProps.txHash); + expect(screen.getByText(/copied!/i)).toBeInTheDocument(); + }); +}); diff --git a/src/components/GovernanceVoteReceiptModal.tsx b/src/components/GovernanceVoteReceiptModal.tsx new file mode 100644 index 0000000..1c59ae6 --- /dev/null +++ b/src/components/GovernanceVoteReceiptModal.tsx @@ -0,0 +1,133 @@ +import React, { useEffect, useId, useRef, useState } from 'react'; +import { Copy, ExternalLink, CheckCircle2, X, AlertCircle } from 'lucide-react'; +import { Button } from './Button'; + +interface GovernanceVoteReceiptModalProps { + isOpen: boolean; + onClose: () => void; + proposalTitle: string; + voteChoice: 'For' | 'Against' | 'Abstain'; + timestamp: string; + txHash: string; + status: 'pending' | 'confirmed' | 'failed'; +} + +export const GovernanceVoteReceiptModal: React.FC = ({ + isOpen, + onClose, + proposalTitle, + voteChoice, + timestamp, + txHash, + status, +}) => { + const dialogRef = useRef(null); + const titleId = useId(); + const descriptionId = useId(); + const [copyStatus, setCopyStatus] = useState<'idle' | 'copied'>('idle'); + + useEffect(() => { + if (isOpen && dialogRef.current) { + dialogRef.current.focus(); + } + }, [isOpen]); + + const handleCopyHash = () => { + navigator.clipboard.writeText(txHash); + setCopyStatus('copied'); + setTimeout(() => setCopyStatus('idle'), 2000); + }; + + const truncatedHash = `${txHash.substring(0, 6)}...${txHash.substring(txHash.length - 4)}`; + + if (!isOpen) return null; + + return ( +
+
event.stopPropagation()} + > +
+

+ Vote Cast Successfully +

+ +
+ +

+ Your vote has been submitted. Here is your receipt. +

+ +
+
+
Proposal
+
{proposalTitle}
+
+ +
+
+
Vote
+
{voteChoice}
+
+
+
Time
+
{timestamp}
+
+
+ +
+
Transaction Hash
+
+ {truncatedHash} +
+ + + + +
+
+ {copyStatus === 'copied' && ( + Copied! + )} +
+
+ +
+ +
+
+
+ ); +}; diff --git a/src/components/RedemptionPostCloseBanner.test.tsx b/src/components/RedemptionPostCloseBanner.test.tsx new file mode 100644 index 0000000..af8d49f --- /dev/null +++ b/src/components/RedemptionPostCloseBanner.test.tsx @@ -0,0 +1,43 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { RedemptionPostCloseBanner } from './RedemptionPostCloseBanner'; +import { vi, describe, it, expect } from 'vitest'; + +describe('RedemptionPostCloseBanner', () => { + const mockProps = { + totalRedeemed: 100000, + userShare: 5000, + reportLink: '/report', + onDismiss: vi.fn(), + closedAt: new Date().toISOString(), + }; + + it('renders correctly with participation', () => { + render(); + expect(screen.getByText(/Redemption Window Closed/i)).toBeDefined(); + expect(screen.getByText(/Total Redeemed: \$100,000/i)).toBeDefined(); + expect(screen.getByText(/Your Share: \$5,000/i)).toBeDefined(); + expect(screen.getByText(/View Detailed Report/i)).toBeDefined(); + }); + + it('renders correctly without participation', () => { + render(); + expect(screen.getByText(/You did not participate in this window/i)).toBeDefined(); + expect(screen.queryByText(/Total Redeemed/i)).toBeNull(); + }); + + it('calls onDismiss when dismissed', () => { + render(); + const dismissBtn = screen.getByLabelText(/Dismiss banner/i); + fireEvent.click(dismissBtn); + expect(mockProps.onDismiss).toHaveBeenCalled(); + }); + + it('auto-dismisses after 30 days', () => { + const thirtyOneDaysAgo = new Date(); + thirtyOneDaysAgo.setDate(thirtyOneDaysAgo.getDate() - 31); + + render(); + expect(screen.queryByText(/Redemption Window Closed/i)).toBeNull(); + expect(mockProps.onDismiss).toHaveBeenCalled(); + }); +}); diff --git a/src/components/RedemptionPostCloseBanner.tsx b/src/components/RedemptionPostCloseBanner.tsx new file mode 100644 index 0000000..5c4736d --- /dev/null +++ b/src/components/RedemptionPostCloseBanner.tsx @@ -0,0 +1,83 @@ +import React, { useState, useEffect } from 'react'; +import { FileText, X } from 'lucide-react'; + +interface RedemptionPostCloseBannerProps { + totalRedeemed: number; + userShare: number; + reportLink: string; + onDismiss: () => void; + closedAt: string; // ISO date string +} + +export const RedemptionPostCloseBanner: React.FC = ({ + totalRedeemed, + userShare, + reportLink, + onDismiss, + closedAt, +}) => { + const [isVisible, setIsVisible] = useState(true); + + useEffect(() => { + const closedDate = new Date(closedAt); + const now = new Date(); + const thirtyDaysInMs = 30 * 24 * 60 * 60 * 1000; + + if (now.getTime() - closedDate.getTime() > thirtyDaysInMs) { + setIsVisible(false); + onDismiss(); + } + }, [closedAt, onDismiss]); + + if (!isVisible) return null; + + const hasParticipated = userShare > 0; + + return ( +
+
+

+ Redemption Window Closed +

+

+ {hasParticipated + ? "The redemption window has closed. Here is your summary." + : "The redemption window has closed. You did not participate in this window." + } +

+
+ + {hasParticipated && ( +
+
+ Total Redeemed: ${totalRedeemed.toLocaleString()} +
+
+ Your Share: ${userShare.toLocaleString()} +
+
+ )} + + +
+ ); +}; diff --git a/src/pages/DistributionDashboard.tsx b/src/pages/DistributionDashboard.tsx index 7b92fd9..91f1516 100644 --- a/src/pages/DistributionDashboard.tsx +++ b/src/pages/DistributionDashboard.tsx @@ -15,9 +15,7 @@ import type { ErrorRateDataPoint } from '../components/ErrorRateSparklineTile/Er import { GovernanceDelegation } from '../components/GovernanceDelegation/GovernanceDelegation'; import { RevenuePayoutChart, RevenuePayoutDataPoint } from '../components/RevenuePayoutChart/RevenuePayoutChart'; import { BlacklistBulkRemoveConfirm, BlacklistEntry } from '../components/BlacklistBulkRemoveConfirm/BlacklistBulkRemoveConfirm'; -import { GovernanceProposalDetail, type ProposalData } from '../components/designSystem/GovernanceProposalDetail'; -import { UploadQueue } from '../components/UploadQueue/UploadQueue'; -import { useUploadQueue, type Uploader } from '../hooks/useUploadQueue'; +import { RedemptionPostCloseBanner } from '../components/RedemptionPostCloseBanner'; interface ExtendedPayoutDetail extends PayoutDetail { region: string; @@ -242,7 +240,19 @@ export const DistributionDashboard: React.FC = () => { }; }); - + const { + queue, + addFiles, + removeFile, + retryFile, + uploadFiles, + clearComplete, + totalCount, + successCount, + errorCount, + uploadingCount, + overallProgress, + } = useUploadQueue(); const handleUploadAll = useCallback(() => { uploadFiles(mockUploader);