diff --git a/app/(dashboard)/finances/page.tsx b/app/(dashboard)/finances/page.tsx index e7c3d6a6..75b469d3 100644 --- a/app/(dashboard)/finances/page.tsx +++ b/app/(dashboard)/finances/page.tsx @@ -14,6 +14,7 @@ import { Calendar as CalendarComponent } from "@/components/ui/calendar" import { MechanicHelp } from "@/components/patterns/MechanicHelp" import { platformFeesHelp } from "@/components/patterns/mechanic-help-content" import { format } from "date-fns" +import { FinancesEmptyState } from "@/components/empty-states/finances" // Mock data for financial overview const financialData = { @@ -188,6 +189,14 @@ export default function FinancesPage() { Fee Distribution + {financialData.totalFees === 0 ? ( + /* Empty state: no deposits/revenue — prompt user to connect wallet */ + + + + + + ) : (
@@ -212,6 +221,7 @@ export default function FinancesPage() {
+ )}
@@ -220,26 +230,31 @@ export default function FinancesPage() { Platform fee transactions for the selected period - - - - Date - Event - Type - Amount - - - - {financialData.transactions.map((transaction) => ( - - {new Date(transaction.date).toLocaleDateString()} - {transaction.eventTitle} - {transaction.type === "platform_fee" ? "Platform Fee" : transaction.type} - {hideBalances ? maskAmount(transaction.amount) : transaction.amount.toLocaleString()} + {financialData.transactions.length === 0 ? ( + /* Empty state: no trades/transactions in the selected period */ + + ) : ( +
+ + + Date + Event + Type + Amount - ))} - -
+ + + {financialData.transactions.map((transaction) => ( + + {new Date(transaction.date).toLocaleDateString()} + {transaction.eventTitle} + {transaction.type === "platform_fee" ? "Platform Fee" : transaction.type} + {hideBalances ? maskAmount(transaction.amount) : transaction.amount.toLocaleString()} + + ))} + + + )}
@@ -253,24 +268,29 @@ export default function FinancesPage() { Breakdown of platform fees by prediction category - - - - Category - Percentage - Amount - - - - {financialData.feeDistribution.map((item) => ( - - {item.category} - {item.percentage}% - ${item.amount.toLocaleString()} + {financialData.feeDistribution.length === 0 ? ( + /* Empty state: no fee distribution data — prompt user to check claims */ + + ) : ( +
+ + + Category + Percentage + Amount - ))} - -
+ + + {financialData.feeDistribution.map((item) => ( + + {item.category} + {item.percentage}% + ${item.amount.toLocaleString()} + + ))} + + + )}
diff --git a/components/empty-states/finances/FinancesEmptyState.tsx b/components/empty-states/finances/FinancesEmptyState.tsx new file mode 100644 index 00000000..78bff576 --- /dev/null +++ b/components/empty-states/finances/FinancesEmptyState.tsx @@ -0,0 +1,103 @@ +'use client'; + +/** + * FinancesEmptyState + * + * Category-aware empty state for the three /finances tabs: + * - "deposits" → no wallet activity yet → CTA: connect wallet + * - "trades" → no trade history yet → CTA: browse markets + * - "distribution" → no payouts/fees yet → CTA: view claims + * + * Design decisions + * ───────────────── + * • Uses design tokens (muted, foreground, primary) for dark-mode consistency. + * • Illustrations are decorative SVGs, aria-hidden; all meaning is in the text. + * • CTA buttons are plain tags (asChild on Button) so they work as real + * deep-links without a JS router dependency — swap to if preferred. + * • WCAG 2.1 AA: minimum 4.5:1 contrast via token colours; focus-visible rings + * are inherited from the global Button styles. + */ + +import Link from 'next/link'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; +import { NoDepositsIllustration } from './NoDepositsIllustration'; +import { NoTradesIllustration } from './NoTradesIllustration'; +import { NoPayoutsIllustration } from './NoPayoutsIllustration'; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export type FinancesEmptyVariant = 'deposits' | 'trades' | 'distribution'; + +interface FinancesEmptyStateProps { + /** Which finances tab this empty state belongs to */ + variant: FinancesEmptyVariant; + className?: string; +} + +// ─── Per-variant copy & routing config ─────────────────────────────────────── + +const CONFIG: Record< + FinancesEmptyVariant, + { + Illustration: React.ComponentType<{ className?: string }>; + title: string; + description: string; + cta: string; + href: string; + } +> = { + deposits: { + Illustration: NoDepositsIllustration, + title: 'No deposits yet', + description: + 'Connect your wallet to fund your account and start participating in prediction markets.', + cta: 'Connect wallet', + href: '/dashboard', // wallet-connect flow is triggered from the dashboard + }, + trades: { + Illustration: NoTradesIllustration, + title: 'No transactions yet', + description: + "You haven't made any trades in this period. Browse open markets to place your first prediction.", + cta: 'Browse markets', + href: '/events', + }, + distribution: { + Illustration: NoPayoutsIllustration, + title: 'No fee distribution yet', + description: + 'Platform fees are distributed once markets resolve. Check your pending claims to see what you may be owed.', + cta: 'View claims', + href: '/mypredictions', + }, +}; + +// ─── Component ─────────────────────────────────────────────────────────────── + +export function FinancesEmptyState({ variant, className }: FinancesEmptyStateProps) { + const { Illustration, title, description, cta, href } = CONFIG[variant]; + + return ( +
+ {/* 2× illustration — renders crisply on retina via SVG viewBox */} + + +
+

{title}

+

{description}

+
+ + +
+ ); +} diff --git a/components/empty-states/finances/NoDepositsIllustration.tsx b/components/empty-states/finances/NoDepositsIllustration.tsx new file mode 100644 index 00000000..1dd0df4e --- /dev/null +++ b/components/empty-states/finances/NoDepositsIllustration.tsx @@ -0,0 +1,79 @@ +/** + * NoDepositsIllustration + * + * Decorative SVG illustration for the empty "deposits" state on /finances. + * Exported at a 2× logical size (240×160 viewBox) so it renders crisp on + * high-DPI screens without extra image assets. + * + * Accessibility: aria-hidden="true" — the surrounding empty-state component + * provides all necessary text context. + */ +export function NoDepositsIllustration({ className }: { className?: string }) { + return ( + + ); +} diff --git a/components/empty-states/finances/NoPayoutsIllustration.tsx b/components/empty-states/finances/NoPayoutsIllustration.tsx new file mode 100644 index 00000000..6abc8d30 --- /dev/null +++ b/components/empty-states/finances/NoPayoutsIllustration.tsx @@ -0,0 +1,61 @@ +/** + * NoPayoutsIllustration + * + * Decorative SVG illustration for the empty "fee distribution / payouts" state + * on /finances. A donut chart outline with an empty centre signals no + * distributed fees yet. + * + * Accessibility: aria-hidden="true" — surrounding text provides full context. + */ +export function NoPayoutsIllustration({ className }: { className?: string }) { + return ( + + ); +} diff --git a/components/empty-states/finances/NoTradesIllustration.tsx b/components/empty-states/finances/NoTradesIllustration.tsx new file mode 100644 index 00000000..c0bd527c --- /dev/null +++ b/components/empty-states/finances/NoTradesIllustration.tsx @@ -0,0 +1,72 @@ +/** + * NoTradesIllustration + * + * Decorative SVG illustration for the empty "trades / transactions" state on + * /finances. The candlestick chart motif ties visually to prediction markets. + * + * Accessibility: aria-hidden="true" — surrounding text provides full context. + */ +export function NoTradesIllustration({ className }: { className?: string }) { + return ( + + ); +} diff --git a/components/empty-states/finances/__tests__/FinancesEmptyState.test.tsx b/components/empty-states/finances/__tests__/FinancesEmptyState.test.tsx new file mode 100644 index 00000000..8ba2ddc7 --- /dev/null +++ b/components/empty-states/finances/__tests__/FinancesEmptyState.test.tsx @@ -0,0 +1,96 @@ +import { render, screen } from '@testing-library/react'; +import { FinancesEmptyState } from '../FinancesEmptyState'; +import type { FinancesEmptyVariant } from '../FinancesEmptyState'; + +// next/link is a client component; stub it so tests run in a plain jsdom env. +jest.mock('next/link', () => { + const MockLink = ({ children, href }: { children: React.ReactNode; href: string }) => ( +
{children} + ); + MockLink.displayName = 'MockLink'; + return MockLink; +}); + +describe('FinancesEmptyState', () => { + const cases: Array<{ + variant: FinancesEmptyVariant; + expectedTitle: string; + expectedCta: string; + expectedHref: string; + }> = [ + { + variant: 'deposits', + expectedTitle: 'No deposits yet', + expectedCta: 'Connect wallet', + expectedHref: '/dashboard', + }, + { + variant: 'trades', + expectedTitle: 'No transactions yet', + expectedCta: 'Browse markets', + expectedHref: '/events', + }, + { + variant: 'distribution', + expectedTitle: 'No fee distribution yet', + expectedCta: 'View claims', + expectedHref: '/mypredictions', + }, + ]; + + it.each(cases)( + '$variant variant renders correct title, description, and CTA', + ({ variant, expectedTitle, expectedCta, expectedHref }) => { + render(); + + // Title + expect(screen.getByRole('heading', { name: expectedTitle })).toBeInTheDocument(); + + // CTA link with correct destination + const link = screen.getByRole('link', { name: expectedCta }); + expect(link).toBeInTheDocument(); + expect(link).toHaveAttribute('href', expectedHref); + } + ); + + it.each(cases)( + '$variant has role="status" and aria-label for screen readers', + ({ variant, expectedTitle }) => { + render(); + const region = screen.getByRole('status', { name: expectedTitle }); + expect(region).toBeInTheDocument(); + } + ); + + it.each(cases)( + '$variant SVG illustration is aria-hidden', + ({ variant }) => { + const { container } = render(); + const svg = container.querySelector('svg'); + expect(svg).toHaveAttribute('aria-hidden', 'true'); + } + ); + + it('accepts an optional className and applies it to the wrapper', () => { + const { container } = render( + + ); + expect(container.firstChild).toHaveClass('custom-class'); + }); + + // Edge case: partial-empty state — rendering two variants in the same view + // should not bleed styles or content between them. + it('renders two variants independently without cross-contamination', () => { + const { getAllByRole } = render( + <> + + + + ); + + const headings = getAllByRole('heading'); + const titles = headings.map((h) => h.textContent); + expect(titles).toContain('No deposits yet'); + expect(titles).toContain('No transactions yet'); + }); +}); diff --git a/components/empty-states/finances/index.ts b/components/empty-states/finances/index.ts new file mode 100644 index 00000000..27b3c362 --- /dev/null +++ b/components/empty-states/finances/index.ts @@ -0,0 +1,5 @@ +export { FinancesEmptyState } from './FinancesEmptyState'; +export type { FinancesEmptyVariant } from './FinancesEmptyState'; +export { NoDepositsIllustration } from './NoDepositsIllustration'; +export { NoTradesIllustration } from './NoTradesIllustration'; +export { NoPayoutsIllustration } from './NoPayoutsIllustration';