Skip to content
Closed
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
120 changes: 120 additions & 0 deletions src/components/creator-card/CreatorCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import React, { useEffect, useCallback, useRef, useState } from 'react';
import { Creator } from '@/types/creator';

interface CreatorCardProps {
creator: Creator;
onQuickBuy: (creatorId: string) => void;
}

export function CreatorCard({ creator, onQuickBuy }: CreatorCardProps): JSX.Element {
const cardRef = useRef<HTMLDivElement>(null);
const [showHint, setShowHint] = useState(false);

// Show hint on hover/focus, hide on leave/blur
useEffect(() => {
const card = cardRef.current;
if (!card) return;

const handleMouseEnter = () => setShowHint(true);
const handleMouseLeave = () => setShowHint(false);
const handleFocusIn = () => setShowHint(true);
const handleFocusOut = (e: FocusEvent) => {
if (!card.contains(e.relatedTarget as Node)) {
setShowHint(false);
}
};

card.addEventListener('mouseenter', handleMouseEnter);
card.addEventListener('mouseleave', handleMouseLeave);
card.addEventListener('focusin', handleFocusIn);
card.addEventListener('focusout', handleFocusOut);

return () => {
card.removeEventListener('mouseenter', handleMouseEnter);
card.removeEventListener('mouseleave', handleMouseLeave);
card.removeEventListener('focusin', handleFocusIn);
card.removeEventListener('focusout', handleFocusOut);
};
}, []);

const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
// 'B' key for quick buy when card is focused
if (e.key === 'b' || e.key === 'B') {
e.preventDefault();
onQuickBuy(creator.id);
}
// Enter/Space to open creator profile (standard behavior)
if (e.key === 'Enter' || e.key === ' ') {
// Let default link/button behavior handle this
}
},
[creator.id, onQuickBuy]
);

const handleQuickBuyClick = useCallback(() => {
onQuickBuy(creator.id);
}, [creator.id, onQuickBuy]);

return (
<div
ref={cardRef}
className="group relative rounded-xl border border-gray-200 bg-white p-4 shadow-sm transition-all hover:shadow-md focus-within:ring-2 focus-within:ring-blue-500 dark:border-gray-700 dark:bg-gray-800"
onKeyDown={handleKeyDown}
tabIndex={0}
role="article"
aria-label={`Creator card for ${creator.name}`}
>
{/* Creator avatar and info */}
<div className="flex items-center gap-3">
<img
src={creator.avatar}
alt=""
className="h-12 w-12 rounded-full object-cover"
loading="lazy"
/>
<div className="min-w-0 flex-1">
<h3 className="truncate text-sm font-semibold text-gray-900 dark:text-white">
{creator.name}
</h3>
<p className="truncate text-xs text-gray-500 dark:text-gray-400">
@{creator.handle}
</p>
</div>
</div>

{/* Stats row */}
<div className="mt-3 flex items-center gap-4 text-xs text-gray-500 dark:text-gray-400">
<span>{creator.subscriberCount.toLocaleString()} subs</span>
<span>{creator.contentCount} posts</span>
</div>

{/* Quick buy button - always visible, keyboard accessible */}
<button
onClick={handleQuickBuyClick}
className="mt-3 w-full rounded-lg bg-blue-600 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 active:bg-blue-800 dark:focus:ring-offset-gray-800"
aria-label={`Quick buy from ${creator.name}`}
>
Quick Buy
</button>

{/* Keyboard shortcut hint - desktop only, unobtrusive */}
<div
className={`absolute -top-2 right-3 hidden items-center gap-1 rounded-md border border-gray-200 bg-white px-2 py-1 text-[10px] font-medium text-gray-500 shadow-sm transition-opacity dark:border-gray-600 dark:bg-gray-700 dark:text-gray-400 md:flex ${
showHint ? 'opacity-100' : 'opacity-0'
}`}
aria-hidden="true"
>
<kbd className="rounded border border-gray-300 bg-gray-100 px-1 py-0.5 font-mono text-[9px] dark:border-gray-500 dark:bg-gray-600">
B
</kbd>
<span>to buy</span>
</div>

{/* Screen-reader only shortcut documentation */}
<span className="sr-only">
Press B to quick buy from this creator
</span>
</div>
);
}
22 changes: 22 additions & 0 deletions src/components/creator-card/CreatorCardGrid.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import React from 'react';
import { Creator } from '@/types/creator';
import { CreatorCard } from './CreatorCard';

interface CreatorCardGridProps {
creators: Creator[];
onQuickBuy: (creatorId: string) => void;
}

export function CreatorCardGrid({ creators, onQuickBuy }: CreatorCardGridProps): JSX.Element {
return (
<div
className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
role="list"
aria-label="Creator list"
>
{creators.map((creator) => (
<CreatorCard key={creator.id} creator={creator} onQuickBuy={onQuickBuy} />
))}
</div>
);
}
17 changes: 17 additions & 0 deletions src/components/creator-card/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# CreatorCard

## Keyboard Shortcuts

When a creator card has focus, the following keyboard shortcuts are available:

| Key | Action |
|-----|--------|
| `B` | Quick buy from this creator |
| `Enter` / `Space` | Activate focused element (standard behavior) |

## Accessibility

- Cards are focusable (`tabIndex={0}`) and announce themselves as "Creator card for [name]"
- The "B" shortcut is documented via screen-reader-only text
- Visual hint appears on hover/focus, hidden from screen readers (`aria-hidden`)
- Mobile UX unchanged: hint hidden on small screens via `md:` breakpoint
137 changes: 137 additions & 0 deletions src/components/creator-card/__tests__/CreatorCard.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

