Skip to content
Merged
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
18 changes: 18 additions & 0 deletions src/components/common/CopySuccessAnnouncement.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
interface CopySuccessAnnouncementProps {
message: string;
}

function CopySuccessAnnouncement({ message }: CopySuccessAnnouncementProps) {
return (
<span
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{message}
</span>
);
}

export default CopySuccessAnnouncement;
2 changes: 1 addition & 1 deletion src/components/common/CreatorProfileHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -148,4 +148,4 @@ const CreatorProfileHeader: React.FC<CreatorProfileHeaderProps> = ({
);
};

export default CreatorProfileHeader;
export default CreatorProfileHeader;
42 changes: 27 additions & 15 deletions src/components/common/EmptyTransactionTimelineState.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Check, Clock3, Copy, XCircle } from 'lucide-react';
import { formatRecentActivityCompactTimestamp } from '@/utils/recentActivityTimestamp.utils';
import CopySuccessAnnouncement from '@/components/common/CopySuccessAnnouncement';
import { useCopySuccessAnnouncement } from '@/hooks/useCopySuccessAnnouncement';

type CopyState = 'idle' | 'success' | 'error';

Expand All @@ -19,39 +21,46 @@ const DEFAULT_TIMELINE_ENTRIES: TimelineEntry[] = [
id: 'entry-1',
action: 'Buy',
amount: '+2 keys',
txHash: '0x2a43bcfdef77ca4c50ef7d38148dd5d7f0149a6e2e20f70f04ce1f4b66fe55dd',
txHash:
'0x2a43bcfdef77ca4c50ef7d38148dd5d7f0149a6e2e20f70f04ce1f4b66fe55dd',
compactTimestamp: '2m ago',
status: 'confirmed',
},
{
id: 'entry-2',
action: 'Sell',
amount: '-1 key',
txHash: '0x90c82ac01478b42fcbf9db73a26ed32bd8e50a8917e2408c31c95e9f6a59fc19',
txHash:
'0x90c82ac01478b42fcbf9db73a26ed32bd8e50a8917e2408c31c95e9f6a59fc19',
compactTimestamp: '18m ago',
status: 'pending',
},
{
id: 'entry-3',
action: 'Buy',
amount: '+3 keys',
txHash: '0x16d2ffbc4297a8c2c3086e07c16e66f47287df0d5a1ce1aef9e448e2f0f3ab51',
txHash:
'0x16d2ffbc4297a8c2c3086e07c16e66f47287df0d5a1ce1aef9e448e2f0f3ab51',
compactTimestamp: '51m ago',
status: 'failed',
},
];

const shortenTxHash = (hash: string) => `${hash.slice(0, 8)}...${hash.slice(-6)}`;
const shortenTxHash = (hash: string) =>
`${hash.slice(0, 8)}...${hash.slice(-6)}`;

interface EmptyTransactionTimelineStateProps {
/** Optional transaction data. If provided and empty, the component returns null. */
data?: TimelineEntry[];
}

