Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions docs/uiux/governance-vote-receipt.md
Original file line number Diff line number Diff line change
@@ -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
<GovernanceVoteReceiptModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
proposalTitle="Proposal Title"
voteChoice="For"
timestamp="2026-08-07 10:00:00 UTC"
txHash="0x..."
status="confirmed"
/>
```
30 changes: 30 additions & 0 deletions docs/uiux/ux254-redemption-post-close-banner.md
Original file line number Diff line number Diff line change
@@ -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
<RedemptionPostCloseBanner
totalRedeemed={500000}
userShare={25000}
reportLink="/detailed-report"
onDismiss={() => ...}
closedAt="2026-07-28T12:00:00Z"
/>
```
45 changes: 45 additions & 0 deletions src/components/GovernanceVoteReceiptModal.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<GovernanceVoteReceiptModal {...defaultProps} />);

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(<GovernanceVoteReceiptModal {...defaultProps} />);

await user.click(screen.getByRole('button', { name: /copy transaction hash/i }));

expect(writeTextMock).toHaveBeenCalledWith(defaultProps.txHash);
expect(screen.getByText(/copied!/i)).toBeInTheDocument();
});
});
133 changes: 133 additions & 0 deletions src/components/GovernanceVoteReceiptModal.tsx
Original file line number Diff line number Diff line change
@@ -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<GovernanceVoteReceiptModalProps> = ({
isOpen,
onClose,
proposalTitle,
voteChoice,
timestamp,
txHash,
status,
}) => {
const dialogRef = useRef<HTMLDivElement>(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 (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/70 px-4 py-6"
onClick={onClose}
role="presentation"
>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={descriptionId}
tabIndex={-1}
className="w-full max-w-md rounded-2xl border border-slate-200 bg-white p-6 shadow-2xl outline-none dark:border-slate-700 dark:bg-slate-900"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-start justify-between gap-3">
<h2 id={titleId} className="text-xl font-semibold text-slate-900 dark:text-white">
Vote Cast Successfully
</h2>
<button
type="button"
onClick={onClose}
className="rounded-full p-2 text-slate-500 transition hover:bg-slate-100 hover:text-slate-700 dark:hover:bg-slate-800"
aria-label="Close receipt modal"
>
<X size={18} aria-hidden="true" />
</button>
</div>

<p id={descriptionId} className="mt-2 text-sm text-slate-600 dark:text-slate-400">
Your vote has been submitted. Here is your receipt.
</p>

<div className="mt-6 space-y-4">
<div className="rounded-xl border border-slate-100 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-800/50">
<div className="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Proposal</div>
<div className="mt-1 font-medium text-slate-900 dark:text-white">{proposalTitle}</div>
</div>

<div className="grid grid-cols-2 gap-4">
<div className="rounded-xl border border-slate-100 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-800/50">
<div className="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Vote</div>
<div className="mt-1 font-medium text-slate-900 dark:text-white">{voteChoice}</div>
</div>
<div className="rounded-xl border border-slate-100 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-800/50">
<div className="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Time</div>
<div className="mt-1 font-medium text-slate-900 dark:text-white">{timestamp}</div>
</div>
</div>

<div className="rounded-xl border border-slate-100 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-800/50">
<div className="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Transaction Hash</div>
<div className="mt-1 flex items-center justify-between gap-2">
<span className="font-mono text-sm text-slate-700 dark:text-slate-300">{truncatedHash}</span>
<div className="flex gap-1">
<button
onClick={handleCopyHash}
className="rounded p-1.5 text-slate-500 hover:bg-white hover:text-slate-900 dark:hover:bg-slate-700 dark:hover:text-white"
aria-label="Copy transaction hash"
>
<Copy size={16} />
</button>
<a
href={`https://explorer.example.com/tx/${txHash}`}
target="_blank"
rel="noopener noreferrer"
className="rounded p-1.5 text-slate-500 hover:bg-white hover:text-slate-900 dark:hover:bg-slate-700 dark:hover:text-white"
aria-label="View transaction in explorer"
>
<ExternalLink size={16} />
</a>
</div>
</div>
{copyStatus === 'copied' && (
<span className="text-xs text-emerald-600 dark:text-emerald-400 mt-1 block">Copied!</span>
)}
</div>
</div>

<div className="mt-6 flex justify-end">
<Button onClick={onClose} className="w-full sm:w-auto">
Close
</Button>
</div>
</div>
</div>
);
};
43 changes: 43 additions & 0 deletions src/components/RedemptionPostCloseBanner.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<RedemptionPostCloseBanner {...mockProps} />);
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(<RedemptionPostCloseBanner {...mockProps} userShare={0} />);
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(<RedemptionPostCloseBanner {...mockProps} />);
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(<RedemptionPostCloseBanner {...mockProps} closedAt={thirtyOneDaysAgo.toISOString()} />);
expect(screen.queryByText(/Redemption Window Closed/i)).toBeNull();
expect(mockProps.onDismiss).toHaveBeenCalled();
});
});
83 changes: 83 additions & 0 deletions src/components/RedemptionPostCloseBanner.tsx
Original file line number Diff line number Diff line change
@@ -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<RedemptionPostCloseBannerProps> = ({
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 (
<section
className="glass-card animate-fade-in p-6 flex flex-col md:flex-row justify-between items-start md:items-center gap-4"
aria-labelledby="post-close-banner-title"
role="region"
>
<div className="flex-1">
<h2 id="post-close-banner-title" className="text-lg font-semibold text-white">
Redemption Window Closed
</h2>
<p className="text-muted text-sm mt-1">
{hasParticipated
? "The redemption window has closed. Here is your summary."
: "The redemption window has closed. You did not participate in this window."
}
</p>
</div>

{hasParticipated && (
<div className="flex flex-wrap gap-3">
<div className="bg-white/5 rounded-full px-4 py-1.5 text-sm font-medium text-white border border-white/10">
Total Redeemed: ${totalRedeemed.toLocaleString()}
</div>
<div className="bg-primary/20 rounded-full px-4 py-1.5 text-sm font-medium text-primary border border-primary/20">
Your Share: ${userShare.toLocaleString()}
</div>
</div>
)}

<div className="flex items-center gap-3">
<a
href={reportLink}
className="text-sm font-semibold text-primary hover:text-primary-hover transition-colors flex items-center gap-2"
>
<FileText size={16} />
View Detailed Report
</a>
<button
onClick={onDismiss}
className="text-muted hover:text-white transition-colors p-1"
aria-label="Dismiss banner"
>
<X size={18} />
</button>
</div>
</section>
);
};
Loading