Check failure on line 3 in src/components/creator-card/__tests__/CreatorCard.test.tsx

View workflow job for this annotation

GitHub Actions / verify

'userEvent' is defined but never used
import { CreatorCard } from '../CreatorCard';

const mockCreator = {
id: 'creator-123',
name: 'Alice Creator',
handle: 'alice',
avatar: 'https://example.com/avatar.jpg',
subscriberCount: 15420,
contentCount: 89,
};

const mockOnQuickBuy = jest.fn();

describe('CreatorCard', () => {
beforeEach(() => {
mockOnQuickBuy.mockClear();
});

it('renders creator info', () => {
render(<CreatorCard creator={mockCreator} onQuickBuy={mockOnQuickBuy} />);

expect(screen.getByText('Alice Creator')).toBeInTheDocument();
expect(screen.getByText('@alice')).toBeInTheDocument();
expect(screen.getByText('15,420 subs')).toBeInTheDocument();
expect(screen.getByText('89 posts')).toBeInTheDocument();
});

it('calls onQuickBuy when Quick Buy button clicked', () => {
render(<CreatorCard creator={mockCreator} onQuickBuy={mockOnQuickBuy} />);

fireEvent.click(screen.getByRole('button', { name: /quick buy/i }));
expect(mockOnQuickBuy).toHaveBeenCalledWith('creator-123');
});

it('calls onQuickBuy when B key pressed while card focused', () => {
render(<CreatorCard creator={mockCreator} onQuickBuy={mockOnQuickBuy} />);

const card = screen.getByRole('article');
card.focus();

fireEvent.keyDown(card, { key: 'b' });
expect(mockOnQuickBuy).toHaveBeenCalledWith('creator-123');
});

it('calls onQuickBuy when uppercase B key pressed', () => {
render(<CreatorCard creator={mockCreator} onQuickBuy={mockOnQuickBuy} />);

const card = screen.getByRole('article');
card.focus();

fireEvent.keyDown(card, { key: 'B' });
expect(mockOnQuickBuy).toHaveBeenCalledWith('creator-123');
});

it('does not call onQuickBuy for other keys', () => {
render(<CreatorCard creator={mockCreator} onQuickBuy={mockOnQuickBuy} />);

const card = screen.getByRole('article');
card.focus();

fireEvent.keyDown(card, { key: 'a' });
fireEvent.keyDown(card, { key: 'Enter' });
fireEvent.keyDown(card, { key: ' ' });

expect(mockOnQuickBuy).not.toHaveBeenCalled();
});

it('has screen-reader shortcut documentation', () => {
render(<CreatorCard creator={mockCreator} onQuickBuy={mockOnQuickBuy} />);

expect(screen.getByText('Press B to quick buy from this creator')).toHaveClass('sr-only');
});

it('has correct ARIA label', () => {
render(<CreatorCard creator={mockCreator} onQuickBuy={mockOnQuickBuy} />);

expect(screen.getByRole('article')).toHaveAttribute(
'aria-label',
'Creator card for Alice Creator'
);
});

it('Quick Buy button has accessible name', () => {
render(<CreatorCard creator={mockCreator} onQuickBuy={mockOnQuickBuy} />);

expect(screen.getByRole('button', { name: 'Quick buy from Alice Creator' })).toBeInTheDocument();
});

it('keyboard hint is hidden by default on desktop', () => {
render(<CreatorCard creator={mockCreator} onQuickBuy={mockOnQuickBuy} />);

const hint = screen.getByText('to buy').parentElement;
expect(hint).toHaveClass('opacity-0');
});

it('shows keyboard hint on hover/focus', async () => {
render(<CreatorCard creator={mockCreator} onQuickBuy={mockOnQuickBuy} />);

const card = screen.getByRole('article');
fireEvent.mouseEnter(card);

const hint = screen.getByText('to buy').parentElement;
await waitFor(() => {
expect(hint).toHaveClass('opacity-100');
});
});

it('hides keyboard hint on mouse leave', async () => {
render(<CreatorCard creator={mockCreator} onQuickBuy={mockOnQuickBuy} />);

const card = screen.getByRole('article');
fireEvent.mouseEnter(card);
fireEvent.mouseLeave(card);

const hint = screen.getByText('to buy').parentElement;
await waitFor(() => {
expect(hint).toHaveClass('opacity-0');
});
});

it('keyboard hint is aria-hidden', () => {
render(<CreatorCard creator={mockCreator} onQuickBuy={mockOnQuickBuy} />);

const hint = screen.getByText('to buy').parentElement;
expect(hint).toHaveAttribute('aria-hidden', 'true');
});

it('keyboard hint uses kbd element for B key', () => {
render(<CreatorCard creator={mockCreator} onQuickBuy={mockOnQuickBuy} />);

const kbd = screen.getByText('B');
expect(kbd.tagName.toLowerCase()).toBe('kbd');
});
});
31 changes: 31 additions & 0 deletions src/components/tx-status/TxStatusBadge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import React from 'react';
import { TxStatus } from './TxStatusLegend';

interface TxStatusBadgeProps {
status: TxStatus;
}

const BADGE_STYLES: Record<TxStatus, string> = {
pending: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400',
processing: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
success: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
failed: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
};

const STATUS_LABELS: Record<TxStatus, string> = {
pending: 'Pending',
processing: 'Processing',
success: 'Success',
failed: 'Failed',
};

export function TxStatusBadge({ status }: TxStatusBadgeProps): JSX.Element {
return (
<span
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${BADGE_STYLES[status]}`}
aria-label={`Transaction status: ${STATUS_LABELS[status]}`}
>
{STATUS_LABELS[status]}
</span>
);
}
Loading
Loading