diff --git a/src/components/creator-card/CreatorCard.tsx b/src/components/creator-card/CreatorCard.tsx new file mode 100644 index 00000000..39569657 --- /dev/null +++ b/src/components/creator-card/CreatorCard.tsx @@ -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(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 ( +
+ {/* Creator avatar and info */} +
+ +
+

+ {creator.name} +

+

+ @{creator.handle} +

+
+
+ + {/* Stats row */} +
+ {creator.subscriberCount.toLocaleString()} subs + {creator.contentCount} posts +
+ + {/* Quick buy button - always visible, keyboard accessible */} + + + {/* Keyboard shortcut hint - desktop only, unobtrusive */} + + + {/* Screen-reader only shortcut documentation */} + + Press B to quick buy from this creator + +
+ ); +} \ No newline at end of file diff --git a/src/components/creator-card/CreatorCardGrid.tsx b/src/components/creator-card/CreatorCardGrid.tsx new file mode 100644 index 00000000..d52678ef --- /dev/null +++ b/src/components/creator-card/CreatorCardGrid.tsx @@ -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 ( +
+ {creators.map((creator) => ( + + ))} +
+ ); +} \ No newline at end of file diff --git a/src/components/creator-card/README.md b/src/components/creator-card/README.md new file mode 100644 index 00000000..ec5eb3e2 --- /dev/null +++ b/src/components/creator-card/README.md @@ -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 \ No newline at end of file diff --git a/src/components/creator-card/__tests__/CreatorCard.test.tsx b/src/components/creator-card/__tests__/CreatorCard.test.tsx new file mode 100644 index 00000000..699733fb --- /dev/null +++ b/src/components/creator-card/__tests__/CreatorCard.test.tsx @@ -0,0 +1,137 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + expect(screen.getByText('Press B to quick buy from this creator')).toHaveClass('sr-only'); + }); + + it('has correct ARIA label', () => { + render(); + + expect(screen.getByRole('article')).toHaveAttribute( + 'aria-label', + 'Creator card for Alice Creator' + ); + }); + + it('Quick Buy button has accessible name', () => { + render(); + + expect(screen.getByRole('button', { name: 'Quick buy from Alice Creator' })).toBeInTheDocument(); + }); + + it('keyboard hint is hidden by default on desktop', () => { + render(); + + const hint = screen.getByText('to buy').parentElement; + expect(hint).toHaveClass('opacity-0'); + }); + + it('shows keyboard hint on hover/focus', async () => { + render(); + + 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(); + + 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(); + + const hint = screen.getByText('to buy').parentElement; + expect(hint).toHaveAttribute('aria-hidden', 'true'); + }); + + it('keyboard hint uses kbd element for B key', () => { + render(); + + const kbd = screen.getByText('B'); + expect(kbd.tagName.toLowerCase()).toBe('kbd'); + }); +}); \ No newline at end of file diff --git a/src/components/tx-status/TxStatusBadge.tsx b/src/components/tx-status/TxStatusBadge.tsx new file mode 100644 index 00000000..d8ca8fd2 --- /dev/null +++ b/src/components/tx-status/TxStatusBadge.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { TxStatus } from './TxStatusLegend'; + +interface TxStatusBadgeProps { + status: TxStatus; +} + +const BADGE_STYLES: Record = { + 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 = { + pending: 'Pending', + processing: 'Processing', + success: 'Success', + failed: 'Failed', +}; + +export function TxStatusBadge({ status }: TxStatusBadgeProps): JSX.Element { + return ( + + {STATUS_LABELS[status]} + + ); +} \ No newline at end of file diff --git a/src/components/tx-status/TxStatusLegend.tsx b/src/components/tx-status/TxStatusLegend.tsx new file mode 100644 index 00000000..1a18c6c6 --- /dev/null +++ b/src/components/tx-status/TxStatusLegend.tsx @@ -0,0 +1,155 @@ +import React, { useState, useRef, useEffect } from 'react'; + +export type TxStatus = 'pending' | 'success' | 'failed' | 'processing'; + +interface StatusConfig { + label: string; + description: string; + color: string; + bgColor: string; +} + +const STATUS_MAP: Record = { + pending: { + label: 'Pending', + description: 'Transaction submitted, awaiting confirmation', + color: 'text-yellow-700', + bgColor: 'bg-yellow-100', + }, + processing: { + label: 'Processing', + description: 'Transaction is being processed on-chain', + color: 'text-blue-700', + bgColor: 'bg-blue-100', + }, + success: { + label: 'Success', + description: 'Transaction confirmed and finalized', + color: 'text-green-700', + bgColor: 'bg-green-100', + }, + failed: { + label: 'Failed', + description: 'Transaction reverted or failed', + color: 'text-red-700', + bgColor: 'bg-red-100', + }, +}; + +export function TxStatusLegend(): JSX.Element { + const [isOpen, setIsOpen] = useState(false); + const triggerRef = useRef(null); + const tooltipRef = useRef(null); + + // Close on Escape and click outside + useEffect(() => { + if (!isOpen) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + setIsOpen(false); + triggerRef.current?.focus(); + } + }; + + const handleClickOutside = (e: MouseEvent) => { + if ( + tooltipRef.current && + !tooltipRef.current.contains(e.target as Node) && + !triggerRef.current?.contains(e.target as Node) + ) { + setIsOpen(false); + } + }; + + document.addEventListener('keydown', handleKeyDown); + document.addEventListener('mousedown', handleClickOutside); + + // Focus trap: focus first focusable element in tooltip + tooltipRef.current?.querySelector('button, [href]')?.focus(); + + return () => { + document.removeEventListener('keydown', handleKeyDown); + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isOpen]); + + const toggleTooltip = () => setIsOpen((prev) => !prev); + + return ( +
+ + + {isOpen && ( + + )} +
+ ); +} \ No newline at end of file diff --git a/src/components/tx-status/__tests__/TxStatusBadge.test.tsx b/src/components/tx-status/__tests__/TxStatusBadge.test.tsx new file mode 100644 index 00000000..d77c9475 --- /dev/null +++ b/src/components/tx-status/__tests__/TxStatusBadge.test.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { TxStatusBadge } from '../TxStatusBadge'; + +describe('TxStatusBadge', () => { + it.each([ + ['pending', 'Pending'], + ['processing', 'Processing'], + ['success', 'Success'], + ['failed', 'Failed'], + ] as const)('renders %s badge with correct label', (status, expectedLabel) => { + render(); + expect(screen.getByText(expectedLabel)).toBeInTheDocument(); + }); + + it('has accessible aria-label', () => { + render(); + expect(screen.getByLabelText('Transaction status: Success')).toBeInTheDocument(); + }); + + it('renders all four status variants without error', () => { + const { rerender } = render(); + expect(screen.getByText('Pending')).toBeInTheDocument(); + + rerender(); + expect(screen.getByText('Processing')).toBeInTheDocument(); + + rerender(); + expect(screen.getByText('Success')).toBeInTheDocument(); + + rerender(); + expect(screen.getByText('Failed')).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/src/components/tx-status/__tests__/TxStatusLegend.test.tsx b/src/components/tx-status/__tests__/TxStatusLegend.test.tsx new file mode 100644 index 00000000..af72e45f --- /dev/null +++ b/src/components/tx-status/__tests__/TxStatusLegend.test.tsx @@ -0,0 +1,127 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { TxStatusLegend } from '../TxStatusLegend'; + +describe('TxStatusLegend', () => { + it('renders trigger button', () => { + render(); + expect(screen.getByRole('button', { name: /status legend/i })).toBeInTheDocument(); + }); + + it('opens tooltip on click', async () => { + render(); + const trigger = screen.getByRole('button', { name: /status legend/i }); + + fireEvent.click(trigger); + + await waitFor(() => { + expect(screen.getByRole('dialog', { name: /status explanations/i })).toBeInTheDocument(); + }); + + expect(screen.getByText('Pending')).toBeInTheDocument(); + expect(screen.getByText('Transaction submitted, awaiting confirmation')).toBeInTheDocument(); + expect(screen.getByText('Success')).toBeInTheDocument(); + expect(screen.getByText('Failed')).toBeInTheDocument(); + expect(screen.getByText('Processing')).toBeInTheDocument(); + }); + + it('closes tooltip on Escape key', async () => { + render(); + const trigger = screen.getByRole('button', { name: /status legend/i }); + + fireEvent.click(trigger); + await waitFor(() => { + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + fireEvent.keyDown(document, { key: 'Escape' }); + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + }); + + it('closes tooltip on close button click', async () => { + render(); + const trigger = screen.getByRole('button', { name: /status legend/i }); + + fireEvent.click(trigger); + await waitFor(() => { + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + const closeBtn = screen.getByRole('button', { name: /close legend/i }); + fireEvent.click(closeBtn); + + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + }); + + it('closes tooltip on click outside', async () => { + render( +
+ +
Outside
+
+ ); + + fireEvent.click(screen.getByRole('button', { name: /status legend/i })); + await waitFor(() => { + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + fireEvent.mouseDown(screen.getByTestId('outside')); + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + }); + + it('toggles tooltip on repeated clicks', async () => { + render(); + const trigger = screen.getByRole('button', { name: /status legend/i }); + + fireEvent.click(trigger); + await waitFor(() => { + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + fireEvent.click(trigger); + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + }); + + it('has correct aria-expanded state', () => { + render(); + const trigger = screen.getByRole('button', { name: /status legend/i }); + + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + + fireEvent.click(trigger); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + }); + + it('has aria-controls pointing to tooltip', () => { + render(); + const trigger = screen.getByRole('button', { name: /status legend/i }); + + expect(trigger).toHaveAttribute('aria-controls', 'tx-status-legend'); + }); + + it('returns focus to trigger on close', async () => { + render(); + const trigger = screen.getByRole('button', { name: /status legend/i }); + + fireEvent.click(trigger); + await waitFor(() => { + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + fireEvent.keyDown(document, { key: 'Escape' }); + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + expect(document.activeElement).toBe(trigger); + }); +}); \ No newline at end of file