Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f0a3a1569 | ||
|
|
1304900e2a | ||
|
|
f9a0da985e |
@@ -20,6 +20,8 @@
|
||||
"style",
|
||||
"bin",
|
||||
"finance",
|
||||
"src/dash/tokens.css",
|
||||
"src/world/tokens.css",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
@@ -280,6 +282,17 @@
|
||||
"import": "./dist/dash/*.mjs",
|
||||
"require": "./dist/dash/*.js"
|
||||
},
|
||||
"./world": {
|
||||
"types": "./dist/src/world/index.d.ts",
|
||||
"import": "./dist/world/index.mjs",
|
||||
"require": "./dist/world/index.js"
|
||||
},
|
||||
"./world/tokens.css": "./src/world/tokens.css",
|
||||
"./world/*": {
|
||||
"types": "./dist/src/world/*.d.ts",
|
||||
"import": "./dist/world/*.mjs",
|
||||
"require": "./dist/world/*.js"
|
||||
},
|
||||
"./billing": {
|
||||
"types": "./dist/src/billing/index.d.ts",
|
||||
"import": "./dist/billing/index.mjs",
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# generated render-proof artifacts
|
||||
.world-*
|
||||
@@ -0,0 +1,104 @@
|
||||
// Render proof for @hanzo/ui/world — SSRs the REAL compiled components
|
||||
// (from dist/world) with sample data and writes a static HTML page.
|
||||
// Usage: node scripts/world-proof.mjs
|
||||
import React from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
import {
|
||||
NewsStream,
|
||||
MarketTicker,
|
||||
InstabilityScore,
|
||||
PredictionMarket,
|
||||
} from '../dist/world/index.mjs'
|
||||
|
||||
const h = React.createElement
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const root = join(__dirname, '..')
|
||||
|
||||
const now = Date.now()
|
||||
const news = [
|
||||
{ title: 'Central bank holds rates, signals one cut before year-end', source: 'Reuters', category: 'macro', severity: 'info', location: 'Frankfurt', timestamp: now - 3 * 60_000, url: 'https://example.com/a' },
|
||||
{ title: 'Undersea cable fault disrupts regional connectivity', source: 'Bloomberg', category: 'infra', severity: 'high', location: 'Red Sea', timestamp: now - 22 * 60_000, url: 'https://example.com/b' },
|
||||
{ title: 'Border clashes escalate; observers report troop movement', source: 'AP', category: 'conflict', severity: 'critical', location: 'Eastern front', timestamp: now - 61 * 60_000, url: 'https://example.com/c' },
|
||||
{ title: 'Grid operator restores power to 2.1M after outage', source: 'AFP', severity: 'normal', location: 'Texas, US', timestamp: now - 5 * 3600_000, url: 'https://example.com/d' },
|
||||
]
|
||||
|
||||
const markets = [
|
||||
{ symbol: 'SPX', name: 'S&P 500', price: 5432.11, change: 0.82, sparkline: [12, 12.4, 12.1, 12.9, 13.2, 13.0, 13.6, 13.9] },
|
||||
{ symbol: 'BTC', name: 'Bitcoin', price: 61230.5, change: -2.14, sparkline: [64, 63.2, 63.6, 62.1, 61.8, 62.0, 61.2, 61.2] },
|
||||
{ symbol: 'VIX', name: 'Volatility', price: 14.22, change: -3.9, currency: '', sparkline: [17, 16.2, 15.8, 15.1, 14.9, 14.6, 14.3, 14.2] },
|
||||
{ symbol: 'GC', name: 'Gold (front)', price: 2338.4, change: 0.31, sparkline: [23.1, 23.3, 23.2, 23.35, 23.4, 23.3, 23.36, 23.38] },
|
||||
]
|
||||
|
||||
const prediction = [
|
||||
{ title: 'Fed cuts rates by Q3 2026?', yesPrice: 63, volume: 1_240_000, url: 'https://example.com/m1' },
|
||||
{ title: 'Ceasefire agreement signed this quarter?', yesPrice: 28, volume: 486_000, url: 'https://example.com/m2' },
|
||||
{ title: 'Incumbent wins re-election?', yesPrice: 91, volume: 3_100_000, url: 'https://example.com/m3' },
|
||||
]
|
||||
|
||||
const ukraine = { code: 'UA', name: 'Ukraine', score: 82, level: 'critical', trend: 'rising', change24h: 4, components: { unrest: 68, conflict: 94, security: 80, information: 72 } }
|
||||
const brazil = { code: 'BR', name: 'Brazil', score: 37, trend: 'falling', change24h: -3, components: { unrest: 44, conflict: 12, security: 41, information: 39 } }
|
||||
|
||||
function Card({ title, children }) {
|
||||
return h('div', { style: { display: 'flex', flexDirection: 'column', gap: 8 } },
|
||||
h('div', { style: { fontSize: 11, letterSpacing: '0.04em', textTransform: 'uppercase', color: '#666', fontFamily: 'var(--world-font-mono)' } }, title),
|
||||
children,
|
||||
)
|
||||
}
|
||||
|
||||
const body = renderToStaticMarkup(
|
||||
h('div', { style: { display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: 20, alignItems: 'start' } },
|
||||
h(Card, { title: '<NewsStream />' }, h(NewsStream, { title: 'Live intel', items: news, maxHeight: '360px' })),
|
||||
h(Card, { title: '<MarketTicker /> — list' }, h(MarketTicker, { title: 'Markets', items: markets })),
|
||||
h(Card, { title: '<MarketTicker variant="strip" />' }, h(MarketTicker, { variant: 'strip', items: markets })),
|
||||
h(Card, { title: '<PredictionMarket />' }, h(PredictionMarket, { title: 'Prediction markets', items: prediction })),
|
||||
h(Card, { title: '<InstabilityScore /> — critical' }, h(InstabilityScore, { data: ukraine })),
|
||||
h(Card, { title: '<InstabilityScore /> — moderate' }, h(InstabilityScore, { data: brazil })),
|
||||
h(Card, { title: '<NewsStream loading />' }, h(NewsStream, { title: 'Loading', items: [], loading: true })),
|
||||
),
|
||||
)
|
||||
|
||||
writeFileSync(join(root, 'scripts/.world-proof.body.html'), body)
|
||||
|
||||
// Assertions on the real rendered output (fail loudly if data didn't render).
|
||||
const checks = [
|
||||
['news title', body.includes('Undersea cable fault')],
|
||||
['market price', body.includes('$61,231') && body.includes('$5,432')],
|
||||
['change color up', body.includes('--world-up')],
|
||||
['instability score', body.includes('>82<')],
|
||||
['level pill', body.toLowerCase().includes('critical')],
|
||||
['prediction yes%', body.includes('Yes 63%') || body.includes('63%')],
|
||||
['mono font var', body.includes('var(--world-font-mono)')],
|
||||
]
|
||||
let ok = true
|
||||
for (const [name, pass] of checks) {
|
||||
console.log(`${pass ? 'PASS' : 'FAIL'} ${name}`)
|
||||
if (!pass) ok = false
|
||||
}
|
||||
|
||||
const tokens = readFileSync(join(root, 'src/world/tokens.css'), 'utf8')
|
||||
const bodyPath = join(root, 'scripts/.world-proof.body.html')
|
||||
|
||||
// Compile the REAL Tailwind utilities used by the components so the page
|
||||
// renders with the true Geist/black look (no hand-written CSS).
|
||||
const postcss = (await import('postcss')).default
|
||||
const tailwindcss = (await import('@tailwindcss/postcss')).default
|
||||
const twInput = `@import "tailwindcss";\n@source "${bodyPath}";`
|
||||
const tw = await postcss([tailwindcss()]).process(twInput, { from: join(root, 'scripts/.world-proof.in.css') })
|
||||
|
||||
const html = `<!doctype html>
|
||||
<html data-theme="dark"><head><meta charset="utf-8"><title>@hanzo/ui/world — render proof</title>
|
||||
<style>${tokens}
|
||||
${tw.css}
|
||||
body{background:var(--world-bg);color:var(--world-text);font-family:var(--world-font-sans);margin:0;padding:32px;}
|
||||
h1{font-size:14px;font-weight:500;color:var(--world-text-muted);margin:0 0 24px;letter-spacing:-0.01em;}
|
||||
</style></head>
|
||||
<body><h1>@hanzo/ui/world — flagship components (SSR of the compiled dist bundle, real Tailwind + Geist/black tokens)</h1>
|
||||
${body}</body></html>`
|
||||
writeFileSync(join(root, 'scripts/.world-proof.html'), html)
|
||||
console.log('wrote scripts/.world-proof.html')
|
||||
console.log(ok ? '\nSSR RENDER OK' : '\nSSR RENDER FAILED')
|
||||
process.exit(ok ? 0 : 1)
|
||||
@@ -0,0 +1,80 @@
|
||||
// Theming proof: the SAME compiled components re-skinned three ways, purely by
|
||||
// overriding --world-* custom properties (no component/prop changes).
|
||||
import React from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { chromium } from 'playwright'
|
||||
import postcss from 'postcss'
|
||||
import tailwindcss from '@tailwindcss/postcss'
|
||||
import { NewsStream, MarketTicker, InstabilityScore } from '../dist/world/index.mjs'
|
||||
|
||||
const h = React.createElement
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
|
||||
const news = [
|
||||
{ title: 'Central bank holds rates, signals one cut before year-end', source: 'Reuters', category: 'macro', severity: 'info', timestamp: Date.now() - 3 * 60_000, url: 'https://example.com/a' },
|
||||
{ title: 'Border clashes escalate; observers report troop movement', source: 'AP', category: 'conflict', severity: 'critical', timestamp: Date.now() - 61 * 60_000, url: 'https://example.com/c' },
|
||||
]
|
||||
const markets = [
|
||||
{ symbol: 'SPX', name: 'S&P 500', price: 5432.11, change: 0.82, sparkline: [12, 12.4, 12.1, 12.9, 13.2, 13.0, 13.6, 13.9] },
|
||||
{ symbol: 'BTC', name: 'Bitcoin', price: 61230.5, change: -2.14, sparkline: [64, 63.2, 63.6, 62.1, 61.8, 62.0, 61.2, 61.2] },
|
||||
]
|
||||
const score = { code: 'UA', name: 'Ukraine', score: 82, trend: 'rising', change24h: 4, components: { unrest: 68, conflict: 94 } }
|
||||
|
||||
function trio() {
|
||||
return h('div', { style: { display: 'flex', flexDirection: 'column', gap: 14 } },
|
||||
h(NewsStream, { title: 'Live intel', items: news }),
|
||||
h(MarketTicker, { title: 'Markets', items: markets }),
|
||||
h(InstabilityScore, { data: score, compact: true }),
|
||||
)
|
||||
}
|
||||
|
||||
// Column 1: default (Geist/black). Column 2: light theme (data-theme=light).
|
||||
// Column 3: a fictitious customer brand — override --world-* to inherit host tokens.
|
||||
const brand = [
|
||||
'--world-surface:#101725', '--world-bg:#0b0f1a', '--world-border:#22304a',
|
||||
'--world-text:#e6edf7', '--world-text-muted:#8aa0c0', '--world-text-dim:#5b7096',
|
||||
'--world-accent:#4f8cff', '--world-up:#22c55e', '--world-down:#f43f5e',
|
||||
'--world-radius:0.9rem', "--world-font-sans:'Inter',system-ui,sans-serif",
|
||||
].join(';')
|
||||
|
||||
const body = h('div', { style: { display: 'grid', gridTemplateColumns: 'repeat(3,1fr)', gap: 28 } },
|
||||
h('div', null, h('div', { className: 'lbl' }, 'default — Geist / black (world.hanzo.ai)'), h('div', {}, trio())),
|
||||
h('div', { 'data-theme': 'light', style: { background: 'var(--world-bg)', padding: 12, borderRadius: 12 } },
|
||||
h('div', { className: 'lbl' }, 'data-theme="light" — inherits light mode'), h('div', {}, trio())),
|
||||
h('div', { style: { cssText: '' } },
|
||||
h('div', { className: 'lbl' }, 'host brand — override --world-*'),
|
||||
h('div', { style: styleObj(brand) }, trio())),
|
||||
)
|
||||
|
||||
function styleObj(css) {
|
||||
const o = {}
|
||||
for (const decl of css.split(';')) {
|
||||
const [k, v] = decl.split(/:(.+)/)
|
||||
if (k && v) o[k.trim()] = v.trim()
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
const rendered = renderToStaticMarkup(body)
|
||||
const bodyPath = join(root, 'scripts/.world-theme.body.html')
|
||||
writeFileSync(bodyPath, rendered)
|
||||
|
||||
const tokens = readFileSync(join(root, 'src/world/tokens.css'), 'utf8')
|
||||
const tw = await postcss([tailwindcss()]).process(`@import "tailwindcss";\n@source "${bodyPath}";`, { from: join(root, 'scripts/.world-theme.in.css') })
|
||||
const html = `<!doctype html><html data-theme="dark"><head><meta charset="utf-8"><style>${tokens}\n${tw.css}
|
||||
body{background:#000;color:#e8e8e8;font-family:var(--world-font-sans);margin:0;padding:28px}
|
||||
.lbl{font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:#777;margin-bottom:10px;font-family:var(--world-font-mono,monospace)}
|
||||
[data-theme=light]{color:var(--world-text)}
|
||||
</style></head><body>${rendered}</body></html>`
|
||||
const htmlPath = join(root, 'scripts/.world-theme.html')
|
||||
writeFileSync(htmlPath, html)
|
||||
|
||||
const b = await chromium.launch()
|
||||
const p = await b.newPage({ viewport: { width: 1280, height: 620 }, deviceScaleFactor: 2 })
|
||||
await p.goto(pathToFileURL(htmlPath).href, { waitUntil: 'networkidle' })
|
||||
await p.screenshot({ path: 'scripts/.world-theme.png', fullPage: true })
|
||||
await b.close()
|
||||
console.log('theme proof written')
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, it, expect, vi, beforeAll } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
|
||||
import { CreditModal } from './credit-modal'
|
||||
|
||||
// Radix Dialog relies on pointer-capture + scrollIntoView, which happy-dom lacks.
|
||||
beforeAll(() => {
|
||||
if (!Element.prototype.hasPointerCapture) {
|
||||
Element.prototype.hasPointerCapture = () => false
|
||||
Element.prototype.setPointerCapture = () => {}
|
||||
Element.prototype.releasePointerCapture = () => {}
|
||||
}
|
||||
if (!Element.prototype.scrollIntoView) {
|
||||
Element.prototype.scrollIntoView = () => {}
|
||||
}
|
||||
})
|
||||
|
||||
describe('CreditModal', () => {
|
||||
it('renders nothing when closed', () => {
|
||||
render(<CreditModal open={false} onClose={() => {}} />)
|
||||
expect(screen.queryByText('Credits & balance')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the trial + prepaid buckets distinctly with a clear total', () => {
|
||||
render(
|
||||
<CreditModal open onClose={() => {}} trialBalanceCents={500} prepaidBalanceCents={1234} />,
|
||||
)
|
||||
// Two distinct buckets.
|
||||
expect(screen.getByText('$5.00')).toBeInTheDocument()
|
||||
expect(screen.getByText('$12.34')).toBeInTheDocument()
|
||||
// Combined breakdown + explicit total.
|
||||
expect(screen.getByText('$5.00 trial + $12.34 credits')).toBeInTheDocument()
|
||||
expect(screen.getByText('Total $17.34')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the welcome celebration for new users', () => {
|
||||
render(
|
||||
<CreditModal
|
||||
open
|
||||
onClose={() => {}}
|
||||
isNewUser
|
||||
trialGrantedCents={500}
|
||||
trialBalanceCents={500}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText("You've got $5.00 in free credits")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onTopUp with the preset amount in cents', () => {
|
||||
const onTopUp = vi.fn()
|
||||
render(<CreditModal open onClose={() => {}} onTopUp={onTopUp} topUpOptions={[1000, 2500]} />)
|
||||
fireEvent.click(screen.getByText('$25.00'))
|
||||
expect(onTopUp).toHaveBeenCalledWith(2500)
|
||||
})
|
||||
|
||||
it('converts a custom dollar amount to cents before calling onTopUp', () => {
|
||||
const onTopUp = vi.fn()
|
||||
render(<CreditModal open onClose={() => {}} onTopUp={onTopUp} topUpOptions={[]} />)
|
||||
fireEvent.change(screen.getByPlaceholderText('Custom amount'), { target: { value: '7.50' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||
expect(onTopUp).toHaveBeenCalledWith(750)
|
||||
})
|
||||
|
||||
it('does not call onTopUp while busy', () => {
|
||||
const onTopUp = vi.fn()
|
||||
render(<CreditModal open onClose={() => {}} onTopUp={onTopUp} topUpOptions={[1000]} busy />)
|
||||
fireEvent.click(screen.getByText('$10.00'))
|
||||
expect(onTopUp).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hides the top-up affordance when no onTopUp is supplied', () => {
|
||||
render(<CreditModal open onClose={() => {}} prepaidBalanceCents={1000} />)
|
||||
expect(screen.queryByText('Add credits')).toBeNull()
|
||||
})
|
||||
|
||||
it('surfaces an error message', () => {
|
||||
render(<CreditModal open onClose={() => {}} error="Card declined" />)
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Card declined')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,198 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '../../../primitives/dialog'
|
||||
|
||||
/**
|
||||
* Default preset top-up amounts, in cents ($10 / $25 / $50 / $100).
|
||||
* Callers may override via `topUpOptions`.
|
||||
*/
|
||||
const DEFAULT_TOP_UP_OPTIONS = [1000, 2500, 5000, 10000]
|
||||
|
||||
export interface CreditModalProps {
|
||||
/** Controls visibility. */
|
||||
open: boolean
|
||||
/** Fired on escape, overlay click, or the close affordance. */
|
||||
onClose: () => void
|
||||
/** Non-cash promotional trial credit remaining, in cents. */
|
||||
trialBalanceCents?: number
|
||||
/** Real (paid) prepaid credit remaining, in cents. */
|
||||
prepaidBalanceCents?: number
|
||||
/** Trial credit originally granted, in cents — drives the welcome copy. */
|
||||
trialGrantedCents?: number
|
||||
/** ISO 4217 currency code. */
|
||||
currency?: string
|
||||
/** Preset top-up amounts, in cents. */
|
||||
topUpOptions?: number[]
|
||||
/**
|
||||
* Injected top-up handler. The Square/HUSD payment flow lives in the caller;
|
||||
* this modal only presents amounts + buckets, then hands back the chosen cents.
|
||||
* Omit to render a read-only balance view.
|
||||
*/
|
||||
onTopUp?: (amountCents: number) => void | Promise<void>
|
||||
/** First-run celebration state (e.g. trial credit just landed). */
|
||||
isNewUser?: boolean
|
||||
/** Disables actions while a payment/settlement is in flight. */
|
||||
busy?: boolean
|
||||
/** Error to surface (e.g. a failed charge). */
|
||||
error?: string | null
|
||||
/**
|
||||
* Optional caller-injected payment surface (e.g. <SquareCardForm/>),
|
||||
* rendered below the amount picker.
|
||||
*/
|
||||
children?: React.ReactNode
|
||||
/** Overrides the default (non-welcome) heading. */
|
||||
title?: string
|
||||
}
|
||||
|
||||
function formatCents(cents: number, currency: string) {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency,
|
||||
minimumFractionDigits: 2,
|
||||
}).format((cents || 0) / 100)
|
||||
}
|
||||
|
||||
export function CreditModal({
|
||||
open,
|
||||
onClose,
|
||||
trialBalanceCents = 0,
|
||||
prepaidBalanceCents = 0,
|
||||
trialGrantedCents,
|
||||
currency = 'usd',
|
||||
topUpOptions = DEFAULT_TOP_UP_OPTIONS,
|
||||
onTopUp,
|
||||
isNewUser = false,
|
||||
busy = false,
|
||||
error,
|
||||
children,
|
||||
title = 'Credits & balance',
|
||||
}: CreditModalProps) {
|
||||
const [custom, setCustom] = React.useState('')
|
||||
|
||||
const totalCents = trialBalanceCents + prepaidBalanceCents
|
||||
const welcomeCents = trialGrantedCents ?? trialBalanceCents
|
||||
const showWelcome = isNewUser && welcomeCents > 0
|
||||
|
||||
const customCents = React.useMemo(() => {
|
||||
const dollars = Number.parseFloat(custom)
|
||||
if (!Number.isFinite(dollars) || dollars <= 0) return 0
|
||||
return Math.round(dollars * 100)
|
||||
}, [custom])
|
||||
|
||||
const topUp = React.useCallback(
|
||||
(cents: number) => {
|
||||
if (busy || cents <= 0 || !onTopUp) return
|
||||
void onTopUp(cents)
|
||||
},
|
||||
[busy, onTopUp],
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => { if (!next) onClose() }}>
|
||||
<DialogContent className="max-w-md border-border bg-bg-card text-text">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-text">
|
||||
{showWelcome
|
||||
? `You've got ${formatCents(welcomeCents, currency)} in free credits`
|
||||
: title}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-text-muted">
|
||||
{showWelcome
|
||||
? 'Start building — trial credit is applied automatically before any charge.'
|
||||
: 'Trial credit is applied before your prepaid balance.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Two distinct buckets: promo trial credit vs. real prepaid money */}
|
||||
<div className="overflow-hidden rounded-xl border border-border bg-bg">
|
||||
<div className="grid grid-cols-2 divide-x divide-border">
|
||||
<div className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-text-muted">Trial</p>
|
||||
<span className="rounded-full bg-warning/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-warning">
|
||||
Promo
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-2xl font-bold text-text">{formatCents(trialBalanceCents, currency)}</p>
|
||||
<p className="mt-0.5 text-xs text-text-dim">Non-cash credit</p>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-text-muted">Prepaid</p>
|
||||
<p className="mt-1 text-2xl font-bold text-text">{formatCents(prepaidBalanceCents, currency)}</p>
|
||||
<p className="mt-0.5 text-xs text-text-dim">Paid credit</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-border px-4 py-3">
|
||||
<span className="text-sm text-text-muted">
|
||||
{formatCents(trialBalanceCents, currency)} trial + {formatCents(prepaidBalanceCents, currency)} credits
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-text">Total {formatCents(totalCents, currency)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top-up affordance. The payment execution is injected via onTopUp. */}
|
||||
{onTopUp && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium text-text">Add credits</p>
|
||||
{topUpOptions.length > 0 && (
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{topUpOptions.map((cents) => (
|
||||
<button
|
||||
key={cents}
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => topUp(cents)}
|
||||
className="rounded-lg border border-border bg-bg px-3 py-2 text-sm font-semibold text-text transition hover:bg-bg-elevated disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{formatCents(cents, currency)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm text-text-dim">$</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
inputMode="decimal"
|
||||
value={custom}
|
||||
onChange={(e) => setCustom(e.target.value)}
|
||||
placeholder="Custom amount"
|
||||
disabled={busy}
|
||||
className="w-full rounded-lg border border-border bg-bg py-2 pl-7 pr-3 text-sm text-text placeholder:text-text-dim focus:border-brand focus:outline-none disabled:opacity-60"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || customCents <= 0}
|
||||
onClick={() => topUp(customCents)}
|
||||
className="rounded-lg bg-brand px-5 py-2 text-sm font-semibold text-brand-foreground transition hover:bg-brand-hover active:scale-[0.97] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{busy ? 'Processing…' : 'Add'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Injected payment surface (e.g. <SquareCardForm/> or an HUSD flow). */}
|
||||
{children}
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -25,6 +25,9 @@ export type { UsagePanelProps } from './usage-panel'
|
||||
export { CreditsPanel } from './credits-panel'
|
||||
export type { CreditsPanelProps } from './credits-panel'
|
||||
|
||||
export { CreditModal } from './credit-modal'
|
||||
export type { CreditModalProps } from './credit-modal'
|
||||
|
||||
export { TransactionsPanel } from './transactions-panel'
|
||||
export type { TransactionsPanelProps } from './transactions-panel'
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
# @hanzo/ui/world
|
||||
|
||||
Embeddable, OSS data-stream components ported from **hanzoai/world**
|
||||
(world.hanzo.ai). Drop a live news feed, market ticker, instability gauge, or
|
||||
prediction-market widget into any Hanzo site or customer app.
|
||||
|
||||
Self-contained React + TypeScript, MIT, no external runtime deps beyond the
|
||||
`@hanzo/ui` peers (`react`, `react-dom`). Default look = the world.hanzo.ai
|
||||
Geist/black-monochrome Vercel aesthetic; re-skins to any host theme by
|
||||
overriding CSS custom properties.
|
||||
|
||||
```bash
|
||||
npm i @hanzo/ui # published to npm as @hanzo/ui (public)
|
||||
```
|
||||
|
||||
```tsx
|
||||
import '@hanzo/ui/world/tokens.css' // once, at app root
|
||||
import { NewsStream, MarketTicker } from '@hanzo/ui/world'
|
||||
|
||||
<NewsStream title="Live intel" items={items} maxHeight="480px" />
|
||||
<MarketTicker title="Markets" items={quotes} />
|
||||
```
|
||||
|
||||
Sub-path imports keep bundles small:
|
||||
`@hanzo/ui/world/news-stream`, `/market-ticker`, `/instability-score`,
|
||||
`/prediction-market`, `/hooks`, `/format`, `/types`.
|
||||
|
||||
## Components
|
||||
|
||||
| Component | Data prop | Archetype |
|
||||
|---|---|---|
|
||||
| `<NewsStream>` | `items: NewsStreamItem[]` | scrolling feed with severity rails |
|
||||
| `<MarketTicker>` | `items: MarketQuote[]`, `variant: 'list' \| 'strip'` | numeric ticker + inline sparkline |
|
||||
| `<InstabilityScore>` | `data: InstabilityScoreData` | 0–100 gauge card, level + trend + drivers |
|
||||
| `<PredictionMarket>` | `items: PredictionMarketItem[]` | yes/no probability bars |
|
||||
|
||||
Every component is **pure props** and accepts `loading` / `error` /
|
||||
`emptyMessage` / `className`. Numeric fields keep `| null` as the missing-data
|
||||
sentinel (components render `N/A`, never a fake `0`).
|
||||
|
||||
### Data: props OR the /v1/world hooks
|
||||
|
||||
Feed data yourself, or pull it from the Hanzo World API with the built-in hooks
|
||||
(native `fetch`, polling, abort-on-unmount, no extra deps):
|
||||
|
||||
```tsx
|
||||
import { NewsStream, useNewsStream } from '@hanzo/ui/world'
|
||||
|
||||
function Feed() {
|
||||
const { data, loading, error } = useNewsStream({ baseUrl: 'https://api.hanzo.ai' })
|
||||
return <NewsStream title="Live intel" items={data ?? []} loading={loading} error={error} />
|
||||
}
|
||||
```
|
||||
|
||||
`useWorldData<T>(path, opts)` is the generic primitive; `useNewsStream`,
|
||||
`useMarketTicker`, `usePredictionMarkets`, `useInstabilityScores` are typed
|
||||
wrappers over `GET {baseUrl}/v1/world/<path>`. Each unwraps `[]`,
|
||||
`{ items }`, or `{ data, unavailable }` envelopes.
|
||||
|
||||
## Theming — default Vercel/Geist, or inherit the host
|
||||
|
||||
Every visible style reads a `--world-*` custom property. `tokens.css` ships the
|
||||
Geist/black default and a `[data-theme="light"]` / `.light` override. To re-skin
|
||||
onto a host design system, point the tokens at the host's own variables — no
|
||||
component or prop changes:
|
||||
|
||||
```css
|
||||
:root {
|
||||
--world-bg: var(--background);
|
||||
--world-surface: var(--card);
|
||||
--world-text: var(--foreground);
|
||||
--world-accent: var(--primary);
|
||||
--world-font-sans: var(--font-sans);
|
||||
--world-radius: 0.9rem;
|
||||
}
|
||||
```
|
||||
|
||||
Geist fonts are **not** bundled — host apps self-host `'Geist'` / `'Geist Mono'`
|
||||
(the tokens fall back to `system-ui` / `SF Mono` when absent).
|
||||
|
||||
Proven three ways (default black / light / host brand) by the render scripts in
|
||||
`scripts/world-proof.mjs` and `scripts/world-theme-proof.mjs` — they SSR the
|
||||
compiled `dist` bundle, compile the real Tailwind, and screenshot it.
|
||||
|
||||
## Adding the next panel (mechanical fan-out)
|
||||
|
||||
The 4 flagship components establish the pattern. To port another world panel:
|
||||
|
||||
1. Add the prop type to `types.ts` (mirror the world `services/*` return shape).
|
||||
2. Write `src/world/<name>.tsx` — pure props, Tailwind classes reading
|
||||
`var(--world-*)`, `cn()` from `../../utils`, a `className` passthrough,
|
||||
`loading`/`error`/empty states. Numerics use `[font-family:var(--world-font-mono)]`.
|
||||
3. Re-export from `index.ts`; register the entry in
|
||||
`tsup.config.minimal.ts` and the `./world/*` export already covers package.json.
|
||||
4. Optional: add a typed hook in `hooks.ts`.
|
||||
|
||||
**High-value next ports** (from the upstream survey, all map-free and
|
||||
single-source): FRED econ indicators (`EconomicPanel` → `services/fred.ts`),
|
||||
the self-fetching finance trio (macro-signals / ETF-flows / stablecoin
|
||||
peg-health — endpoint+component pairs), an AI-insight card, and a research feed
|
||||
(arXiv / HackerNews / GitHub-trending). Reimplement independently against the
|
||||
public data source — never copy AGPL upstream.
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* @hanzo/ui/world - Formatting + safety helpers
|
||||
*
|
||||
* Pure, dependency-free. Numeric formatting mirrors the hanzoai/world panels
|
||||
* so ported components read identically to world.hanzo.ai.
|
||||
*/
|
||||
|
||||
import type { InstabilityLevel } from './types'
|
||||
|
||||
/** Price with adaptive precision (sub-1 values keep more decimals). */
|
||||
export function formatPrice(value: number | null | undefined, currency = '$'): string {
|
||||
if (value == null || Number.isNaN(value)) return 'N/A'
|
||||
const abs = Math.abs(value)
|
||||
const digits = abs >= 1000 ? 0 : abs >= 1 ? 2 : abs >= 0.01 ? 4 : 6
|
||||
return currency + value.toLocaleString('en-US', { minimumFractionDigits: digits, maximumFractionDigits: digits })
|
||||
}
|
||||
|
||||
/** Signed percent change, e.g. "+1.24%" / "-0.80%". */
|
||||
export function formatChange(change: number | null | undefined): string {
|
||||
if (change == null || Number.isNaN(change)) return '—'
|
||||
const sign = change > 0 ? '+' : ''
|
||||
return `${sign}${change.toFixed(2)}%`
|
||||
}
|
||||
|
||||
/** Compact magnitude, e.g. 1_240_000 -> "1.2M". */
|
||||
export function formatCompact(value: number | null | undefined): string {
|
||||
if (value == null || Number.isNaN(value)) return ''
|
||||
const abs = Math.abs(value)
|
||||
if (abs >= 1_000_000_000) return `${(value / 1_000_000_000).toFixed(1)}B`
|
||||
if (abs >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`
|
||||
if (abs >= 1_000) return `${(value / 1_000).toFixed(0)}K`
|
||||
return `${value}`
|
||||
}
|
||||
|
||||
/** USD volume, e.g. "$1.2M". Empty string for falsy. */
|
||||
export function formatVolume(volume: number | null | undefined): string {
|
||||
if (!volume) return ''
|
||||
return `$${formatCompact(volume)}`
|
||||
}
|
||||
|
||||
/** Human relative time, e.g. "3m", "2h", "5d". */
|
||||
export function formatRelativeTime(input: string | number | Date | undefined): string {
|
||||
if (input == null) return ''
|
||||
const then = input instanceof Date ? input.getTime() : new Date(input).getTime()
|
||||
if (Number.isNaN(then)) return ''
|
||||
const secs = Math.max(0, Math.floor((Date.now() - then) / 1000))
|
||||
if (secs < 60) return `${secs}s`
|
||||
const mins = Math.floor(secs / 60)
|
||||
if (mins < 60) return `${mins}m`
|
||||
const hours = Math.floor(mins / 60)
|
||||
if (hours < 24) return `${hours}h`
|
||||
const days = Math.floor(hours / 24)
|
||||
if (days < 7) return `${days}d`
|
||||
return new Date(then).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Boundary validation: only allow http/https links. Returns undefined for
|
||||
* anything else (javascript:, data:, relative, malformed) so callers render
|
||||
* plain text instead of an unsafe anchor.
|
||||
*/
|
||||
export function safeUrl(url: string | undefined): string | undefined {
|
||||
if (!url) return undefined
|
||||
try {
|
||||
const u = new URL(url, 'https://invalid.local')
|
||||
if (u.protocol === 'http:' || u.protocol === 'https:') {
|
||||
// Reject the relative-resolution sentinel origin.
|
||||
if (u.hostname === 'invalid.local' && !/^https?:\/\//i.test(url)) return undefined
|
||||
return u.href
|
||||
}
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** SVG polyline points for a sparkline within a w×h box. */
|
||||
export function sparklinePoints(data: number[], w: number, h: number): string {
|
||||
if (!data || data.length < 2) return ''
|
||||
const min = Math.min(...data)
|
||||
const max = Math.max(...data)
|
||||
const range = max - min || 1
|
||||
return data
|
||||
.map((v, i) => {
|
||||
const x = (i / (data.length - 1)) * w
|
||||
const y = h - ((v - min) / range) * (h - 2) - 1
|
||||
return `${x.toFixed(1)},${y.toFixed(1)}`
|
||||
})
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
/** Derive a severity band from a 0-100 instability score. */
|
||||
export function instabilityLevel(score: number): InstabilityLevel {
|
||||
if (score >= 80) return 'critical'
|
||||
if (score >= 60) return 'high'
|
||||
if (score >= 40) return 'elevated'
|
||||
if (score >= 20) return 'normal'
|
||||
return 'low'
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import type { PanelState, NewsStreamItem, MarketQuote, PredictionMarketItem, InstabilityScoreData } from './types'
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* useWorldData */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export interface UseWorldDataOptions {
|
||||
/**
|
||||
* API origin. Default `''` → same-origin (`/v1/world/...`). Point at
|
||||
* `https://api.hanzo.ai` (or a customer gateway) to embed cross-origin.
|
||||
*/
|
||||
baseUrl?: string
|
||||
/** Poll interval in ms. Omit / 0 to fetch once. */
|
||||
refreshMs?: number
|
||||
/** Query params appended to the request. */
|
||||
params?: Record<string, string | number | boolean | undefined>
|
||||
/** Set false to skip fetching (e.g. while a parent gate is closed). */
|
||||
enabled?: boolean
|
||||
/** Override fetch (SSR / auth-injecting wrappers). */
|
||||
fetcher?: typeof fetch
|
||||
/** Extra request headers (e.g. Authorization). */
|
||||
headers?: Record<string, string>
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch + poll a `/v1/world/<path>` endpoint into a normalized PanelState.
|
||||
* Accepts a bare array, `{ items: [...] }`, or `{ data: ... , unavailable? }`.
|
||||
*/
|
||||
export function useWorldData<T>(path: string, options: UseWorldDataOptions = {}): PanelState<T> & { refresh: () => void } {
|
||||
const { baseUrl = '', refreshMs, params, enabled = true, fetcher, headers } = options
|
||||
|
||||
const [state, setState] = React.useState<PanelState<T>>({ data: null, loading: enabled, error: null })
|
||||
|
||||
// Serialize params so the effect only re-runs on real changes.
|
||||
const paramsKey = params ? JSON.stringify(params) : ''
|
||||
const headersKey = headers ? JSON.stringify(headers) : ''
|
||||
|
||||
const url = React.useMemo(() => {
|
||||
const base = baseUrl.replace(/\/$/, '')
|
||||
const clean = path.replace(/^\//, '')
|
||||
const u = new URL(`${base}/v1/world/${clean}`, base || 'http://local.invalid')
|
||||
if (params) {
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v !== undefined) u.searchParams.set(k, String(v))
|
||||
}
|
||||
}
|
||||
// Same-origin: emit a relative URL so it works in the browser.
|
||||
return base ? u.href : `${u.pathname}${u.search}`
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [baseUrl, path, paramsKey])
|
||||
|
||||
const [nonce, setNonce] = React.useState(0)
|
||||
const refresh = React.useCallback(() => setNonce((n) => n + 1), [])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!enabled) {
|
||||
setState({ data: null, loading: false, error: null })
|
||||
return
|
||||
}
|
||||
const controller = new AbortController()
|
||||
let active = true
|
||||
const doFetch = fetcher ?? fetch
|
||||
|
||||
async function run() {
|
||||
try {
|
||||
setState((s) => ({ ...s, loading: true, error: null }))
|
||||
const res = await doFetch(url, { signal: controller.signal, headers })
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const json = (await res.json()) as unknown
|
||||
if (!active) return
|
||||
const { data, unavailable } = normalize<T>(json)
|
||||
setState({ data, loading: false, error: null, unavailable })
|
||||
} catch (err) {
|
||||
if (!active || controller.signal.aborted) return
|
||||
setState({ data: null, loading: false, error: err instanceof Error ? err.message : 'Request failed' })
|
||||
}
|
||||
}
|
||||
|
||||
run()
|
||||
const timer = refreshMs && refreshMs > 0 ? setInterval(run, refreshMs) : null
|
||||
return () => {
|
||||
active = false
|
||||
controller.abort()
|
||||
if (timer) clearInterval(timer)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [url, enabled, refreshMs, nonce, headersKey])
|
||||
|
||||
return { ...state, refresh }
|
||||
}
|
||||
|
||||
/** Unwrap the common envelopes into `{ data, unavailable }`. */
|
||||
function normalize<T>(json: unknown): { data: T | null; unavailable?: boolean } {
|
||||
if (json && typeof json === 'object' && !Array.isArray(json)) {
|
||||
const obj = json as Record<string, unknown>
|
||||
const unavailable = obj.unavailable === true
|
||||
if ('items' in obj) return { data: obj.items as T, unavailable }
|
||||
if ('data' in obj) return { data: obj.data as T, unavailable }
|
||||
return { data: json as T, unavailable }
|
||||
}
|
||||
return { data: json as T }
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Typed convenience hooks */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** News feed → `NewsStreamItem[]`. Default 60s poll. */
|
||||
export function useNewsStream(options: UseWorldDataOptions = {}) {
|
||||
return useWorldData<NewsStreamItem[]>('news', { refreshMs: 60_000, ...options })
|
||||
}
|
||||
|
||||
/** Market quotes → `MarketQuote[]`. Default 30s poll. */
|
||||
export function useMarketTicker(options: UseWorldDataOptions = {}) {
|
||||
return useWorldData<MarketQuote[]>('markets', { refreshMs: 30_000, ...options })
|
||||
}
|
||||
|
||||
/** Prediction markets → `PredictionMarketItem[]`. Default 60s poll. */
|
||||
export function usePredictionMarkets(options: UseWorldDataOptions = {}) {
|
||||
return useWorldData<PredictionMarketItem[]>('predictions', { refreshMs: 60_000, ...options })
|
||||
}
|
||||
|
||||
/** Instability scores → `InstabilityScoreData[]`. Default 5m poll. */
|
||||
export function useInstabilityScores(options: UseWorldDataOptions = {}) {
|
||||
return useWorldData<InstabilityScoreData[]>('instability', { refreshMs: 300_000, ...options })
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* @hanzo/ui/world
|
||||
*
|
||||
* Reusable, embeddable data-stream components ported from hanzoai/world
|
||||
* (world.hanzo.ai). Self-contained React + TS, MIT, no external deps beyond
|
||||
* @hanzo/ui peers. Feed them data via props or the `useWorldData` hooks that
|
||||
* hit the /v1/world API.
|
||||
*
|
||||
* Default look = the world.hanzo.ai Geist/black-monochrome aesthetic. Re-skin
|
||||
* to any host by overriding the `--world-*` custom properties — import the
|
||||
* tokens once:
|
||||
*
|
||||
* import '@hanzo/ui/world/tokens.css'
|
||||
* import { NewsStream, MarketTicker } from '@hanzo/ui/world'
|
||||
*/
|
||||
|
||||
// Components
|
||||
export { NewsStream } from './news-stream'
|
||||
export type { NewsStreamProps } from './news-stream'
|
||||
|
||||
export { MarketTicker } from './market-ticker'
|
||||
export type { MarketTickerProps } from './market-ticker'
|
||||
|
||||
export { InstabilityScore } from './instability-score'
|
||||
export type { InstabilityScoreProps } from './instability-score'
|
||||
|
||||
export { PredictionMarket } from './prediction-market'
|
||||
export type { PredictionMarketProps } from './prediction-market'
|
||||
|
||||
// Data hooks
|
||||
export {
|
||||
useWorldData,
|
||||
useNewsStream,
|
||||
useMarketTicker,
|
||||
usePredictionMarkets,
|
||||
useInstabilityScores,
|
||||
} from './hooks'
|
||||
export type { UseWorldDataOptions } from './hooks'
|
||||
|
||||
// Formatting + safety helpers (reusable in host cells / custom renders)
|
||||
export {
|
||||
formatPrice,
|
||||
formatChange,
|
||||
formatCompact,
|
||||
formatVolume,
|
||||
formatRelativeTime,
|
||||
safeUrl,
|
||||
sparklinePoints,
|
||||
instabilityLevel,
|
||||
} from './format'
|
||||
|
||||
// Types
|
||||
export type {
|
||||
PanelState,
|
||||
WorldSeverity,
|
||||
NewsStreamItem,
|
||||
MarketQuote,
|
||||
InstabilityLevel,
|
||||
InstabilityTrend,
|
||||
InstabilityComponents,
|
||||
InstabilityScoreData,
|
||||
PredictionMarketItem,
|
||||
} from './types'
|
||||
@@ -0,0 +1,171 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { cn } from '../../utils'
|
||||
import type { InstabilityScoreData, InstabilityLevel, InstabilityComponents } from './types'
|
||||
import { instabilityLevel } from './format'
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Level → token + label */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const LEVEL: Record<InstabilityLevel, { color: string; label: string }> = {
|
||||
low: { color: 'var(--world-normal)', label: 'Low' },
|
||||
normal: { color: 'var(--world-normal)', label: 'Moderate' },
|
||||
elevated: { color: 'var(--world-elevated)', label: 'Elevated' },
|
||||
high: { color: 'var(--world-high)', label: 'High' },
|
||||
critical: { color: 'var(--world-critical)', label: 'Critical' },
|
||||
}
|
||||
|
||||
const COMPONENT_LABEL: Record<keyof InstabilityComponents, string> = {
|
||||
unrest: 'Unrest',
|
||||
conflict: 'Conflict',
|
||||
security: 'Security',
|
||||
information: 'Information',
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Props */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export interface InstabilityScoreProps {
|
||||
/** Score data. `score` is required; `level` is derived if omitted. */
|
||||
data: InstabilityScoreData
|
||||
/** Compact layout (no component breakdown, smaller number). */
|
||||
compact?: boolean
|
||||
loading?: boolean
|
||||
error?: string | null
|
||||
onClick?: (data: InstabilityScoreData) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Component */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Instability / risk gauge card. Renders a 0-100 composite score with a
|
||||
* level band, trend, 24h delta and an optional per-driver breakdown.
|
||||
* The score is computed upstream (app or /v1/world) and passed in as data.
|
||||
*
|
||||
* @example
|
||||
* <InstabilityScore data={{ name: 'Ukraine', score: 82, trend: 'rising', change24h: 4 }} />
|
||||
*/
|
||||
export function InstabilityScore({ data, compact = false, loading = false, error = null, onClick, className }: InstabilityScoreProps) {
|
||||
const score = Math.max(0, Math.min(100, Math.round(data.score)))
|
||||
const level = data.level ?? instabilityLevel(score)
|
||||
const { color, label } = LEVEL[level]
|
||||
|
||||
const shell = cn(
|
||||
'flex flex-col gap-3 overflow-hidden rounded-[var(--world-radius)] border border-[var(--world-border)]',
|
||||
'bg-[var(--world-surface)] p-3 text-[var(--world-text)] [font-family:var(--world-font-sans)]',
|
||||
onClick && 'cursor-pointer transition-colors hover:bg-[var(--world-surface-hover)]',
|
||||
className,
|
||||
)
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{error ? (
|
||||
<div className="py-6 text-center text-sm text-[var(--world-text-dim)]">{error}</div>
|
||||
) : loading ? (
|
||||
<div className="space-y-3">
|
||||
<div className="h-3 w-24 rounded bg-[var(--world-surface-active)]" />
|
||||
<div className="h-8 w-16 rounded bg-[var(--world-surface-active)]" />
|
||||
<div className="h-1.5 w-full rounded bg-[var(--world-surface-hover)]" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Header: name + level pill */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 truncate">
|
||||
{data.code && (
|
||||
<span className="rounded-[var(--world-radius-sm)] bg-[var(--world-surface-active)] px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-[var(--world-text-muted)] [font-family:var(--world-font-mono)]">
|
||||
{data.code}
|
||||
</span>
|
||||
)}
|
||||
{data.name && <span className="truncate text-sm font-medium text-[var(--world-text)]">{data.name}</span>}
|
||||
</div>
|
||||
<span
|
||||
className="rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide"
|
||||
style={{ color, background: 'color-mix(in srgb, ' + color + ' 14%, transparent)' }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Score + trend */}
|
||||
<div className="flex items-end justify-between">
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span
|
||||
className={cn('tabular-nums leading-none [font-family:var(--world-font-mono)]', compact ? 'text-2xl' : 'text-4xl')}
|
||||
style={{ color }}
|
||||
>
|
||||
{score}
|
||||
</span>
|
||||
<span className="text-xs text-[var(--world-text-dim)]">/100</span>
|
||||
</div>
|
||||
{(data.trend || typeof data.change24h === 'number') && <Trend trend={data.trend} change24h={data.change24h} />}
|
||||
</div>
|
||||
|
||||
{/* Meter */}
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-[var(--world-surface-active)]">
|
||||
<div className="h-full rounded-full transition-[width]" style={{ width: `${score}%`, background: color }} />
|
||||
</div>
|
||||
|
||||
{/* Component breakdown */}
|
||||
{!compact && data.components && <Components components={data.components} />}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
if (onClick && !loading && !error) {
|
||||
return (
|
||||
<button type="button" onClick={() => onClick(data)} className={cn(shell, 'text-left')}>
|
||||
{content}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
return <div className={shell}>{content}</div>
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Trend + components */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function Trend({ trend, change24h }: { trend?: InstabilityScoreData['trend']; change24h?: number }) {
|
||||
// Rising instability is worse (high), falling is improving (normal).
|
||||
const color = trend === 'rising' ? 'var(--world-high)' : trend === 'falling' ? 'var(--world-normal)' : 'var(--world-text-dim)'
|
||||
const arrow = trend === 'rising' ? '↑' : trend === 'falling' ? '↓' : '→'
|
||||
const delta = typeof change24h === 'number' ? `${change24h > 0 ? '+' : ''}${change24h}` : null
|
||||
return (
|
||||
<div className="flex items-center gap-1 text-xs [font-family:var(--world-font-mono)]" style={{ color }}>
|
||||
<span aria-hidden>{arrow}</span>
|
||||
{delta && <span className="tabular-nums">{delta}</span>}
|
||||
<span className="text-[10px] text-[var(--world-text-dim)]">24h</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Components({ components }: { components: InstabilityComponents }) {
|
||||
const entries = (Object.keys(COMPONENT_LABEL) as Array<keyof InstabilityComponents>)
|
||||
.filter((k) => typeof components[k] === 'number')
|
||||
.map((k) => [k, components[k] as number] as const)
|
||||
if (entries.length === 0) return null
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-1.5 border-t border-[var(--world-border)] pt-3">
|
||||
{entries.map(([key, value]) => {
|
||||
const v = Math.max(0, Math.min(100, Math.round(value)))
|
||||
return (
|
||||
<div key={key} className="flex items-center gap-2">
|
||||
<span className="w-16 shrink-0 text-[10px] uppercase tracking-wide text-[var(--world-text-dim)]">{COMPONENT_LABEL[key]}</span>
|
||||
<div className="h-1 flex-1 overflow-hidden rounded-full bg-[var(--world-surface-active)]">
|
||||
<div className="h-full rounded-full bg-[var(--world-text-muted)]" style={{ width: `${v}%` }} />
|
||||
</div>
|
||||
<span className="w-6 text-right text-[10px] tabular-nums text-[var(--world-text-muted)] [font-family:var(--world-font-mono)]">{v}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { cn } from '../../utils'
|
||||
import type { MarketQuote } from './types'
|
||||
import { formatPrice, formatChange, sparklinePoints } from './format'
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Sparkline */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function Sparkline({ data, change, w = 56, h = 18 }: { data?: number[]; change: number | null; w?: number; h?: number }) {
|
||||
const points = data ? sparklinePoints(data, w, h) : ''
|
||||
if (!points) return <span className="inline-block" style={{ width: w, height: h }} />
|
||||
const color = change != null && change < 0 ? 'var(--world-down)' : 'var(--world-up)'
|
||||
return (
|
||||
<svg width={w} height={h} viewBox={`0 0 ${w} ${h}`} className="shrink-0" aria-hidden>
|
||||
<polyline points={points} fill="none" stroke={color} strokeWidth={1.2} strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function changeColor(change: number | null): string {
|
||||
if (change == null) return 'text-[var(--world-text-dim)]'
|
||||
if (change > 0) return 'text-[var(--world-up)]'
|
||||
if (change < 0) return 'text-[var(--world-down)]'
|
||||
return 'text-[var(--world-text-muted)]'
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Props */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export interface MarketTickerProps {
|
||||
/** Quotes to render. */
|
||||
items: MarketQuote[]
|
||||
/** `list` = vertical rows; `strip` = horizontal scrolling tape. */
|
||||
variant?: 'list' | 'strip'
|
||||
/** Optional header label (list variant only). */
|
||||
title?: string
|
||||
loading?: boolean
|
||||
error?: string | null
|
||||
emptyMessage?: string
|
||||
onItemClick?: (quote: MarketQuote) => void
|
||||
/** Constrain height and scroll internally (list variant). CSS length. */
|
||||
maxHeight?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Component */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Market quotes with inline sparklines and color-coded change. Two layouts:
|
||||
* a vertical `list` panel or a horizontal `strip` ticker tape.
|
||||
*
|
||||
* @example
|
||||
* <MarketTicker title="Markets" items={quotes} />
|
||||
* <MarketTicker variant="strip" items={quotes} />
|
||||
*/
|
||||
export function MarketTicker({
|
||||
items,
|
||||
variant = 'list',
|
||||
title,
|
||||
loading = false,
|
||||
error = null,
|
||||
emptyMessage = 'No market data',
|
||||
onItemClick,
|
||||
maxHeight,
|
||||
className,
|
||||
}: MarketTickerProps) {
|
||||
if (variant === 'strip') {
|
||||
return <MarketStrip items={items} loading={loading} error={error} onItemClick={onItemClick} className={className} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col overflow-hidden rounded-[var(--world-radius)] border border-[var(--world-border)]',
|
||||
'bg-[var(--world-surface)] text-[var(--world-text)] [font-family:var(--world-font-sans)]',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{title && (
|
||||
<div className="border-b border-[var(--world-border)] px-3 py-2 text-xs font-medium text-[var(--world-text-muted)]">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto" style={maxHeight ? { maxHeight } : undefined}>
|
||||
{error ? (
|
||||
<div className="px-3 py-8 text-center text-sm text-[var(--world-text-dim)]">{error}</div>
|
||||
) : loading ? (
|
||||
<MarketSkeleton />
|
||||
) : items.length === 0 ? (
|
||||
<div className="px-3 py-8 text-center text-sm text-[var(--world-text-dim)]">{emptyMessage}</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-[var(--world-border)]">
|
||||
{items.map((q, i) => (
|
||||
<MarketRow key={q.symbol ?? i} quote={q} onItemClick={onItemClick} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* List row */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function MarketRow({ quote, onItemClick }: { quote: MarketQuote; onItemClick?: (q: MarketQuote) => void }) {
|
||||
const body = (
|
||||
<div className="flex items-center gap-3 px-3 py-2.5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[13px] font-medium text-[var(--world-text)]">{quote.symbol}</div>
|
||||
{quote.name && <div className="truncate text-[11px] text-[var(--world-text-dim)]">{quote.name}</div>}
|
||||
</div>
|
||||
<Sparkline data={quote.sparkline} change={quote.change} />
|
||||
<div className="w-20 text-right [font-family:var(--world-font-mono)]">
|
||||
<div className="text-[13px] tabular-nums text-[var(--world-text)]">{formatPrice(quote.price, quote.currency ?? '$')}</div>
|
||||
<div className={cn('text-[11px] tabular-nums', changeColor(quote.change))}>{formatChange(quote.change)}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (onItemClick) {
|
||||
return (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onItemClick(quote)}
|
||||
className="block w-full text-left transition-colors hover:bg-[var(--world-surface-hover)]"
|
||||
>
|
||||
{body}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
return <li>{body}</li>
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Strip (ticker tape) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function MarketStrip({
|
||||
items,
|
||||
loading,
|
||||
error,
|
||||
onItemClick,
|
||||
className,
|
||||
}: {
|
||||
items: MarketQuote[]
|
||||
loading: boolean
|
||||
error: string | null
|
||||
onItemClick?: (q: MarketQuote) => void
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-5 overflow-x-auto rounded-[var(--world-radius)] border border-[var(--world-border)]',
|
||||
'bg-[var(--world-surface)] px-3 py-2 [font-family:var(--world-font-mono)]',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{error ? (
|
||||
<span className="text-xs text-[var(--world-text-dim)]">{error}</span>
|
||||
) : loading ? (
|
||||
<span className="text-xs text-[var(--world-text-dim)]">Loading…</span>
|
||||
) : (
|
||||
items.map((q, i) => (
|
||||
<button
|
||||
key={q.symbol ?? i}
|
||||
type="button"
|
||||
onClick={onItemClick ? () => onItemClick(q) : undefined}
|
||||
className={cn('flex shrink-0 items-center gap-1.5 whitespace-nowrap text-xs', onItemClick && 'cursor-pointer')}
|
||||
>
|
||||
<span className="font-medium text-[var(--world-text)] [font-family:var(--world-font-sans)]">{q.symbol}</span>
|
||||
<span className="tabular-nums text-[var(--world-text-muted)]">{formatPrice(q.price, q.currency ?? '$')}</span>
|
||||
<span className={cn('tabular-nums', changeColor(q.change))}>{formatChange(q.change)}</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MarketSkeleton() {
|
||||
return (
|
||||
<ul className="divide-y divide-[var(--world-border)]">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<li key={i} className="flex items-center gap-3 px-3 py-2.5">
|
||||
<div className="min-w-0 flex-1 space-y-1.5">
|
||||
<div className="h-3 w-16 rounded bg-[var(--world-surface-active)]" />
|
||||
<div className="h-2.5 w-24 rounded bg-[var(--world-surface-hover)]" />
|
||||
</div>
|
||||
<div className="h-[18px] w-14 rounded bg-[var(--world-surface-hover)]" />
|
||||
<div className="w-20 space-y-1.5">
|
||||
<div className="ml-auto h-3 w-14 rounded bg-[var(--world-surface-active)]" />
|
||||
<div className="ml-auto h-2.5 w-10 rounded bg-[var(--world-surface-hover)]" />
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { cn } from '../../utils'
|
||||
import type { NewsStreamItem, WorldSeverity } from './types'
|
||||
import { formatRelativeTime, safeUrl } from './format'
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Severity → token */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const SEVERITY_VAR: Record<WorldSeverity, string> = {
|
||||
critical: 'var(--world-critical)',
|
||||
high: 'var(--world-high)',
|
||||
elevated: 'var(--world-elevated)',
|
||||
normal: 'var(--world-normal)',
|
||||
low: 'var(--world-normal)',
|
||||
info: 'var(--world-info)',
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Props */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export interface NewsStreamProps {
|
||||
/** Items to render, newest first. */
|
||||
items: NewsStreamItem[]
|
||||
/** Optional header label rendered above the stream. */
|
||||
title?: string
|
||||
/** Show a skeleton/loading state instead of items. */
|
||||
loading?: boolean
|
||||
/** Error message; replaces the list when set. */
|
||||
error?: string | null
|
||||
/** Message shown when there are no items. */
|
||||
emptyMessage?: string
|
||||
/** Cap the number of rendered rows. */
|
||||
maxItems?: number
|
||||
/** Called on row click (in addition to following `url`). */
|
||||
onItemClick?: (item: NewsStreamItem) => void
|
||||
/** Constrain height and scroll internally. CSS length. */
|
||||
maxHeight?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Component */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Vertical stream of news / signal items. Pure props — feed it any array
|
||||
* (from the `useNewsStream` hook, the /v1/world API, or your own source).
|
||||
*
|
||||
* @example
|
||||
* <NewsStream title="Live intel" items={items} maxHeight="480px" />
|
||||
*/
|
||||
export function NewsStream({
|
||||
items,
|
||||
title,
|
||||
loading = false,
|
||||
error = null,
|
||||
emptyMessage = 'No items',
|
||||
maxItems,
|
||||
onItemClick,
|
||||
maxHeight,
|
||||
className,
|
||||
}: NewsStreamProps) {
|
||||
const rows = maxItems ? items.slice(0, maxItems) : items
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col overflow-hidden rounded-[var(--world-radius)] border border-[var(--world-border)]',
|
||||
'bg-[var(--world-surface)] text-[var(--world-text)]',
|
||||
'[font-family:var(--world-font-sans)]',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{title && (
|
||||
<div className="flex items-center justify-between border-b border-[var(--world-border)] px-3 py-2">
|
||||
<span className="text-xs font-medium text-[var(--world-text-muted)]">{title}</span>
|
||||
{!loading && !error && (
|
||||
<span className="text-[11px] tabular-nums text-[var(--world-text-dim)] [font-family:var(--world-font-mono)]">
|
||||
{items.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto" style={maxHeight ? { maxHeight } : undefined}>
|
||||
{error ? (
|
||||
<div className="px-3 py-8 text-center text-sm text-[var(--world-text-dim)]">{error}</div>
|
||||
) : loading ? (
|
||||
<NewsStreamSkeleton />
|
||||
) : rows.length === 0 ? (
|
||||
<div className="px-3 py-8 text-center text-sm text-[var(--world-text-dim)]">{emptyMessage}</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-[var(--world-border)]">
|
||||
{rows.map((item, i) => (
|
||||
<NewsRow key={item.id ?? item.url ?? `${item.title}-${i}`} item={item} onItemClick={onItemClick} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Row */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function NewsRow({ item, onItemClick }: { item: NewsStreamItem; onItemClick?: (item: NewsStreamItem) => void }) {
|
||||
const href = safeUrl(item.url)
|
||||
const rail = item.severity ? SEVERITY_VAR[item.severity] : undefined
|
||||
const time = formatRelativeTime(item.timestamp)
|
||||
|
||||
const inner = (
|
||||
<div className="flex gap-2.5 px-3 py-2.5">
|
||||
<span
|
||||
aria-hidden
|
||||
className="mt-1 w-0.5 shrink-0 self-stretch rounded-full"
|
||||
style={{ background: rail ?? 'transparent' }}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[13px] font-normal leading-snug text-[var(--world-text)]">{item.title}</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px] text-[var(--world-text-dim)] [font-family:var(--world-font-mono)]">
|
||||
{item.source && <span className="text-[var(--world-text-muted)]">{item.source}</span>}
|
||||
{item.location && <span>{item.location}</span>}
|
||||
{item.category && (
|
||||
<span className="rounded-[var(--world-radius-sm)] bg-[var(--world-surface-active)] px-1.5 py-px text-[10px] uppercase tracking-wide text-[var(--world-text-muted)]">
|
||||
{item.category}
|
||||
</span>
|
||||
)}
|
||||
{time && <span className="ml-auto tabular-nums">{time}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const interactive =
|
||||
'cursor-pointer transition-colors hover:bg-[var(--world-surface-hover)] focus-visible:bg-[var(--world-surface-hover)] focus-visible:outline-none'
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<li>
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => onItemClick?.(item)}
|
||||
className={cn('block', interactive)}
|
||||
>
|
||||
{inner}
|
||||
</a>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
if (onItemClick) {
|
||||
return (
|
||||
<li>
|
||||
<button type="button" onClick={() => onItemClick(item)} className={cn('block w-full text-left', interactive)}>
|
||||
{inner}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
return <li>{inner}</li>
|
||||
}
|
||||
|
||||
function NewsStreamSkeleton() {
|
||||
return (
|
||||
<ul className="divide-y divide-[var(--world-border)]">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<li key={i} className="flex gap-2.5 px-3 py-2.5">
|
||||
<span className="mt-1 w-0.5 shrink-0 self-stretch rounded-full bg-[var(--world-surface-active)]" />
|
||||
<div className="min-w-0 flex-1 space-y-1.5">
|
||||
<div className="h-3 w-4/5 rounded bg-[var(--world-surface-active)]" />
|
||||
<div className="h-2.5 w-2/5 rounded bg-[var(--world-surface-hover)]" />
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { cn } from '../../utils'
|
||||
import type { PredictionMarketItem } from './types'
|
||||
import { formatVolume, safeUrl } from './format'
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Props */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export interface PredictionMarketProps {
|
||||
/** Markets to render. */
|
||||
items: PredictionMarketItem[]
|
||||
/** Optional header label. */
|
||||
title?: string
|
||||
loading?: boolean
|
||||
error?: string | null
|
||||
emptyMessage?: string
|
||||
maxItems?: number
|
||||
/** Constrain height and scroll internally. CSS length. */
|
||||
maxHeight?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Component */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Prediction-market widget: each row shows a question and a yes/no
|
||||
* probability split bar. Pure props — feed it Polymarket-shaped data
|
||||
* (title / yesPrice / volume / url) from any source.
|
||||
*
|
||||
* @example
|
||||
* <PredictionMarket title="Markets" items={[{ title: 'Rate cut by Q3?', yesPrice: 63, volume: 1_200_000, url }]} />
|
||||
*/
|
||||
export function PredictionMarket({
|
||||
items,
|
||||
title,
|
||||
loading = false,
|
||||
error = null,
|
||||
emptyMessage = 'No markets',
|
||||
maxItems,
|
||||
maxHeight,
|
||||
className,
|
||||
}: PredictionMarketProps) {
|
||||
const rows = maxItems ? items.slice(0, maxItems) : items
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col overflow-hidden rounded-[var(--world-radius)] border border-[var(--world-border)]',
|
||||
'bg-[var(--world-surface)] text-[var(--world-text)] [font-family:var(--world-font-sans)]',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{title && (
|
||||
<div className="border-b border-[var(--world-border)] px-3 py-2 text-xs font-medium text-[var(--world-text-muted)]">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto" style={maxHeight ? { maxHeight } : undefined}>
|
||||
{error ? (
|
||||
<div className="px-3 py-8 text-center text-sm text-[var(--world-text-dim)]">{error}</div>
|
||||
) : loading ? (
|
||||
<MarketSkeleton />
|
||||
) : rows.length === 0 ? (
|
||||
<div className="px-3 py-8 text-center text-sm text-[var(--world-text-dim)]">{emptyMessage}</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-[var(--world-border)]">
|
||||
{rows.map((m, i) => (
|
||||
<PredictionRow key={m.url ?? `${m.title}-${i}`} market={m} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Row */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function PredictionRow({ market }: { market: PredictionMarketItem }) {
|
||||
const yes = Math.max(0, Math.min(100, Math.round(market.yesPrice)))
|
||||
const no = 100 - yes
|
||||
const href = safeUrl(market.url)
|
||||
const volume = formatVolume(market.volume)
|
||||
|
||||
return (
|
||||
<li className="px-3 py-2.5">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
{href ? (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[13px] font-normal leading-snug text-[var(--world-text)] transition-colors hover:text-[var(--world-accent)]"
|
||||
>
|
||||
{market.title}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-[13px] font-normal leading-snug text-[var(--world-text)]">{market.title}</span>
|
||||
)}
|
||||
{volume && (
|
||||
<span className="shrink-0 text-[11px] tabular-nums text-[var(--world-text-dim)] [font-family:var(--world-font-mono)]">{volume}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex h-5 overflow-hidden rounded-[var(--world-radius-sm)] bg-[var(--world-surface-active)] text-[10px] font-medium [font-family:var(--world-font-mono)]">
|
||||
<div
|
||||
className="flex items-center justify-start overflow-hidden whitespace-nowrap px-1.5 text-black"
|
||||
style={{ width: `${yes}%`, background: 'var(--world-up)' }}
|
||||
>
|
||||
{yes >= 18 && <span>Yes {yes}%</span>}
|
||||
</div>
|
||||
<div className="flex items-center justify-end overflow-hidden whitespace-nowrap px-1.5 text-[var(--world-text-muted)]" style={{ width: `${no}%` }}>
|
||||
{no >= 18 && <span>No {no}%</span>}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function MarketSkeleton() {
|
||||
return (
|
||||
<ul className="divide-y divide-[var(--world-border)]">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<li key={i} className="space-y-2 px-3 py-2.5">
|
||||
<div className="h-3 w-3/4 rounded bg-[var(--world-surface-active)]" />
|
||||
<div className="h-5 w-full rounded-[var(--world-radius-sm)] bg-[var(--world-surface-hover)]" />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* @hanzo/ui/world - Design tokens
|
||||
*
|
||||
* Framework-agnostic CSS custom properties for World data components
|
||||
* (NewsStream, MarketTicker, InstabilityScore, PredictionMarket, ...).
|
||||
*
|
||||
* DEFAULT THEME = the world.hanzo.ai look: black monochrome surfaces,
|
||||
* Geist Sans (UI) + Geist Mono (data/numeric), sentence case, light weights —
|
||||
* the clean Vercel/Geist aesthetic.
|
||||
*
|
||||
* THEMING — one way: every visible style reads a `--world-*` custom property.
|
||||
* To re-skin the components on top of a host app's design system, override any
|
||||
* `--world-*` property in your own `:root` (or a scoped ancestor). Point them at
|
||||
* your host tokens to unify, e.g.:
|
||||
*
|
||||
* :root {
|
||||
* --world-bg: var(--background);
|
||||
* --world-surface: var(--card);
|
||||
* --world-text: var(--foreground);
|
||||
* --world-accent: var(--primary);
|
||||
* --world-font-sans: var(--font-sans);
|
||||
* }
|
||||
*
|
||||
* The Geist fonts are NOT bundled — host apps self-host 'Geist' / 'Geist Mono'
|
||||
* (the tokens fall back to system-ui / SF Mono when the faces are absent).
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* Surfaces — black monochrome (matches hanzo.ai / world.hanzo.ai) */
|
||||
--world-bg: #000;
|
||||
--world-surface: #0a0a0a;
|
||||
--world-surface-hover: #161616;
|
||||
--world-surface-active: #1c1c1c;
|
||||
|
||||
/* Borders — neutral monochrome grey (no tint) */
|
||||
--world-border: #1e1e1e;
|
||||
--world-border-strong: #3a3a3a;
|
||||
|
||||
/* Text */
|
||||
--world-text: #e8e8e8;
|
||||
--world-text-muted: #888;
|
||||
--world-text-dim: #666;
|
||||
--world-accent: #fff;
|
||||
|
||||
/* Severity — instability / threat / alert level */
|
||||
--world-critical: #ff4444;
|
||||
--world-high: #ff8800;
|
||||
--world-elevated: #ffaa00;
|
||||
--world-normal: #44aa44;
|
||||
--world-info: #3b82f6;
|
||||
|
||||
/* Directional — price up / down, escalation */
|
||||
--world-up: #44ff88;
|
||||
--world-down: #ff4444;
|
||||
|
||||
/* Status dot */
|
||||
--world-live: #44ff88;
|
||||
--world-stale: #ffaa00;
|
||||
|
||||
/* Typography — Geist Sans for UI, Geist Mono for data/numeric */
|
||||
--world-font-sans: 'Geist', system-ui, -apple-system, sans-serif;
|
||||
--world-font-mono: 'Geist Mono', 'SF Mono', 'Monaco', ui-monospace, monospace;
|
||||
|
||||
/* Sizing */
|
||||
--world-radius: 0.5rem;
|
||||
--world-radius-sm: 0.375rem;
|
||||
--world-transition: 150ms ease;
|
||||
}
|
||||
|
||||
/*
|
||||
* Light theme override. Applies when a host marks the tree with
|
||||
* data-theme="light" or a `.light` class (matching world.hanzo.ai and the
|
||||
* common next-themes conventions). Bright dark-mode accents are darkened so
|
||||
* they stay legible on light surfaces.
|
||||
*/
|
||||
[data-theme='light'],
|
||||
.light {
|
||||
--world-bg: #ffffff;
|
||||
--world-surface: #f8f9fa;
|
||||
--world-surface-hover: #f0f0f0;
|
||||
--world-surface-active: #e8e8e8;
|
||||
|
||||
--world-border: #e4e4e7;
|
||||
--world-border-strong: #d4d4d8;
|
||||
|
||||
--world-text: #18181b;
|
||||
--world-text-muted: #52525b;
|
||||
--world-text-dim: #71717a;
|
||||
--world-accent: #000;
|
||||
|
||||
--world-critical: #dc2626;
|
||||
--world-high: #ea580c;
|
||||
--world-elevated: #d97706;
|
||||
--world-normal: #16a34a;
|
||||
--world-info: #2563eb;
|
||||
|
||||
--world-up: #16a34a;
|
||||
--world-down: #dc2626;
|
||||
|
||||
--world-live: #16a34a;
|
||||
--world-stale: #d97706;
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* @hanzo/ui/world - Shared data types
|
||||
*
|
||||
* Prop shapes for the World data components. These mirror the typed data the
|
||||
* hanzoai/world panels consume, so a host can feed the same payloads the
|
||||
* /v1/world API returns. Numeric fields keep `| null` as the missing-data
|
||||
* sentinel — components branch on null to show "N/A" rather than a fake 0.
|
||||
*/
|
||||
|
||||
/** Standard async data envelope shared by every self-fetching component. */
|
||||
export interface PanelState<T> {
|
||||
data: T | null
|
||||
loading: boolean
|
||||
error: string | null
|
||||
/** Source reported no data (endpoint reachable but empty/unavailable). */
|
||||
unavailable?: boolean
|
||||
}
|
||||
|
||||
/** Severity ramp used for alerts, threats and instability. */
|
||||
export type WorldSeverity = 'critical' | 'high' | 'elevated' | 'normal' | 'low' | 'info'
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* NewsStream */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export interface NewsStreamItem {
|
||||
/** Stable key. Falls back to `url` then title+index. */
|
||||
id?: string
|
||||
title: string
|
||||
/** Publisher / feed name, e.g. "Reuters". */
|
||||
source?: string
|
||||
/** Canonical article link (http/https only — validated at render). */
|
||||
url?: string
|
||||
/** Publish time — Date, epoch ms, or ISO string. */
|
||||
timestamp?: string | number | Date
|
||||
/** Free-form category / topic label. */
|
||||
category?: string
|
||||
/** Highlights the item and colors its rail. */
|
||||
severity?: WorldSeverity
|
||||
/** Optional place label, e.g. "Kyiv, UA". */
|
||||
location?: string
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* MarketTicker */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export interface MarketQuote {
|
||||
/** Trading symbol, e.g. "AAPL", "BTC". */
|
||||
symbol: string
|
||||
/** Display name, e.g. "Apple Inc.". */
|
||||
name?: string
|
||||
/** Last price. `null` = unavailable. */
|
||||
price: number | null
|
||||
/** Percent change over the period. `null` = unavailable. */
|
||||
change: number | null
|
||||
/** Recent price series for the inline sparkline. */
|
||||
sparkline?: number[]
|
||||
/** Currency prefix, default "$". Pass "" for indices. */
|
||||
currency?: string
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* InstabilityScore */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export type InstabilityLevel = 'low' | 'normal' | 'elevated' | 'high' | 'critical'
|
||||
export type InstabilityTrend = 'rising' | 'stable' | 'falling'
|
||||
|
||||
/** Per-driver breakdown (0-100 each). Mirrors world's ComponentScores. */
|
||||
export interface InstabilityComponents {
|
||||
unrest?: number
|
||||
conflict?: number
|
||||
security?: number
|
||||
information?: number
|
||||
}
|
||||
|
||||
export interface InstabilityScoreData {
|
||||
/** Composite score, 0-100. */
|
||||
score: number
|
||||
/** ISO country code or region key, e.g. "UA". */
|
||||
code?: string
|
||||
/** Display label, e.g. "Ukraine". */
|
||||
name?: string
|
||||
/** Severity band. Derived from `score` when omitted. */
|
||||
level?: InstabilityLevel
|
||||
/** Directional movement. */
|
||||
trend?: InstabilityTrend
|
||||
/** Change over the last 24h (score points). */
|
||||
change24h?: number
|
||||
/** Optional driver breakdown, rendered as mini bars. */
|
||||
components?: InstabilityComponents
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* PredictionMarket */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export interface PredictionMarketItem {
|
||||
title: string
|
||||
/** Probability of "yes" as a percentage, 0-100. */
|
||||
yesPrice: number
|
||||
/** Notional traded volume (USD). */
|
||||
volume?: number
|
||||
/** Market link (http/https only). */
|
||||
url?: string
|
||||
}
|
||||
@@ -189,6 +189,16 @@ export default defineConfig({
|
||||
'dash/form': 'src/dash/form.tsx',
|
||||
'dash/crud': 'src/dash/crud.tsx',
|
||||
|
||||
// World components (embeddable data-stream panels from world.hanzo.ai)
|
||||
'world/index': 'src/world/index.ts',
|
||||
'world/news-stream': 'src/world/news-stream.tsx',
|
||||
'world/market-ticker': 'src/world/market-ticker.tsx',
|
||||
'world/instability-score': 'src/world/instability-score.tsx',
|
||||
'world/prediction-market': 'src/world/prediction-market.tsx',
|
||||
'world/hooks': 'src/world/hooks.ts',
|
||||
'world/format': 'src/world/format.ts',
|
||||
'world/types': 'src/world/types.ts',
|
||||
|
||||
// Auth components (IAMLoginButton, AuthGuard)
|
||||
'auth/index': 'src/auth/index.ts',
|
||||
|
||||
|
||||
Reference in New Issue
Block a user