diff --git a/apps/demo/package.json b/apps/demo/package.json index cd4fd82..737dbd8 100644 --- a/apps/demo/package.json +++ b/apps/demo/package.json @@ -2,7 +2,6 @@ "name": "@usdh-kit-apps/demo", "version": "0.0.1", "private": true, - "type": "module", "scripts": { "dev": "next dev -p 3000", "build": "next build && node scripts/assert-widget-css.mjs", @@ -10,14 +9,25 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@radix-ui/react-dialog": "1.1.15", + "@radix-ui/react-scroll-area": "1.2.10", + "@radix-ui/react-separator": "1.1.8", + "@radix-ui/react-slot": "1.2.4", + "@radix-ui/react-tabs": "1.1.13", + "@radix-ui/react-toggle-group": "1.1.11", + "@radix-ui/react-tooltip": "1.2.8", "@tanstack/react-query": "5.62.7", "@usdh-kit/sdk": "workspace:*", "@usdh-kit/widget": "workspace:*", + "class-variance-authority": "0.7.1", + "clsx": "2.1.1", "connectkit": "1.8.2", + "geist": "1.7.0", "lucide-react": "1.14.0", "next": "15.5.18", "react": "18.3.1", "react-dom": "18.3.1", + "tailwind-merge": "3.6.0", "viem": "2.21.55", "wagmi": "2.14.6" }, diff --git a/apps/demo/postcss.config.js b/apps/demo/postcss.config.mjs similarity index 100% rename from apps/demo/postcss.config.js rename to apps/demo/postcss.config.mjs diff --git a/apps/demo/src/app/components/[slug]/page.tsx b/apps/demo/src/app/components/[slug]/page.tsx new file mode 100644 index 0000000..b148264 --- /dev/null +++ b/apps/demo/src/app/components/[slug]/page.tsx @@ -0,0 +1,61 @@ +import type { Metadata } from 'next' +import { notFound } from 'next/navigation' + +import { ComponentDetailExperience } from '../../../components/ComponentDetailExperience' +import { ComponentDocsShell } from '../../../components/ComponentDocsShell' +import { + componentEntries, + getComponentEntry, + parseRegistryDataMode, +} from '../../../lib/component-registry' +import { loadGallerySnapshot } from '../../../lib/gallery-data' + +export const dynamic = 'force-dynamic' + +type Params = Promise<{ slug: string }> +type SearchParams = Promise> + +export function generateMetadata({ params }: { params: Params }): Promise { + return params.then(({ slug }) => { + const entry = getComponentEntry(slug) + if (!entry) return { title: 'Component not found - usdh-kit' } + return { + title: `${entry.title} - usdh-kit components`, + description: entry.description, + } + }) +} + +export function generateStaticParams() { + return componentEntries.map((entry) => ({ slug: entry.slug })) +} + +export default async function ComponentDetailPage({ + params, + searchParams, +}: { + params: Params + searchParams?: SearchParams +}) { + const [{ slug }, query] = await Promise.all([params, searchParams]) + const entry = getComponentEntry(slug) + if (!entry) notFound() + + const dataMode = parseRegistryDataMode(query?.data) + const effectiveDataMode = entry.liveCapable ? dataMode : 'sample' + const bookCoin = typeof query?.coin === 'string' ? query.coin : undefined + const snapshot = await loadGallerySnapshot({ + mode: effectiveDataMode, + bookCoin, + }) + + return ( + + + + ) +} diff --git a/apps/demo/src/app/components/page.tsx b/apps/demo/src/app/components/page.tsx new file mode 100644 index 0000000..8d5e6ee --- /dev/null +++ b/apps/demo/src/app/components/page.tsx @@ -0,0 +1,10 @@ +import { ComponentDocsShell } from '../../components/ComponentDocsShell' +import { ComponentRegistryExperience } from '../../components/ComponentRegistryExperience' + +export default function ComponentsPage() { + return ( + + + + ) +} diff --git a/apps/demo/src/app/icon.svg b/apps/demo/src/app/icon.svg new file mode 100644 index 0000000..1c5b439 --- /dev/null +++ b/apps/demo/src/app/icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/demo/src/app/layout.tsx b/apps/demo/src/app/layout.tsx index 04708a0..b1f7c97 100644 --- a/apps/demo/src/app/layout.tsx +++ b/apps/demo/src/app/layout.tsx @@ -1,3 +1,5 @@ +import { GeistMono } from 'geist/font/mono' +import { GeistSans } from 'geist/font/sans' import type { Metadata, Viewport } from 'next' import type { ReactNode } from 'react' @@ -18,7 +20,9 @@ export const viewport: Viewport = { export default function RootLayout({ children }: { children: ReactNode }) { return ( - + {children} diff --git a/apps/demo/src/app/registry/page.tsx b/apps/demo/src/app/registry/page.tsx new file mode 100644 index 0000000..61bab34 --- /dev/null +++ b/apps/demo/src/app/registry/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from 'next/navigation' + +export default function RegistryPage() { + redirect('/components') +} diff --git a/apps/demo/src/components/ComponentDetailExperience.tsx b/apps/demo/src/components/ComponentDetailExperience.tsx new file mode 100644 index 0000000..d9d08f9 --- /dev/null +++ b/apps/demo/src/components/ComponentDetailExperience.tsx @@ -0,0 +1,563 @@ +'use client' + +import { ArrowLeft, ArrowRight } from 'lucide-react' +import Link from 'next/link' +import { useRouter } from 'next/navigation' +import { type ReactNode, useEffect, useState, useTransition } from 'react' + +import { Button } from '@/components/ui/button' +import { Card, CardContent } from '@/components/ui/card' +import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group' + +import { getComponentContract } from '../lib/component-contracts' +import { type ComponentCopyContract, getComponentIntegration } from '../lib/component-integrations' +import { type ComponentRecipe, getComponentRecipes } from '../lib/component-recipes' +import { + type ComponentEntry, + type ComponentSlug, + type RegistryDataMode, + componentHref, + getBuilderPathStep, + getComponentEntry, +} from '../lib/component-registry' +import { type ComponentSdkRead, getComponentSdkReads } from '../lib/component-sdk-reads' +import type { GallerySnapshot } from '../lib/gallery-data' +import { ComponentPreview } from './ComponentPreview' +import { CodeBlock } from './registry/code-block' +import { VariantSection } from './registry/preview-primitives' + +interface ComponentDetailExperienceProps { + slug: ComponentSlug + dataMode: RegistryDataMode + snapshot: GallerySnapshot +} + +export function ComponentDetailExperience({ + slug, + dataMode, + snapshot, +}: ComponentDetailExperienceProps) { + const entry = getComponentEntry(slug) + const [activeSnippetIndex, setActiveSnippetIndex] = useState(0) + const [copiedKey, setCopiedKey] = useState(null) + const router = useRouter() + const [, startRefresh] = useTransition() + const canRefresh = dataMode === 'live' && Boolean(entry?.liveCapable) + + useEffect(() => { + if (!canRefresh) return + const timer = window.setInterval(() => { + if (document.visibilityState !== 'visible') return + startRefresh(() => router.refresh()) + }, 8_000) + return () => window.clearInterval(timer) + }, [canRefresh, router]) + + if (!entry) return null + + const activeSnippet = entry.snippets[activeSnippetIndex] ?? entry.snippets[0] + const usageSnippet = entry.usage ?? activeSnippet + const variants = entry.variants ?? [] + const recipes = getComponentRecipes(entry.slug) + const sdkReads = getComponentSdkReads(entry.slug) + const snippetSelector = + entry.snippets.length > 1 ? ( + + ) : undefined + + function copyText(key: string, code: string) { + void writeClipboardText(code).then((copied) => { + if (!copied) return + setCopiedKey(key) + window.setTimeout(() => setCopiedKey(null), 1200) + }) + } + + return ( +
+
+
+
+
{entry.eyebrow}
+

+ {entry.title} +

+

+ {entry.description} +

+
+
+ +
+
+
+ + copyText('source', activeSnippet?.code ?? '')} + > + + + + + + {variants.length > 0 ? ( + +
+ {variants.map((variant) => ( + + copyText(`variant-${variant.id}`, variant.snippet.code)} + > + + + + ))} +
+
+ ) : null} + + {sdkReads.length > 0 ? : null} + + + + {recipes.length > 0 ? : null} + + + copyText('install', entry.installCommand ?? installCommand(entry))} + /> + + + + copyText('usage', usageSnippet?.code ?? '')} + /> + + + {entry.composition ? ( + + + +

+ {entry.composition} +

+
+
+
+ ) : null} + + +
+ ) +} + +function ExampleBlock({ + children, + actions, + code, + language, + copied, + onCopy, +}: { + children: ReactNode + actions?: ReactNode + code: string + language?: string + copied: boolean + onCopy: () => void +}) { + return ( +
+ {actions ? ( +
+ {actions} +
+ ) : null} +
+ {children} +
+ +
+ ) +} + +function SnippetSelector({ + entry, + activeSnippetIndex, + onChange, +}: { + entry: ComponentEntry + activeSnippetIndex: number + onChange: (index: number) => void +}) { + if (entry.snippets.length <= 1) return null + return ( + { + if (value) onChange(Number(value)) + }} + aria-label="Choose snippet" + > + {entry.snippets.map((snippet, index) => ( + + {snippet.title} + + ))} + + ) +} + +function DocsSection({ title, children }: { title: string; children: ReactNode }) { + return ( +
+

{title}

+ {children} +
+ ) +} + +function RecipeSection({ recipes }: { recipes: ComponentRecipe[] }) { + return ( + +
+ {recipes.map((recipe) => ( + + +
{recipe.title}
+

+ {recipe.description} +

+
    + {recipe.steps.map((step, index) => ( +
  1. + {index + 1} + {step} +
  2. + ))} +
+ {recipe.relatedSlug ? ( + + ) : null} +
+
+ ))} +
+
+ ) +} + +function SdkReadsSection({ reads }: { reads: ComponentSdkRead[] }) { + return ( + +
+
+
Raw read
+
+ Adapter +
+
+ UI props +
+
+ Parent owns +
+
+
+ {reads.map((read) => ( +
+ {read.rawRead} + {read.adapter} + {read.produces} + {read.parentOwns} +
+ ))} +
+
+
+ ) +} + +function SdkReadCell({ label, children }: { label: string; children: ReactNode }) { + return ( +
+
+ {label} +
+

{children}

+
+ ) +} + +function BuilderPathNav({ entry }: { entry: ComponentEntry }) { + const placement = getBuilderPathStep(entry.slug) + if (!placement) return null + + return ( + + ) +} + +async function writeClipboardText(code: string) { + if (copyTextWithTextarea(code)) return true + + try { + await navigator.clipboard.writeText(code) + return true + } catch { + return false + } +} + +function copyTextWithTextarea(code: string) { + const textarea = document.createElement('textarea') + textarea.value = code + textarea.setAttribute('readonly', 'true') + textarea.style.position = 'fixed' + textarea.style.left = '-9999px' + textarea.style.top = '0' + document.body.appendChild(textarea) + textarea.focus() + textarea.select() + textarea.setSelectionRange(0, textarea.value.length) + + try { + return document.execCommand('copy') + } catch { + return false + } finally { + document.body.removeChild(textarea) + } +} + +function PatternApiSection({ entry }: { entry: ComponentEntry }) { + const contract = getComponentContract(entry.slug) + if (contract.props.length === 0 && contract.states.length === 0) return null + + return ( + +
+ + +
+
Props
+
+
+ {contract.props.map((prop) => ( +
+
+
+ {prop.name} + {prop.required ? * : null} +
+
+ {prop.type} +
+
+

+ {prop.description} +

+
+ ))} +
+
+
+ +
+ + +
States
+
+ {contract.states.map((state) => ( +
+
{state.name}
+

+ {state.description} +

+
+ ))} +
+
+
+ + + +
Accessibility
+

+ {contract.accessibility} +

+
+
+
+
+
+ ) +} + +function CopyGuideSection({ entry }: { entry: ComponentEntry }) { + const integration = getComponentIntegration(entry.slug) + + return ( + + + {integration.copyContract ? : null} +
+ + + +
+
+ ) +} + +function BoundarySummary({ entry }: { entry: ComponentEntry }) { + const rows = [ + ['Used for', entry.useCase.usedFor], + ['Reads', entry.useCase.reads], + ['Does not', entry.useCase.doesNot], + ] as const + + return ( +
+ {rows.map(([label, value]) => ( + + ))} +
+ ) +} + +function CopyMap({ contract }: { contract: ComponentCopyContract }) { + const rows = [ + ['Adapter', contract.adapter], + ['Pattern', contract.pattern], + ['Parent owns', contract.parentOwns], + ] as const + + return ( +
+
Implementation map
+
+ {rows.map(([label, value]) => ( +
+
+ {label} +
+

+ {value} +

+
+ ))} +
+
+ ) +} + +function IntegrationList({ title, items }: { title: string; items: string[] }) { + return ( +
+
{title}
+
    + {items.map((item) => ( +
  • + + {item} +
  • + ))} +
+
+ ) +} + +function UseCaseRow({ label, value }: { label: string; value: string }) { + return ( +
+
+ {label} +
+

{value}

+
+ ) +} + +function installCommand(entry: ComponentEntry) { + if (entry.slug === 'usdh-widget') return 'pnpm add @usdh-kit/widget wagmi viem' + return 'pnpm add @usdh-kit/sdk' +} diff --git a/apps/demo/src/components/ComponentDocsShell.tsx b/apps/demo/src/components/ComponentDocsShell.tsx new file mode 100644 index 0000000..cb69892 --- /dev/null +++ b/apps/demo/src/components/ComponentDocsShell.tsx @@ -0,0 +1,252 @@ +'use client' + +import { Box, ExternalLink, Menu, Search } from 'lucide-react' +import Link from 'next/link' +import { type ReactNode, useMemo, useState } from 'react' + +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { ScrollArea } from '@/components/ui/scroll-area' +import { Separator } from '@/components/ui/separator' +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, + SheetTrigger, +} from '@/components/ui/sheet' +import { cn } from '@/lib/utils' + +import { + type ComponentSlug, + componentEntries, + componentHref, + componentSections, +} from '../lib/component-registry' + +interface ComponentDocsShellProps { + activeSlug?: ComponentSlug + children: ReactNode +} + +export function ComponentDocsShell({ activeSlug, children }: ComponentDocsShellProps) { + const [navQuery, setNavQuery] = useState('') + + return ( +
+ +
+
+
+ + + + + + + Components + USDH surfaces for builder apps. + + +
+ +
+ + + +
+
+
+ + + + u + + usdh-kit + + + + + + + +
+
+ +
+ + +
+
{children}
+
+
+
+ ) +} + +function SidebarSearch({ + value, + onChange, +}: { + value: string + onChange: (value: string) => void +}) { + return ( +
+ + onChange(event.target.value)} + /> +
+ ) +} + +function SidebarNav({ + activeSlug, + query, + showIndex, +}: { + activeSlug?: ComponentSlug + query: string + showIndex?: boolean +}) { + const normalizedQuery = query.trim().toLowerCase() + const visibleSections = useMemo( + () => + componentSections + .map((section) => ({ + ...section, + items: section.items.filter((slug) => { + const entry = componentEntries.find((item) => item.slug === slug) + if (!entry || !normalizedQuery) return true + return [entry.title, entry.shortTitle, entry.category, entry.description, ...entry.tags] + .join(' ') + .toLowerCase() + .includes(normalizedQuery) + }), + })) + .filter((section) => section.items.length > 0), + [normalizedQuery], + ) + + return ( + + ) +} + +function NavLink({ + href, + active, + children, +}: { + href: string + active: boolean + children: ReactNode +}) { + return ( + + {children} + + ) +} + +function RegistryMotionStyles() { + return ( + + ) +} diff --git a/apps/demo/src/components/ComponentPreview.tsx b/apps/demo/src/components/ComponentPreview.tsx new file mode 100644 index 0000000..569611d --- /dev/null +++ b/apps/demo/src/components/ComponentPreview.tsx @@ -0,0 +1,136 @@ +'use client' + +import dynamic from 'next/dynamic' + +import type { ComponentSlug, RegistryDataMode } from '../lib/component-registry' +import type { GallerySnapshot } from '../lib/gallery-data' +import { Panel } from './registry/preview-primitives' +import type { PreviewSize } from './registry/preview-primitives' + +interface ComponentPreviewProps { + slug: ComponentSlug + snapshot: GallerySnapshot + dataMode: RegistryDataMode + size?: PreviewSize + previewId?: string +} + +type SnapshotPreviewProps = { + snapshot: GallerySnapshot + size: PreviewSize + previewId?: string +} + +type MarketPreviewProps = SnapshotPreviewProps & { + dataMode: RegistryDataMode +} + +type StaticPreviewProps = { + size: PreviewSize + previewId?: string +} + +const UsdhWidgetPreview = dynamic( + () => import('./registry/previews/widget').then((module) => module.UsdhWidgetPreview), + { loading: PreviewLoading }, +) +const MarketBoardPreview = dynamic( + () => import('./registry/previews/market-board').then((module) => module.MarketBoardPreview), + { loading: PreviewLoading }, +) +const OutcomeKitPreview = dynamic( + () => import('./registry/previews/outcomes').then((module) => module.OutcomeKitPreview), + { loading: PreviewLoading }, +) +const OrderTicketPreview = dynamic( + () => import('./registry/previews/order-ticket').then((module) => module.OrderTicketPreview), + { loading: PreviewLoading }, +) +const FlowChartPreview = dynamic( + () => import('./registry/previews/flows').then((module) => module.FlowChartPreview), + { loading: PreviewLoading }, +) +const BridgeSwapRoutePreview = dynamic( + () => import('./registry/previews/flows').then((module) => module.BridgeSwapRoutePreview), + { loading: PreviewLoading }, +) +const SdkPrimitiveCardsPreview = dynamic( + () => + import('./registry/previews/sdk-primitives').then((module) => module.SdkPrimitiveCardsPreview), + { loading: PreviewLoading }, +) + +export function ComponentPreview({ + slug, + snapshot, + dataMode, + size = 'full', + previewId, +}: ComponentPreviewProps) { + switch (slug) { + case 'usdh-widget': + return + case 'market-board': + return ( + + ) + case 'quote-readiness': + return ( + + ) + case 'outcome-reads': + return + case 'outcome-market-row': + return ( + + ) + case 'outcome-odds-selector': + return ( + + ) + case 'outcome-order-book': + return ( + + ) + case 'outcome-position-row': + return ( + + ) + case 'order-ticket-mock': + return + case 'flow-chart': + return + case 'bridge-swap-route': + return + case 'sdk-primitive-cards': + return + default: + return null + } +} + +function PreviewLoading() { + return ( + + Loading preview + + ) +} diff --git a/apps/demo/src/components/ComponentRegistryExperience.tsx b/apps/demo/src/components/ComponentRegistryExperience.tsx new file mode 100644 index 0000000..b4692ef --- /dev/null +++ b/apps/demo/src/components/ComponentRegistryExperience.tsx @@ -0,0 +1,143 @@ +'use client' + +import { ArrowRight, Search } from 'lucide-react' +import Link from 'next/link' +import { useMemo, useState } from 'react' + +import { Card, CardContent, CardDescription, CardTitle } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Separator } from '@/components/ui/separator' +import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group' + +import { + type ComponentEntry, + componentHref, + visibleComponentEntries, +} from '../lib/component-registry' + +const categories = ['All', 'Widget', 'USDH', 'HIP-4', 'Trading'] as const +type CategoryFilter = (typeof categories)[number] + +export function ComponentRegistryExperience() { + const [category, setCategory] = useState('All') + const [query, setQuery] = useState('') + + const filteredEntries = useMemo( + () => + visibleComponentEntries.filter((entry) => { + const categoryMatch = category === 'All' || entry.category === category + const queryValue = query.trim().toLowerCase() + if (!queryValue) return categoryMatch + const text = [ + entry.title, + entry.shortTitle, + entry.description, + entry.category, + ...entry.tags, + ] + .join(' ') + .toLowerCase() + return categoryMatch && text.includes(queryValue) + }), + [category, query], + ) + + return ( +
+
+
+

