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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Built with Next.js 15, React 19, TypeScript, and the Stellar Wallets Kit, Predic
## Features

- **Decentralized & Transparent**: All predictions are recorded on-chain with complete transparency. No central authority controls the outcomes.
- **Shareable Prediction Receipts**: Completed predictions can now be shared with a polished receipt summary for campaigns such as GrantFox FWC26.
- **Instant Payouts**: Smart contracts automatically distribute winnings immediately after event resolution. No waiting periods.
- **Multi-Wallet Support**: Connect with your preferred Stellar wallet (Freighter, LOBSTR, XBull, Albedo, Rabet).
- **Real-Time Markets**: Access live prediction markets with real-time updates on odds, stakes, and participant activity.
Expand Down
23 changes: 23 additions & 0 deletions app/a11y-audit/page.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
import A11yAuditPage from './page'

describe('A11y audit page', () => {
it('renders the board summary and status table', () => {
render(<A11yAuditPage />)

expect(screen.getByRole('heading', { name: /accessibility audit board/i })).toBeInTheDocument()
expect(screen.getByRole('region', { name: /board summary/i })).toBeInTheDocument()
expect(screen.getByRole('table', { name: /grantfox accessibility audit board/i })).toBeInTheDocument()
expect(screen.getByText('ConnectWalletModal')).toBeInTheDocument()
expect(screen.getByText('New event form focus order')).toBeInTheDocument()
})

it('shows the expected status counts', () => {
render(<A11yAuditPage />)

expect(screen.getByText('6')).toBeInTheDocument()
expect(screen.getByText('1')).toBeInTheDocument()
expect(screen.getByText('0')).toBeInTheDocument()
})
})
194 changes: 194 additions & 0 deletions app/a11y-audit/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import type { Metadata } from 'next'

type AuditItem = {
component: string
status: 'Verified' | 'Partial' | 'Needs follow-up'
summary: string
evidence: string[]
}

const auditItems: AuditItem[] = [
{
component: 'ConnectWalletModal',
status: 'Verified',
summary:
'Provider labels, announced badge state, and recovery messaging are covered for assistive technologies.',
evidence: ['components/connect-wallet-modal.tsx', 'components/__tests__/connect-wallet-modal.test.tsx'],
},
{
component: 'Outcome icons and dispute voting states',
status: 'Verified',
summary:
'Outcome affordances rely on text and shape cues rather than color alone, with decorative icon handling verified.',
evidence: [
'components/icons/OutcomeIcons.tsx',
'components/icons/__tests__/OutcomeIcons.test.tsx',
'components/disputes/shared/TallyBar.tsx',
],
},
{
component: 'DisputeOutcomeExplainer',
status: 'Verified',
summary:
'Dialog flow, math steps, and tally values are structured so the explanation remains readable at larger zoom levels.',
evidence: [
'components/disputes/DisputeOutcomeExplainer.tsx',
'components/disputes/__tests__/DisputeOutcomeExplainer.test.tsx',
],
},
{
component: 'ErrorRecoveryScreen',
status: 'Verified',
summary:
'Recovery actions, incident copy, and escape paths are all available through keyboard and screen-reader friendly patterns.',
evidence: [
'components/error/ErrorRecoveryScreen.tsx',
'components/error/ErrorRecoveryScreen.test.tsx',
'components/error-boundary.tsx',
],
},
{
component: 'VirtualizedEventsList',
status: 'Verified',
summary:
'Keyboard focus visibility, loading messaging, and scroll restoration remain intact during large-list navigation.',
evidence: [
'components/events/virtualized-events-list.tsx',
'components/events/__tests__/virtualized-events-list.integration.test.tsx',
],
},
{
component: 'New event form focus order',
status: 'Partial',
summary:
'Early tab order is covered, but the full form sequence still needs a broader review for keyboard continuity.',
evidence: ['app/(dashboard)/events/new/page.tsx', 'app/(dashboard)/events/new/page.test.tsx'],
},
{
component: 'Focus-visible CSS layer',
status: 'Verified',
summary:
'Global focus rings remain visible in dark mode and are paired with strong contrast for interactive surfaces.',
evidence: ['app/styles/focus.css', 'app/globals.css', 'app/__tests__/focus-visible.test.js'],
},
]

