diff --git a/frontend/src/app/markets/[marketId]/page.css b/frontend/src/app/markets/[marketId]/page.css new file mode 100644 index 00000000..2801d0d1 --- /dev/null +++ b/frontend/src/app/markets/[marketId]/page.css @@ -0,0 +1,224 @@ +.market-detail-page { + max-width: var(--container); + margin: 0 auto; + padding: 2rem 1rem; + min-height: 100vh; +} + +.market-header { + margin-bottom: 2rem; + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; +} + +.market-title-section { + display: flex; + align-items: center; + gap: 1rem; + flex-wrap: wrap; +} + +.market-title-section h1 { + margin: 0; + font-size: var(--text-2xl); + font-family: var(--font-display); + color: var(--fg); +} + +.back-link { + color: var(--primary); + text-decoration: none; + font-size: var(--text-sm); + transition: color var(--dur-fast) var(--ease-expo); + white-space: nowrap; +} + +.back-link:hover { + color: var(--gold-soft); + text-decoration: underline; +} + +.back-link:focus-visible { + outline: 2px solid var(--ring); + outline-offset: 2px; + border-radius: var(--radius-sm); +} + +.loading-state, +.error-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 1rem; + padding: 3rem 1rem; + text-align: center; +} + +.error-state { + background-color: rgba(248, 113, 113, 0.1); + border: 1px solid rgba(248, 113, 113, 0.3); + border-radius: var(--radius); + padding: 2rem; +} + +.error-state h2 { + color: var(--destructive); + font-size: var(--text-xl); + margin-bottom: 0.5rem; +} + +.error-state p { + color: var(--fg-muted); + margin-bottom: 1rem; +} + +.market-not-found { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 1rem; + padding: 3rem 1rem; + text-align: center; +} + +.not-found-content { + background-color: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 2rem; + max-width: 500px; +} + +.not-found-content h1 { + font-size: var(--text-xl); + margin-bottom: 0.5rem; + color: var(--fg); +} + +.not-found-content p { + color: var(--fg-muted); + margin-bottom: 1.5rem; +} + +.retry-button { + padding: 0.75rem 1.5rem; + border: none; + border-radius: var(--radius-sm); + background-color: var(--primary); + color: var(--on-primary); + font-size: var(--text-sm); + font-weight: 600; + cursor: pointer; + transition: all var(--dur-fast) var(--ease-expo); +} + +.retry-button:hover { + transform: translateY(-2px); + box-shadow: 0 8px 20px rgba(245, 158, 11, 0.3); +} + +.retry-button:focus-visible { + outline: 2px solid var(--ring); + outline-offset: 2px; +} + +.tab-content { + animation: fadeIn var(--dur) var(--ease-expo); +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.market-details-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 1.5rem; + margin-bottom: 2rem; +} + +.detail-card { + background-color: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1.5rem; + transition: all var(--dur-fast) var(--ease-expo); +} + +.detail-card:hover { + border-color: var(--border-strong); + transform: translateY(-2px); + box-shadow: var(--shadow-sm); +} + +.detail-card h3 { + font-size: var(--text-sm); + font-weight: 600; + color: var(--fg-muted); + margin-bottom: 0.5rem; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.detail-value { + font-size: var(--text-lg); + color: var(--fg); + margin: 0; + font-weight: 500; + word-break: break-word; +} + +.coming-soon { + padding: 2rem; + text-align: center; + background-color: var(--surface-2); + border: 1px dashed var(--border); + border-radius: var(--radius); + color: var(--fg-muted); +} + +.capitalize { + text-transform: capitalize; +} + +@media (prefers-reduced-motion: reduce) { + .tab-content, + .detail-card { + animation: none; + transition: none; + } +} + +@media (max-width: 768px) { + .market-detail-page { + padding: 1rem; + } + + .market-header { + flex-direction: column; + align-items: flex-start; + } + + .market-title-section { + width: 100%; + } + + .market-title-section h1 { + font-size: var(--text-xl); + } + + .market-details-grid { + grid-template-columns: 1fr; + } +} diff --git a/frontend/src/components/Tabs.css b/frontend/src/components/Tabs.css new file mode 100644 index 00000000..f9a14c82 --- /dev/null +++ b/frontend/src/components/Tabs.css @@ -0,0 +1,108 @@ +.tabs { + display: flex; + flex-direction: column; + width: 100%; +} + +.tabs-header { + display: flex; + gap: 0.5rem; + border-bottom: 1px solid var(--border); + overflow-x: auto; + padding-bottom: 0; + -webkit-overflow-scrolling: touch; +} + +.tab-button { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 1rem 1.5rem; + border: none; + background: transparent; + color: var(--fg-muted); + font-size: var(--text-sm); + font-weight: 600; + font-family: var(--font-body); + cursor: pointer; + transition: all var(--dur-fast) var(--ease-expo); + position: relative; + white-space: nowrap; + flex-shrink: 0; +} + +.tab-button:hover { + color: var(--fg); + background-color: rgba(245, 158, 11, 0.05); +} + +.tab-button.active { + color: var(--primary); +} + +.tab-button.active::after { + content: ''; + position: absolute; + bottom: -1px; + left: 0; + right: 0; + height: 2px; + background-color: var(--primary); +} + +.tab-button:focus-visible { + outline: 2px solid var(--ring); + outline-offset: -2px; +} + +.tab-icon { + font-size: 1.1em; + opacity: 0.8; +} + +.tab-label { + letter-spacing: 0.3px; +} + +.tabs-content { + position: relative; + padding-top: 1.5rem; +} + +.tab-panel { + animation: fadeIn var(--dur) var(--ease-expo); +} + +.tab-panel:not(.active) { + display: none; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (prefers-reduced-motion: reduce) { + .tab-button, + .tab-panel { + transition: none; + animation: none; + } +} + +@media (max-width: 640px) { + .tab-button { + padding: 0.875rem 1rem; + font-size: var(--text-xs); + } + + .tabs-header { + gap: 0.25rem; + } +} diff --git a/frontend/src/components/Tabs.tsx b/frontend/src/components/Tabs.tsx new file mode 100644 index 00000000..f88de54a --- /dev/null +++ b/frontend/src/components/Tabs.tsx @@ -0,0 +1,69 @@ +import React, { useState } from 'react'; +import './Tabs.css'; + +interface TabConfig { + id: string; + label: string; + icon?: string; +} + +interface TabsProps { + tabs: TabConfig[]; + children: React.ReactNode[]; + defaultTab?: string; + onTabChange?: (tabId: string) => void; + className?: string; +} + +export const Tabs: React.FC = ({ + tabs, + children, + defaultTab, + onTabChange, + className = '', +}) => { + const [activeTab, setActiveTab] = useState(defaultTab || tabs[0]?.id || ''); + + const handleTabClick = (tabId: string) => { + setActiveTab(tabId); + onTabChange?.(tabId); + }; + + const activeTabConfig = tabs.find(t => t.id === activeTab); + + return ( +
+
+ {tabs.map((tab) => ( + + ))} +
+ +
+ {tabs.map((tab, index) => ( + + ))} +
+
+ ); +}; diff --git a/frontend/src/components/__tests__/MarketStatusBadge.test.tsx b/frontend/src/components/__tests__/MarketStatusBadge.test.tsx new file mode 100644 index 00000000..e84866be --- /dev/null +++ b/frontend/src/components/__tests__/MarketStatusBadge.test.tsx @@ -0,0 +1,98 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { MarketStatusBadge } from '../markets/MarketStatusBadge'; + +describe('MarketStatusBadge', () => { + it('renders Active status correctly', () => { + render(); + + expect(screen.getByText('Active')).toBeInTheDocument(); + expect(screen.getByRole('status')).toHaveClass('status-active'); + }); + + it('renders PendingResolution status correctly', () => { + render(); + + expect(screen.getByText('Pending Resolution')).toBeInTheDocument(); + expect(screen.getByRole('status')).toHaveClass('status-pending'); + }); + + it('renders Disputed status correctly', () => { + render(); + + expect(screen.getByText('Disputed')).toBeInTheDocument(); + expect(screen.getByRole('status')).toHaveClass('status-disputed'); + }); + + it('renders Resolved status correctly', () => { + render(); + + expect(screen.getByText('Resolved')).toBeInTheDocument(); + expect(screen.getByRole('status')).toHaveClass('status-resolved'); + }); + + it('renders Cancelled status correctly', () => { + render(); + + expect(screen.getByText('Cancelled')).toBeInTheDocument(); + expect(screen.getByRole('status')).toHaveClass('status-cancelled'); + }); + + it('renders Unknown status for unrecognized state', () => { + render(); + + expect(screen.getByText('Unknown')).toBeInTheDocument(); + expect(screen.getByRole('status')).toHaveClass('status-unknown'); + }); + + it('renders Unknown status when status is null', () => { + render(); + + expect(screen.getByText('Unknown')).toBeInTheDocument(); + expect(screen.getByRole('status')).toHaveClass('status-unknown'); + }); + + it('renders Unknown status when status is undefined', () => { + render(); + + expect(screen.getByText('Unknown')).toBeInTheDocument(); + expect(screen.getByRole('status')).toHaveClass('status-unknown'); + }); + + it('applies custom className', () => { + render(); + + const badge = screen.getByRole('status'); + expect(badge).toHaveClass('custom-class'); + expect(badge).toHaveClass('market-status-badge'); + }); + + it('includes status icon', () => { + const { container } = render(); + + const icon = container.querySelector('.status-icon'); + expect(icon).toBeInTheDocument(); + expect(icon).toHaveClass('status-icon'); + }); + + it('has correct aria-label for accessibility', () => { + render(); + + const badge = screen.getByRole('status'); + expect(badge).toHaveAttribute('aria-label', 'Market is active'); + }); + + it('has correct aria-label for unknown status', () => { + render(); + + const badge = screen.getByRole('status'); + expect(badge).toHaveAttribute('aria-label', 'Market status is unknown'); + }); + + it('handles case-sensitive status values', () => { + render(); + + expect(screen.getByText('Unknown')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/__tests__/Tabs.test.tsx b/frontend/src/components/__tests__/Tabs.test.tsx new file mode 100644 index 00000000..ccfa8c36 --- /dev/null +++ b/frontend/src/components/__tests__/Tabs.test.tsx @@ -0,0 +1,127 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { Tabs } from '../Tabs'; + +describe('Tabs', () => { + const tabs = [ + { id: 'tab1', label: 'Tab 1', icon: '📋' }, + { id: 'tab2', label: 'Tab 2', icon: '🎯' }, + { id: 'tab3', label: 'Tab 3' }, + ]; + + const children = [ +
Content 1
, +
Content 2
, +
Content 3
, + ]; + + it('renders tabs with correct labels', () => { + render({children}); + + expect(screen.getByText('Tab 1')).toBeInTheDocument(); + expect(screen.getByText('Tab 2')).toBeInTheDocument(); + expect(screen.getByText('Tab 3')).toBeInTheDocument(); + }); + + it('displays first tab content by default', () => { + render({children}); + + const content1 = screen.getByText('Content 1').parentElement; + const content2 = screen.getByText('Content 2').parentElement; + const content3 = screen.getByText('Content 3').parentElement; + + expect(content1).not.toHaveAttribute('hidden'); + expect(content2).toHaveAttribute('hidden'); + expect(content3).toHaveAttribute('hidden'); + }); + + it('switches content when tab is clicked', () => { + render({children}); + + fireEvent.click(screen.getByText('Tab 2')); + + const content1 = screen.getByText('Content 1').parentElement; + const content2 = screen.getByText('Content 2').parentElement; + const content3 = screen.getByText('Content 3').parentElement; + + expect(content1).toHaveAttribute('hidden'); + expect(content2).not.toHaveAttribute('hidden'); + expect(content3).toHaveAttribute('hidden'); + }); + + it('sets aria-selected correctly on active tab', () => { + render({children}); + + const tab1 = screen.getByRole('tab', { name: /Tab 1/i }); + const tab2 = screen.getByRole('tab', { name: /Tab 2/i }); + + expect(tab1).toHaveAttribute('aria-selected', 'true'); + expect(tab2).toHaveAttribute('aria-selected', 'false'); + + fireEvent.click(tab2); + + expect(tab1).toHaveAttribute('aria-selected', 'false'); + expect(tab2).toHaveAttribute('aria-selected', 'true'); + }); + + it('calls onTabChange callback when tab is clicked', () => { + const onTabChange = jest.fn(); + render({children}); + + fireEvent.click(screen.getByText('Tab 2')); + + expect(onTabChange).toHaveBeenCalledWith('tab2'); + }); + + it('uses defaultTab if provided', () => { + render({children}); + + const content1 = screen.getByText('Content 1').parentElement; + const content2 = screen.getByText('Content 2').parentElement; + + expect(content1).toHaveAttribute('hidden'); + expect(content2).not.toHaveAttribute('hidden'); + }); + + it('renders icons when provided', () => { + render({children}); + + const iconElements = screen.getAllByText('📋'); + expect(iconElements.length).toBeGreaterThan(0); + }); + + it('applies active class to active tab button', () => { + render({children}); + + const tab1Button = screen.getByRole('tab', { name: /Tab 1/i }); + const tab2Button = screen.getByRole('tab', { name: /Tab 2/i }); + + expect(tab1Button).toHaveClass('active'); + expect(tab2Button).not.toHaveClass('active'); + + fireEvent.click(tab2Button); + + expect(tab1Button).not.toHaveClass('active'); + expect(tab2Button).toHaveClass('active'); + }); + + it('applies custom className', () => { + const { container } = render( + {children} + ); + + const tabsDiv = container.querySelector('.tabs'); + expect(tabsDiv).toHaveClass('custom-tabs'); + }); + + it('handles tab switching without onTabChange callback', () => { + render({children}); + + const tab2Button = screen.getByRole('tab', { name: /Tab 2/i }); + fireEvent.click(tab2Button); + + const content2 = screen.getByText('Content 2').parentElement; + expect(content2).not.toHaveAttribute('hidden'); + }); +}); diff --git a/frontend/src/components/markets/MarketStatusBadge.css b/frontend/src/components/markets/MarketStatusBadge.css new file mode 100644 index 00000000..e7b83b01 --- /dev/null +++ b/frontend/src/components/markets/MarketStatusBadge.css @@ -0,0 +1,104 @@ +.market-status-badge { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + border-radius: var(--radius-pill); + font-size: var(--text-sm); + font-weight: 600; + font-family: var(--font-body); + transition: all var(--dur-fast) var(--ease-expo); +} + +.market-status-badge .status-icon { + font-size: 1.1em; + opacity: 0.9; +} + +.market-status-badge .status-label { + letter-spacing: 0.5px; +} + +/* Active — success color, accessible green */ +.market-status-badge.status-active { + background-color: rgba(52, 211, 153, 0.15); + color: var(--success); + border: 1px solid rgba(52, 211, 153, 0.3); +} + +.light-mode .market-status-badge.status-active { + background-color: rgba(5, 150, 105, 0.1); + color: var(--success); + border: 1px solid rgba(5, 150, 105, 0.25); +} + +/* Pending Resolution — primary/gold color */ +.market-status-badge.status-pending { + background-color: rgba(245, 158, 11, 0.15); + color: var(--primary); + border: 1px solid rgba(245, 158, 11, 0.3); +} + +.light-mode .market-status-badge.status-pending { + background-color: rgba(245, 158, 11, 0.12); + color: var(--primary); + border: 1px solid rgba(245, 158, 11, 0.25); +} + +/* Disputed — accent/purple color */ +.market-status-badge.status-disputed { + background-color: rgba(139, 92, 246, 0.15); + color: var(--accent); + border: 1px solid rgba(139, 92, 246, 0.3); +} + +.light-mode .market-status-badge.status-disputed { + background-color: rgba(139, 92, 246, 0.12); + color: var(--accent); + border: 1px solid rgba(139, 92, 246, 0.25); +} + +/* Resolved — muted/subtle color */ +.market-status-badge.status-resolved { + background-color: rgba(159, 176, 204, 0.15); + color: var(--fg-muted); + border: 1px solid rgba(159, 176, 204, 0.3); +} + +.light-mode .market-status-badge.status-resolved { + background-color: rgba(74, 88, 120, 0.1); + color: var(--fg-muted); + border: 1px solid rgba(74, 88, 120, 0.2); +} + +/* Cancelled — destructive/red color */ +.market-status-badge.status-cancelled { + background-color: rgba(248, 113, 113, 0.15); + color: var(--destructive); + border: 1px solid rgba(248, 113, 113, 0.3); +} + +.light-mode .market-status-badge.status-cancelled { + background-color: rgba(220, 38, 38, 0.1); + color: var(--destructive); + border: 1px solid rgba(220, 38, 38, 0.25); +} + +/* Unknown/Unrecognized state — neutral color */ +.market-status-badge.status-unknown { + background-color: rgba(107, 124, 156, 0.15); + color: var(--fg-subtle); + border: 1px solid rgba(107, 124, 156, 0.3); +} + +.light-mode .market-status-badge.status-unknown { + background-color: rgba(107, 124, 156, 0.1); + color: var(--fg-subtle); + border: 1px solid rgba(107, 124, 156, 0.2); +} + +@media (prefers-reduced-motion: reduce) { + .market-status-badge { + transition: none; + } +} diff --git a/frontend/src/components/markets/MarketStatusBadge.tsx b/frontend/src/components/markets/MarketStatusBadge.tsx new file mode 100644 index 00000000..a2bb0791 --- /dev/null +++ b/frontend/src/components/markets/MarketStatusBadge.tsx @@ -0,0 +1,74 @@ +import React from 'react'; +import './MarketStatusBadge.css'; + +interface MarketStatusBadgeProps { + status?: string | null; + className?: string; +} + +type StatusConfig = { + label: string; + icon: string; + colorClass: string; + ariaLabel: string; +}; + +const STATUS_MAP: Record = { + Active: { + label: 'Active', + icon: '●', + colorClass: 'status-active', + ariaLabel: 'Market is active', + }, + PendingResolution: { + label: 'Pending Resolution', + icon: '⧗', + colorClass: 'status-pending', + ariaLabel: 'Market is pending resolution', + }, + Disputed: { + label: 'Disputed', + icon: '⚡', + colorClass: 'status-disputed', + ariaLabel: 'Market is disputed', + }, + Resolved: { + label: 'Resolved', + icon: '✓', + colorClass: 'status-resolved', + ariaLabel: 'Market is resolved', + }, + Cancelled: { + label: 'Cancelled', + icon: '✕', + colorClass: 'status-cancelled', + ariaLabel: 'Market is cancelled', + }, +}; + +const UNKNOWN_STATUS: StatusConfig = { + label: 'Unknown', + icon: '?', + colorClass: 'status-unknown', + ariaLabel: 'Market status is unknown', +}; + +export const MarketStatusBadge: React.FC = ({ + status, + className = '', +}) => { + const config = status && STATUS_MAP[status] ? STATUS_MAP[status] : UNKNOWN_STATUS; + + return ( +
+ + {config.label} +
+ ); +};