+ USDH components for app builders. +

+

+ Copyable USDH and HIP-4 patterns with preview, code, contract, and examples. +

+
+
+ +
+
+ + setQuery(event.target.value)} + placeholder="Filter components" + aria-label="Filter components" + /> +
+
+ { + if (value) setCategory(value as CategoryFilter) + }} + aria-label="Filter by category" + > + {categories.map((item) => ( + + {item} + + ))} + +
+
+ +
+
+ {filteredEntries.map((entry) => ( + + ))} +
+ + {filteredEntries.length === 0 ? ( + + + No components match this filter. + + + ) : null} +
+
+ ) +} + +function ComponentCard({ entry }: { entry: ComponentEntry }) { + return ( + + +
+ + + +
+ {entry.title} + + {entry.description} + + + +
+ {entry.category} + + Open + + +
+
+ ) +} diff --git a/apps/demo/src/components/registry/code-block.tsx b/apps/demo/src/components/registry/code-block.tsx new file mode 100644 index 0000000..37b2474 --- /dev/null +++ b/apps/demo/src/components/registry/code-block.tsx @@ -0,0 +1,95 @@ +'use client' + +import { Check, Clipboard, Terminal } from 'lucide-react' +import type { ReactNode } from 'react' + +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' + +export function CodeBlock({ + code, + language, + copied, + onCopy, + embedded, +}: { + code: string + language?: string + copied: boolean + onCopy: () => void + embedded?: boolean +}) { + return ( +
+
+
+ + {language ?? 'tsx'} +
+ +
+
+        
+          {code.split('\n').map((line, index) => (
+            
+              
+                {index + 1}
+              
+              {highlightCodeLine(line, language)}
+            
+          ))}
+        
+      
+
+ ) +} + +function highlightCodeLine(line: string, language?: string) { + const shell = language === 'shell' + const pattern = shell + ? /(#.*|\b(?:pnpm|npm|yarn|bun|bunx|npx)\b|--[\w-]+|\b\d[\d.]*\b)/g + : /(\/\/.*|(['"`])(?:\\.|(?!\2).)*\2|\b(?:import|from|export|function|return|const|let|await|async|if|else|true|false|null|undefined|type|as)\b|<\/?[A-Za-z][\w.:/-]*|\b\d[\d._]*\b)/g + const parts: ReactNode[] = [] + let lastIndex = 0 + + for (const match of line.matchAll(pattern)) { + const token = match[0] + const index = match.index ?? 0 + if (index > lastIndex) parts.push(line.slice(lastIndex, index)) + parts.push( + + {token} + , + ) + lastIndex = index + token.length + } + + if (lastIndex < line.length) parts.push(line.slice(lastIndex)) + return parts.length > 0 ? parts : line +} + +function codeTokenClass(token: string, shell: boolean) { + if (token.startsWith('//') || token.startsWith('#')) return 'text-neutral-500' + if (!shell && /^['"`]/.test(token)) return 'text-emerald-300' + if (!shell && /^<\/?[A-Za-z]/.test(token)) return 'text-sky-300' + if (/^--/.test(token)) return 'text-amber-300' + if (/^\d/.test(token)) return 'text-amber-300 tabular-nums' + if (shell) return 'text-violet-300' + return 'text-rose-300' +} diff --git a/apps/demo/src/components/registry/patterns/outcome-adapters.ts b/apps/demo/src/components/registry/patterns/outcome-adapters.ts new file mode 100644 index 0000000..1d122e6 --- /dev/null +++ b/apps/demo/src/components/registry/patterns/outcome-adapters.ts @@ -0,0 +1,288 @@ +import { + type OutcomeSide, + type OutcomeSideMarket, + type UsdhOutcomeMarket, + createOutcomeOrderBookSummary, + createOutcomeEventData as createSdkOutcomeEventData, + createOutcomePositionData as createSdkOutcomePositionData, + createOutcomeSideQuote as createSdkOutcomeSideQuote, +} from '@usdh-kit/sdk' + +import type { GalleryOutcome } from '../../../lib/gallery-data' +import type { + OutcomeBookRow, + OutcomeEventData, + OutcomeOrderBookSideLevels, + OutcomePositionData, + OutcomeSideQuote, +} from './outcomes' + +export interface OutcomePatternSideInput { + label: string + coin: string +} + +export interface OutcomePatternMarketInput { + id: number | string + title: string + subtitle?: string + sides: [OutcomePatternSideInput, OutcomePatternSideInput] +} + +export interface OutcomePatternReadInput { + probability?: number | null + bestBid?: string | null + bestAsk?: string | null + depth?: string | null +} + +export interface OutcomePatternPositionInput { + market: string + side: string + coin: string + shares?: string + mark?: string + state?: OutcomePositionData['state'] +} + +export interface L2BookLike { + levels: [Array<{ px: string; sz: string }>, Array<{ px: string; sz: string }>] +} + +export function createOutcomeEventData( + market: OutcomePatternMarketInput, + reads: [OutcomePatternReadInput?, OutcomePatternReadInput?] = [], +): OutcomeEventData { + const event = createSdkOutcomeEventData(toSdkOutcomeMarket(market), reads) + return { + id: event.id, + title: event.title, + subtitle: market.subtitle ?? event.subtitle, + sides: event.sides, + } +} + +export function createOutcomeSideQuote( + side: OutcomePatternSideInput, + read: OutcomePatternReadInput = {}, +): OutcomeSideQuote { + const quote = createSdkOutcomeSideQuote(toSdkOutcomeSideMarket(side, 0, 0), read) + return { + label: quote.label, + coin: quote.coin, + probability: quote.probability, + bestBid: quote.bestBid, + bestAsk: quote.bestAsk, + depth: quote.depth, + } +} + +export function createOutcomeOrderBookLevels(book: L2BookLike): OutcomeOrderBookSideLevels { + const levels = createOutcomeOrderBookSummary(book).levels + return { + bids: levels.bids.map(toOutcomeBookRow), + asks: levels.asks.map(toOutcomeBookRow), + } +} + +export function outcomeEventWithoutLiquidity(event: OutcomeEventData): OutcomeEventData { + return { + ...event, + sides: event.sides.map((side) => ({ + ...side, + probability: null, + bestBid: undefined, + bestAsk: undefined, + depth: '$0', + })) as OutcomeEventData['sides'], + } +} + +export function createOutcomePositionData( + position: OutcomePatternPositionInput, +): OutcomePositionData { + const state = position.state ?? 'held' + const market = toSdkOutcomeMarket({ + id: 20, + title: position.market, + sides: [ + { label: position.side, coin: position.coin }, + { label: position.side === 'Yes' ? 'No' : 'Other', coin: nextOutcomeCoin(position.coin) }, + ], + }) + const sdkPosition = createSdkOutcomePositionData({ + market, + side: market.sides[0], + ...(position.shares !== undefined && { quantity: position.shares }), + ...(position.mark !== undefined && { mark: position.mark }), + state, + }) + return { + market: sdkPosition.market, + side: sdkPosition.sideName, + coin: sdkPosition.coin, + position: position.shares + ? `${sdkPosition.quantity} ${sdkPosition.sideName}` + : positionLabel(state, sdkPosition.sideName), + mark: sdkPosition.mark ?? markLabel(state, 0), + state, + } +} + +export function galleryOutcomeToEventData(outcome: GalleryOutcome): OutcomeEventData { + return createOutcomeEventData( + { + id: outcome.id, + title: formatOutcomeTitle(outcome), + subtitle: outcome.sides.join(' / '), + sides: [ + { label: outcome.sideReads[0].side, coin: outcome.sideReads[0].coin }, + { label: outcome.sideReads[1].side, coin: outcome.sideReads[1].coin }, + ], + }, + [outcome.sideReads[0], outcome.sideReads[1]], + ) +} + +export function galleryOutcomeToLevelsByCoin( + outcome: GalleryOutcome, + sideIndex: 0 | 1 = 0, +): Record { + const read = outcome.sideReads[sideIndex] + if (read === undefined) return {} + return { + [read.coin]: createOutcomeOrderBookLevels(bookFromSideRead(read)), + } +} + +export function galleryOutcomesToPositionData( + outcomes: GalleryOutcome[], + options?: { state?: OutcomePositionData['state'] }, +): OutcomePositionData[] { + return outcomes.slice(0, 4).map((outcome, index) => { + const read = outcome.sideReads[index % 2] ?? outcome.sideReads[0] + const side = read?.side ?? 'Yes' + const state = options?.state ?? (index === 0 ? 'held' : 'watch') + return createOutcomePositionData({ + market: formatOutcomeTitle(outcome), + side, + coin: read?.coin ?? '#200', + mark: markLabel(state, read?.probability ?? 50), + state, + }) + }) +} + +function toSdkOutcomeMarket(market: OutcomePatternMarketInput): UsdhOutcomeMarket { + const outcome = typeof market.id === 'number' ? market.id : Number(market.id) + const normalizedOutcome = Number.isSafeInteger(outcome) && outcome >= 0 ? outcome : 0 + return { + outcome: normalizedOutcome, + name: market.title, + description: '', + descriptionFields: {}, + sides: [ + toSdkOutcomeSideMarket(market.sides[0], normalizedOutcome, 0), + toSdkOutcomeSideMarket(market.sides[1], normalizedOutcome, 1), + ], + } +} + +function toSdkOutcomeSideMarket( + side: OutcomePatternSideInput, + outcome: number, + sideIndex: OutcomeSide, +): OutcomeSideMarket { + const encoding = outcomeEncodingFromCoin(side.coin) ?? 10 * outcome + sideIndex + return { + side: sideIndex, + name: side.label, + encoding, + coin: `#${encoding}`, + tokenName: `+${encoding}`, + assetId: 100_000_000 + encoding, + } +} + +function outcomeEncodingFromCoin(coin: string): number | null { + if (!coin.startsWith('#') && !coin.startsWith('+')) return null + const value = Number(coin.slice(1)) + return Number.isSafeInteger(value) && value >= 0 ? value : null +} + +function nextOutcomeCoin(coin: string): `#${number}` { + const encoding = outcomeEncodingFromCoin(coin) + return `#${encoding === null ? 201 : encoding + 1}` +} + +function toOutcomeBookRow(row: { price: string; size: string; depthPct: number }): OutcomeBookRow { + return { + price: row.price, + size: compactSize(row.size), + depthPct: row.depthPct, + } +} + +function bookFromSideRead(read: GalleryOutcome['sideReads'][number]): L2BookLike { + const depth = readDepthNumber(read.depth) + const bid = Number(read.bestBid) + const ask = Number(read.bestAsk) + const safeBid = Number.isFinite(bid) ? bid : 0.01 + const safeAsk = Number.isFinite(ask) ? ask : 0.99 + return { + levels: [ + [0, 1, 2].map((index) => ({ + px: Math.max(0.01, safeBid - index * 0.01).toFixed(2), + sz: String(Math.max(1, Math.round(depth / (index + 2)))), + })), + [0, 1, 2].map((index) => ({ + px: Math.min(0.99, safeAsk + index * 0.01).toFixed(2), + sz: String(Math.max(1, Math.round(depth / (index + 3)))), + })), + ], + } +} + +function readDepthNumber(depth: string): number { + const value = depth.replace(/[$,\s]/g, '').toLowerCase() + const multiplier = value.endsWith('m') ? 1_000_000 : value.endsWith('k') ? 1_000 : 1 + const parsed = Number(value.replace(/[mk]$/, '')) + return Number.isFinite(parsed) && parsed > 0 ? parsed * multiplier : 1_000 +} + +function compactSize(value: string) { + const parsed = Number(value.replace(/,/g, '')) + if (!Number.isFinite(parsed)) return value + if (parsed >= 1_000_000) return `${(parsed / 1_000_000).toFixed(1)}m` + if (parsed >= 1_000) return `${(parsed / 1_000).toFixed(1)}k` + return parsed.toFixed(0) +} + +function positionLabel(state: OutcomePositionData['state'], side: string) { + if (state === 'redeemable') return `125.0 ${side}` + if (state === 'settled') return `0.0 ${side}` + if (state === 'held') return `125.0 ${side}` + return 'watching' +} + +function markLabel(state: OutcomePositionData['state'], probability: number) { + if (state === 'redeemable') return 'Won' + if (state === 'settled') return 'Final' + return `${probability}%` +} + +function formatOutcomeTitle(outcome: GalleryOutcome) { + if (/^recurring/i.test(outcome.name) || /^binary market/i.test(outcome.name)) { + return fallbackOutcomeTitle(outcome.id) + } + return outcome.name +} + +function fallbackOutcomeTitle(id: number) { + const examples = [ + 'USDH weekly volume clears $5m', + 'HYPE weekly close green', + 'Protocol fee vote passes', + ] + return examples[Math.abs(id) % examples.length] ?? `Outcome market #${id}` +} diff --git a/apps/demo/src/components/registry/patterns/outcomes.tsx b/apps/demo/src/components/registry/patterns/outcomes.tsx new file mode 100644 index 0000000..e276bd0 --- /dev/null +++ b/apps/demo/src/components/registry/patterns/outcomes.tsx @@ -0,0 +1,696 @@ +'use client' + +import { ArrowUpRight, BookOpen, CheckCircle2, Coins, ListChecks, WalletCards } from 'lucide-react' +import { useState } from 'react' + +import { Button } from '@/components/ui/button' +import { CardContent } from '@/components/ui/card' +import { cn } from '@/lib/utils' + +import { Panel, PreviewShell, ProgressBar } from '../preview-primitives' + +export interface OutcomeSideQuote { + label: string + coin: string + probability: number | null + bestBid?: string + bestAsk?: string + depth?: string +} + +export type OutcomeSideIndex = 0 | 1 + +export interface OutcomeEventData { + id: number | string + title: string + subtitle: string + sides: [OutcomeSideQuote, OutcomeSideQuote] +} + +export interface OutcomePositionData { + market: string + side: string + coin: string + position: string + mark: string + state: 'held' | 'watch' | 'settled' | 'redeemable' +} + +export interface OutcomeBookRow { + price: string + size: string + depthPct: number +} + +export interface OutcomeOrderBookSideLevels { + bids: OutcomeBookRow[] + asks: OutcomeBookRow[] +} + +export function OutcomeLoadingCard() { + return ( + + + +
+
+
+
+
+
+
+
+
+
+
+
+
+ + + + ) +} + +export function OutcomeEventCard({ + event, + compact, + showBookContext, + selectedSideIndex, + onSideChange, +}: { + event: OutcomeEventData + compact?: boolean + showBookContext?: boolean + selectedSideIndex?: OutcomeSideIndex + onSideChange?: (quote: OutcomeSideQuote, index: OutcomeSideIndex) => void +}) { + const [internalSideIndex, setInternalSideIndex] = useState(0) + const sideIndex = selectedSideIndex ?? internalSideIndex + const selected = event.sides[sideIndex] ?? event.sides[0] + const [yes, no] = event.sides + const handleSideChange = (index: OutcomeSideIndex) => { + setInternalSideIndex(index) + onSideChange?.(event.sides[index], index) + } + + return ( + + + +
+
+
+ + Outcome event + #{event.id} +
+

{event.title}

+

{event.subtitle}

+
+ +
+ +
+
+
+
Selected side
+
+ {probabilityLabel(selected.probability)} +
+
+
+
{selected.label}
+
+ {priceLabel(selected.bestAsk)} +
+
+
+ +
+ +
+ {[yes, no].map((quote, index) => ( + handleSideChange(index as OutcomeSideIndex)} + /> + ))} +
+ + {showBookContext ? ( +
+ + + +
+ ) : ( +
+ No wallet write + Side coin resolved + Feed-ready +
+ )} +
+
+
+ ) +} + +export function OutcomeMarketRows({ + events, + selectedId, + onSelect, +}: { + events: OutcomeEventData[] + selectedId: number | string + onSelect: (id: number | string) => void +}) { + return ( + + + +
+
+ + Outcome market rows +
+
Read-only list pattern
+
+
+ {events.slice(0, 4).map((event) => ( + + ))} +
+
+
+
+ ) +} + +export function OutcomeOddsSelector({ + event, + disabled, + value, + onValueChange, +}: { + event: OutcomeEventData + disabled?: boolean + value?: string + onValueChange?: (coin: string, quote: OutcomeSideQuote, index: OutcomeSideIndex) => void +}) { + const [internalSideIndex, setInternalSideIndex] = useState(0) + const controlledIndex = value ? event.sides.findIndex((quote) => quote.coin === value) : -1 + const sideIndex = + controlledIndex === 0 || controlledIndex === 1 ? controlledIndex : internalSideIndex + const selected = event.sides[sideIndex] ?? event.sides[0] + const handleSideChange = (index: OutcomeSideIndex) => { + setInternalSideIndex(index) + const quote = event.sides[index] + onValueChange?.(quote.coin, quote, index) + } + + return ( + + + +
+
+
Outcome side selector
+

{event.title}

+
+ + {disabled ? 'locked' : 'read only'} + +
+ +
+ {event.sides.map((quote, index) => ( + + ))} +
+ +
+
+ + + +
+
+
+
+
+ ) +} + +export function OutcomeOrderBook({ + event, + empty, + sideIndex, + onSideChange, + levelsByCoin, +}: { + event: OutcomeEventData + empty?: boolean + sideIndex?: OutcomeSideIndex + onSideChange?: (quote: OutcomeSideQuote, index: OutcomeSideIndex) => void + levelsByCoin?: Record +}) { + const [internalSideIndex, setInternalSideIndex] = useState(0) + const activeSideIndex = sideIndex ?? internalSideIndex + const side = event.sides[activeSideIndex] ?? event.sides[0] + const suppliedLevels = levelsByCoin?.[side.coin] + const bids = empty ? [] : (suppliedLevels?.bids ?? bookRows(side, 'bid')) + const asks = empty ? [] : (suppliedLevels?.asks ?? bookRows(side, 'ask')) + const handleSideChange = (index: OutcomeSideIndex) => { + setInternalSideIndex(index) + onSideChange?.(event.sides[index], index) + } + + return ( + + + +
+
+
+ + Outcome order book +
+
{event.title}
+
+
+ {event.sides.map((quote, index) => ( + + ))} +
+
+ +
+ + +
+ +
+ + + +
+
+
+
+ ) +} + +export function OutcomePositionRows({ + positions, + compact, +}: { + positions: OutcomePositionData[] + compact?: boolean +}) { + const rows = positions.slice(0, compact ? 2 : 4) + + return ( + + + +
+
+
+ + Outcome positions +
+
+ Resolved side coins for portfolios. +
+
+
+ + no write action +
+
+
+ {rows.map((position) => ( + + ))} +
+
+
+
+ ) +} + +export function OutcomeEmptyList() { + return ( + + + +
+
+ +
+
No outcome markets
+

+ Keep the list calm when filters, live reads, or search return no rows. +

+
+
+
+
+ ) +} + +export function OutcomeEmptyPositions() { + return ( + + + +
+
+ +
+
No HIP-4 positions yet
+

+ Show an empty portfolio state instead of raw account data or a blank table. +

+
+
+
+
+ ) +} + +function SideQuoteButton({ + quote, + active, + tone, + onClick, +}: { + quote: OutcomeSideQuote + active: boolean + tone: 'green' | 'red' + onClick: () => void +}) { + return ( + + ) +} + +function SmallQuote({ quote, tone }: { quote: OutcomeSideQuote; tone: 'green' | 'red' }) { + return ( +
+
{quote.label}
+
+ {priceLabel(quote.bestAsk)} +
+
+ ) +} + +function PositionRow({ position }: { position: OutcomePositionData }) { + return ( +
+
+
{position.market}
+
+ {position.side} side + - + {position.coin} +
+
+ + + +
+ ) +} + +function BookSide({ + title, + rows, + tone, +}: { + title: string + rows: OutcomeBookRow[] + tone: 'green' | 'red' +}) { + return ( +
+
+ {title} + Shares +
+
+ {rows.map((row) => ( +
+ + + + {row.price} + + {row.size} + +
+ ))} +
+ {rows.length === 0 ? ( +
+ No levels +
+ ) : null} +
+ ) +} + +function BookCell({ label, value }: { label: string; value: string }) { + return ( +
+
+ {label} +
+
{value}
+
+ ) +} + +function MiniCell({ + label, + value, + tone, +}: { + label: string + value: string + tone?: 'green' +}) { + return ( +
+
+ {label} +
+
+ {value} +
+
+ ) +} + +function bookRows(read: OutcomeSideQuote, side: 'bid' | 'ask'): OutcomeBookRow[] { + const base = Number(side === 'bid' ? read.bestBid : read.bestAsk) + const fallback = side === 'bid' ? 0.68 : 0.72 + const anchor = Number.isFinite(base) ? base : fallback + const direction = side === 'bid' ? -1 : 1 + + return [0, 1, 2].map((index) => ({ + price: priceLabel(String(anchor + direction * index * 0.01)), + size: formatShares((3 - index) * 1840 + (read.probability ?? 50) * 12), + depthPct: 84 - index * 18, + })) +} + +function formatShares(value: number) { + if (value >= 1_000) return `${(value / 1_000).toFixed(1)}k` + return value.toFixed(0) +} + +function positionStateLabel(state: OutcomePositionData['state']) { + if (state === 'redeemable') return 'Redeem' + if (state === 'settled') return 'Settled' + if (state === 'held') return 'Held' + return 'Watch' +} + +function spreadLabel(read: OutcomeSideQuote) { + const bid = Number(read.bestBid) + const ask = Number(read.bestAsk) + if (!Number.isFinite(bid) || !Number.isFinite(ask) || bid <= 0) return '-' + return `${Math.abs((ask - bid) * 100).toFixed(1)}c` +} + +function priceLabel(value?: string) { + const parsed = Number(value) + if (!Number.isFinite(parsed)) return '-' + return `${Math.round(parsed * 100)}c` +} + +function probabilityLabel(value: number | null) { + return value === null ? '-' : `${value}%` +} + +function bookPriceLabel(read: OutcomeSideQuote) { + return `${priceLabel(read.bestBid)} / ${priceLabel(read.bestAsk)}` +} diff --git a/apps/demo/src/components/registry/preview-primitives.tsx b/apps/demo/src/components/registry/preview-primitives.tsx new file mode 100644 index 0000000..8c1fb61 --- /dev/null +++ b/apps/demo/src/components/registry/preview-primitives.tsx @@ -0,0 +1,339 @@ +import type { LucideIcon } from 'lucide-react' +import type { ReactNode } from 'react' + +import { Card } from '@/components/ui/card' +import { cn } from '@/lib/utils' + +import type { + GalleryBookLevel, + GalleryOutcome, + GalleryPair, + GallerySnapshot, +} from '../../lib/gallery-data' + +export type PreviewSize = 'full' | 'compact' +export type PreviewTone = 'neutral' | 'green' | 'red' | 'amber' | 'blue' + +export const fallbackPair: GalleryPair = { + name: '@230', + label: 'USDH/USDC', + index: 230, + role: 'base', + mid: '1.0002', +} + +export const fallbackOutcome: GalleryOutcome = { + id: 20, + name: 'USDH weekly volume clears $5m', + sides: ['Yes', 'No'], + coin: '#200', + sideCoins: ['#200', '#201'], + sideReads: [ + { + side: 'Yes', + coin: '#200', + probability: 70, + bestBid: '0.68', + bestAsk: '0.72', + depth: '$18.4k', + source: 'sample', + }, + { + side: 'No', + coin: '#201', + probability: 34, + bestBid: '0.32', + bestAsk: '0.36', + depth: '$12.1k', + source: 'sample', + }, + ], +} + +export function PreviewShell({ children, className }: { children: ReactNode; className?: string }) { + return ( +
{children}
+ ) +} + +export function PreviewHeader({ + icon: Icon, + title, + eyebrow, + right, +}: { + icon: LucideIcon + title: string + eyebrow: string + right?: ReactNode +}) { + return ( +
+
+ + + +
+
{eyebrow}
+

{title}

+
+
+ {right} +
+ ) +} + +export function StatusPill({ + children, + tone = 'neutral', +}: { + children: ReactNode + tone?: PreviewTone +}) { + return ( + + {children} + + ) +} + +export function MetricRow({ + label, + value, + tone = 'neutral', +}: { + label: string + value: string + tone?: PreviewTone +}) { + return ( +
+ {label} + + {value} + +
+ ) +} + +export function MetricList({ rows }: { rows: Array<[string, string, PreviewTone?]> }) { + return ( +
+ {rows.map(([label, value, tone]) => ( + + ))} +
+ ) +} + +export function MetricCard({ + label, + value, + tone = 'neutral', +}: { + label: string + value: string + tone?: PreviewTone +}) { + return ( +
+
+ {label} +
+
+ {value} +
+
+ ) +} + +export function ProgressBar({ + value, + tone = 'green', + className, +}: { + value: number + tone?: Exclude + className?: string +}) { + return ( +