const statusStyles: Record<AuditItem['status'], string> = {
Verified: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-200',
Partial: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-200',
'Needs follow-up': 'bg-rose-100 text-rose-800 dark:bg-rose-900/30 dark:text-rose-200',
}

export const metadata: Metadata = {
title: 'Accessibility audit board',
description: 'Internal GrantFox accessibility audit board for WCAG 2.1 AA review status.',
}

export default function A11yAuditPage() {
const verifiedCount = auditItems.filter((item) => item.status === 'Verified').length
const partialCount = auditItems.filter((item) => item.status === 'Partial').length
const followUpCount = auditItems.filter((item) => item.status === 'Needs follow-up').length

return (
<main className="min-h-screen bg-slate-50 px-4 py-10 text-slate-900 dark:bg-slate-950 dark:text-slate-100 sm:px-6 lg:px-8">
<div className="mx-auto flex max-w-6xl flex-col gap-8">
<header className="space-y-4">
<div className="inline-flex items-center rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1 text-sm font-medium text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950/40 dark:text-emerald-300">
Internal accessibility board
</div>
<div className="space-y-2">
<h1 className="text-3xl font-semibold tracking-tight sm:text-4xl">
Accessibility audit board
</h1>
<p className="max-w-3xl text-lg text-slate-600 dark:text-slate-300">
GrantFox-facing surfaces are tracked here for WCAG 2.1 AA readiness, with focused evidence for each component.
</p>
</div>
</header>

<section aria-label="Board summary" className="grid gap-4 md:grid-cols-3">
<article className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<p className="text-sm font-medium text-slate-500 dark:text-slate-400">Verified</p>
<p className="mt-2 text-3xl font-semibold">{verifiedCount}</p>
</article>
<article className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<p className="text-sm font-medium text-slate-500 dark:text-slate-400">Partial</p>
<p className="mt-2 text-3xl font-semibold">{partialCount}</p>
</article>
<article className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<p className="text-sm font-medium text-slate-500 dark:text-slate-400">Needs follow-up</p>
<p className="mt-2 text-3xl font-semibold">{followUpCount}</p>
</article>
</section>

<section className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div className="border-b border-slate-200 p-6 dark:border-slate-800">
<h2 className="text-xl font-semibold">AA status by component</h2>
<p className="mt-2 text-sm text-slate-600 dark:text-slate-300">
Status is based on implementation evidence and targeted audit coverage.
</p>
</div>
<div className="overflow-x-auto">
<table
className="min-w-full divide-y divide-slate-200 text-left text-sm dark:divide-slate-800"
aria-labelledby="audit-board-caption"
>
<caption id="audit-board-caption" className="sr-only">
GrantFox accessibility audit board
</caption>
<thead className="bg-slate-50 text-slate-700 dark:bg-slate-950/70 dark:text-slate-300">
<tr>
<th scope="col" className="px-6 py-4 font-semibold">
Component
</th>
<th scope="col" className="px-6 py-4 font-semibold">
Status
</th>
<th scope="col" className="px-6 py-4 font-semibold">
What was verified
</th>
<th scope="col" className="px-6 py-4 font-semibold">
Evidence
</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200 dark:divide-slate-800">
{auditItems.map((item) => (
<tr key={item.component} className="align-top">
<td className="px-6 py-5 font-medium text-slate-900 dark:text-slate-100">
{item.component}
</td>
<td className="px-6 py-5">
<span className={`inline-flex rounded-full px-3 py-1 text-xs font-semibold ${statusStyles[item.status]}`}>
{item.status}
</span>
</td>
<td className="max-w-xl px-6 py-5 text-slate-600 dark:text-slate-300">
{item.summary}
</td>
<td className="px-6 py-5">
<ul className="list-disc space-y-1 pl-5 text-slate-600 dark:text-slate-300">
{item.evidence.map((piece) => (
<li key={piece}>{piece}</li>
))}
</ul>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>

<section className="rounded-2xl border border-slate-200 bg-slate-100/80 p-6 text-sm text-slate-700 dark:border-slate-800 dark:bg-slate-900/70 dark:text-slate-300">
<h2 className="text-base font-semibold">How to use this board</h2>
<ul className="mt-3 list-disc space-y-2 pl-5">
<li>Use the status values as a quick review summary before shipping a GrantFox-facing change.</li>
<li>Pair any “Partial” item with a follow-up pass for keyboard and screen-reader behavior.</li>
<li>Keep the evidence list in sync with the implementation and test files when the component changes.</li>
</ul>
</section>
</div>
</main>
)
}
157 changes: 157 additions & 0 deletions app/components/ReceiptShare.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
"use client"

import { useMemo, useState } from "react"
import { Check, Copy, Download, ExternalLink, Share2 } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { cn } from "@/lib/utils"

interface ReceiptShareProps {
receiptId: string
marketTitle: string
outcome: string
amount: string
timestamp: string
campaign?: string
className?: string
}

function formatTimestamp(timestamp: string) {
const date = new Date(timestamp)

if (Number.isNaN(date.getTime())) {
return "Pending confirmation"
}

return date.toLocaleString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
})
}

export function ReceiptShare({
receiptId,
marketTitle,
outcome,
amount,
timestamp,
campaign = "GrantFox FWC26",
className,
}: ReceiptShareProps) {
const [open, setOpen] = useState(false)
const [copied, setCopied] = useState(false)

const shareUrl = useMemo(
() => `https://predictify.app/receipts/${receiptId}`,
[receiptId],
)

const handleCopy = async () => {
if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) {
return
}

await navigator.clipboard.writeText(shareUrl)
setCopied(true)
window.setTimeout(() => setCopied(false), 1800)
}

const handleDownload = () => {
const payload = [
`Receipt: ${receiptId}`,
`Market: ${marketTitle}`,
`Outcome: ${outcome}`,
`Amount: ${amount}`,
`Confirmed: ${formatTimestamp(timestamp)}`,
`Campaign: ${campaign}`,
].join("\n")

const blob = new Blob([payload], { type: "text/plain;charset=utf-8" })
const url = URL.createObjectURL(blob)
const link = document.createElement("a")
link.href = url
link.download = `receipt-${receiptId}.txt`
link.click()
URL.revokeObjectURL(url)
}

return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" className={cn("gap-2", className)}>
<Share2 className="h-4 w-4" />
Share receipt
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Share your prediction receipt</DialogTitle>
<DialogDescription>
Share a polished receipt card for this completed prediction with your audience.
</DialogDescription>
</DialogHeader>

<div className="space-y-4">
<div className="rounded-3xl border border-border/60 bg-gradient-to-br from-primary/10 via-background to-emerald-500/10 p-4 sm:p-6">
<div className="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-4">
<div className="inline-flex items-center rounded-full border border-emerald-500/30 bg-emerald-500/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.25em] text-emerald-600 dark:text-emerald-400">
{campaign}
</div>
<div className="space-y-2">
<p className="text-sm font-medium uppercase tracking-[0.25em] text-muted-foreground">
Prediction confirmed
</p>
<h3 className="text-2xl font-semibold text-foreground">{marketTitle}</h3>
</div>
<div className="flex flex-wrap gap-3">
<div className="rounded-2xl border border-border/60 bg-background/80 px-4 py-3">
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">Outcome</p>
<p className="mt-1 text-lg font-semibold text-foreground">{outcome}</p>
</div>
<div className="rounded-2xl border border-border/60 bg-background/80 px-4 py-3">
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">Amount</p>
<p className="mt-1 text-lg font-semibold text-foreground">{amount}</p>
</div>
</div>
</div>

<div className="rounded-3xl border border-border/60 bg-background/80 p-5 shadow-sm">
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">Receipt ID</p>
<p className="mt-2 text-lg font-semibold text-foreground">{receiptId}</p>
<p className="mt-4 text-sm text-muted-foreground">{formatTimestamp(timestamp)}</p>
</div>
</div>
</div>

<div className="flex flex-wrap items-center gap-2">
<Button type="button" onClick={handleCopy} className="gap-2">
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
{copied ? "Copied" : "Copy Link"}
</Button>
<Button type="button" variant="outline" className="gap-2" onClick={handleDownload}>
<Download className="h-4 w-4" />
Download summary
</Button>
<Button type="button" variant="ghost" className="gap-2" asChild>
<a href={shareUrl} target="_blank" rel="noreferrer noopener">
<ExternalLink className="h-4 w-4" />
Open link
</a>
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
}
Loading