const EmptyTransactionTimelineState: React.FC<EmptyTransactionTimelineStateProps> = ({
data = DEFAULT_TIMELINE_ENTRIES,
}) => {
const [copyStateById, setCopyStateById] = useState<Record<string, CopyState>>({});
const EmptyTransactionTimelineState: React.FC<
EmptyTransactionTimelineStateProps
> = ({ data = DEFAULT_TIMELINE_ENTRIES }) => {
const [copyStateById, setCopyStateById] = useState<
Record<string, CopyState>
>({});
const { announcement, announceCopySuccess } = useCopySuccessAnnouncement();

if (!data || data.length === 0) {
return null;
Expand All @@ -60,6 +69,7 @@ const EmptyTransactionTimelineState: React.FC<EmptyTransactionTimelineStateProps
const copyTxHash = async (entryId: string, txHash: string) => {
try {
await navigator.clipboard.writeText(txHash);
announceCopySuccess('Transaction hash copied.');
setCopyStateById(current => ({ ...current, [entryId]: 'success' }));
} catch {
setCopyStateById(current => ({ ...current, [entryId]: 'error' }));
Expand Down Expand Up @@ -105,7 +115,9 @@ const EmptyTransactionTimelineState: React.FC<EmptyTransactionTimelineStateProps
className="grid grid-cols-[auto_auto_minmax(0,1fr)_auto] items-center gap-3 rounded-lg border border-white/5 bg-white/[0.04] px-3 py-2 text-xs md:text-sm"
>
<span className="text-white/80">{entry.action}</span>
<span className="font-semibold text-white">{entry.amount}</span>
<span className="font-semibold text-white">
{entry.amount}
</span>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span
Expand All @@ -116,7 +128,9 @@ const EmptyTransactionTimelineState: React.FC<EmptyTransactionTimelineStateProps
</span>
<button
type="button"
onClick={() => copyTxHash(entry.id, entry.txHash)}
onClick={() =>
copyTxHash(entry.id, entry.txHash)
}
className="inline-flex size-6 shrink-0 items-center justify-center rounded-md bg-white/5 text-white/55 transition-colors hover:bg-white/10 hover:text-white"
aria-label={
isSuccess
Expand All @@ -141,11 +155,7 @@ const EmptyTransactionTimelineState: React.FC<EmptyTransactionTimelineStateProps
aria-atomic="true"
className="sr-only"
>
{isSuccess
? 'Transaction hash copied to clipboard'
: isError
? 'Failed to copy transaction hash'
: ''}
{isError ? 'Failed to copy transaction hash' : ''}
</span>
</div>
<div className="text-right">
Expand All @@ -161,6 +171,8 @@ const EmptyTransactionTimelineState: React.FC<EmptyTransactionTimelineStateProps
})}
</div>

<CopySuccessAnnouncement message={announcement} />

<div className="mt-5 flex justify-center">
<Button className="rounded-xl">Open full history</Button>
</div>
Expand Down
37 changes: 16 additions & 21 deletions src/components/common/TransactionFailureDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ import { Button } from '@/components/ui/button';
import { AlertCircle, Copy, Check } from 'lucide-react';
import showToast from '@/utils/toast.util';
import { formatTimestampTooltip } from '@/utils/time.utils';
import CopySuccessAnnouncement from '@/components/common/CopySuccessAnnouncement';
import {
COPY_SUCCESS_TOAST_ARIA_PROPS,
useCopySuccessAnnouncement,
} from '@/hooks/useCopySuccessAnnouncement';

export interface TransactionFailureDetails {
txHash?: string;
Expand Down Expand Up @@ -38,14 +43,22 @@ const TransactionFailureDrawer: React.FC<TransactionFailureDrawerProps> = ({
const [copiedField, setCopiedField] = useState<
'errorCode' | 'txHash' | null
>(null);
const { announcement, announceCopySuccess } = useCopySuccessAnnouncement();

const copyToClipboard = async (
text: string,
field: 'errorCode' | 'txHash'
) => {
try {
await navigator.clipboard.writeText(text);
showToast.success('Copied to clipboard');
showToast.success('Copied to clipboard', {
ariaProps: COPY_SUCCESS_TOAST_ARIA_PROPS,
});
announceCopySuccess(
field === 'errorCode'
? 'Error code copied.'
: 'Transaction hash copied.'
);
setCopiedField(field);
window.setTimeout(() => setCopiedField(null), 2000);
} catch {
Expand Down Expand Up @@ -148,16 +161,6 @@ const TransactionFailureDrawer: React.FC<TransactionFailureDrawerProps> = ({
)}
</button>
</div>
<span
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{copiedField === 'errorCode'
? 'Error code copied to clipboard'
: ''}
</span>
</div>
)}

Expand Down Expand Up @@ -193,19 +196,11 @@ const TransactionFailureDrawer: React.FC<TransactionFailureDrawerProps> = ({
)}
</button>
</div>
<span
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{copiedField === 'txHash'
? 'Transaction hash copied to clipboard'
: ''}
</span>
</div>
)}

<CopySuccessAnnouncement message={announcement} />

{failureDetails.developerDetails &&
Object.keys(failureDetails.developerDetails).length > 0 && (
<details className="text-sm cursor-pointer group">
Expand Down
24 changes: 14 additions & 10 deletions src/components/common/TransactionHashRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import React, { useState } from 'react';
import { Copy, Check, ExternalLink } from 'lucide-react';
import { cn } from '@/lib/utils';
import { shortenAddress } from '@/lib/web3/format';
import CopySuccessAnnouncement from '@/components/common/CopySuccessAnnouncement';
import { useCopySuccessAnnouncement } from '@/hooks/useCopySuccessAnnouncement';

interface TransactionHashRowProps {
hash: string;
Expand All @@ -17,10 +19,12 @@ const TransactionHashRow: React.FC<TransactionHashRowProps> = ({
className,
}) => {
const [copied, setCopied] = useState(false);
const { announcement, announceCopySuccess } = useCopySuccessAnnouncement();

const handleCopy = async (e: React.MouseEvent) => {
e.stopPropagation();
await navigator.clipboard.writeText(hash);
announceCopySuccess('Transaction hash copied.');
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
Expand All @@ -46,22 +50,22 @@ const TransactionHashRow: React.FC<TransactionHashRowProps> = ({
<button
onClick={handleCopy}
className="inline-flex size-6 items-center justify-center rounded-md bg-white/5 text-white/40 transition-colors hover:bg-white/10 hover:text-white"
aria-label={copied ? 'Transaction hash copied' : 'Copy transaction hash'}
aria-label={
copied
? 'Transaction hash copied'
: 'Copy transaction hash'
}
>
{copied ? (
<Check className="size-3 text-emerald-400" aria-hidden="true" />
<Check
className="size-3 text-emerald-400"
aria-hidden="true"
/>
) : (
<Copy className="size-3" aria-hidden="true" />
)}
</button>
<span
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{copied ? 'Transaction hash copied to clipboard' : ''}
</span>
<CopySuccessAnnouncement message={announcement} />
{explorerUrl && (
<a
href={explorerUrl}
Expand Down
13 changes: 5 additions & 8 deletions src/components/common/TruncatedAddress.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { useState } from 'react';
import { Copy, Check } from 'lucide-react';
import { cn } from '@/lib/utils';
import CopySuccessAnnouncement from '@/components/common/CopySuccessAnnouncement';
import { useCopySuccessAnnouncement } from '@/hooks/useCopySuccessAnnouncement';

interface TruncatedAddressProps {
address: string;
Expand All @@ -25,9 +27,11 @@ const TruncatedAddress: React.FC<TruncatedAddressProps> = ({
className,
}) => {
const [copied, setCopied] = useState(false);
const { announcement, announceCopySuccess } = useCopySuccessAnnouncement();

const handleCopy = async () => {
await navigator.clipboard.writeText(address);
announceCopySuccess('Address copied.');
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
Expand All @@ -54,14 +58,7 @@ const TruncatedAddress: React.FC<TruncatedAddressProps> = ({
<Copy className="size-3" aria-hidden="true" />
)}
</button>
<span
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{copied ? 'Address copied to clipboard' : ''}
</span>
<CopySuccessAnnouncement message={announcement} />
</>
)}
</span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,10 @@ describe('CreatorInitialsAvatar', () => {
it('renders initials fallback with hashed background when image is missing', () => {
render(<CreatorInitialsAvatar name="Alex Rivers" creatorId="creator-123" />);

const initials = screen.getByLabelText('Alex Rivers initials avatar');
const avatar = initials.parentElement;
const avatar = screen.getByRole('img', { name: 'Alex Rivers avatar' });
const colors = getFallbackAvatarColors('creator-123');

expect(initials).toHaveTextContent('AR');
expect(avatar).toHaveTextContent('AR');
expect(avatar).toHaveStyle({
background: colors.background,
color: colors.textColor,
Expand Down
34 changes: 34 additions & 0 deletions src/components/common/__tests__/CreatorProfileHeader.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { describe, expect, it } from 'vitest';

import CreatorProfileHeader from '@/components/common/CreatorProfileHeader';

describe('CreatorProfileHeader', () => {
it('closes the profile image lightbox with Escape and returns focus to the trigger', async () => {
render(
<CreatorProfileHeader
name="Alex Rivers"
handle="arivers"
creatorId="arivers"
avatarUrl="https://example.com/avatar.png"
/>
);

const avatarTrigger = screen.getByRole('button', {
name: 'Open Alex Rivers profile image',
});

fireEvent.click(avatarTrigger);

expect(
screen.getByRole('dialog', { name: 'Alex Rivers profile image' })
).toBeInTheDocument();

fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' });

await waitFor(() => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
expect(avatarTrigger).toHaveFocus();
});
});
41 changes: 41 additions & 0 deletions src/components/common/__tests__/TransactionHashRow.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';

import TransactionHashRow from '@/components/common/TransactionHashRow';

describe('TransactionHashRow', () => {
it('announces copy success in a visually hidden live region', async () => {
vi.useFakeTimers();
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
});

render(<TransactionHashRow hash="0xabcdef1234567890" />);

await act(async () => {
fireEvent.click(
screen.getByRole('button', { name: 'Copy transaction hash' })
);
});

expect(writeText).toHaveBeenCalledWith('0xabcdef1234567890');
expect(
screen.getByRole('button', { name: 'Transaction hash copied' })
).toBeInTheDocument();

act(() => {
vi.advanceTimersByTime(25);
});

const status = screen.getByRole('status');
expect(status).toHaveTextContent('Transaction hash copied.');
expect(status).toHaveClass('sr-only');
expect(status).not.toHaveTextContent(
'Transaction hash copied to clipboard'
);

vi.useRealTimers();
});
});
Loading
Loading