| import React, { useState, useEffect, useCallback, createContext, useContext } from 'react'; |
|
|
| |
| type ToastType = 'success' | 'error' | 'info'; |
| interface Toast { id: string; type: ToastType; title: string; message?: string; } |
| const ToastContext = createContext<{ addToast: (t: Omit<Toast, 'id'>) => void }>({ addToast: () => {} }); |
| export const useToast = () => useContext(ToastContext); |
|
|
| export function ToastProvider({ children }: { children: React.ReactNode }) { |
| const [toasts, setToasts] = useState<Toast[]>([]); |
| const addToast = useCallback((t: Omit<Toast, 'id'>) => { |
| const id = Date.now().toString(36); |
| setToasts(p => [...p, { ...t, id }]); |
| setTimeout(() => setToasts(p => p.filter(x => x.id !== id)), 4000); |
| }, []); |
| return ( |
| <ToastContext.Provider value={{ addToast }}> |
| {children} |
| <div className="fixed top-4 right-4 z-50 flex flex-col gap-2"> |
| {toasts.map(t => ( |
| <div key={t.id} className="bg-canvas rounded-xl p-4 shadow-soft border border-hairline min-w-[300px] page-enter cursor-pointer" onClick={() => setToasts(p => p.filter(x => x.id !== t.id))}> |
| <div className="flex items-center gap-3"> |
| <div className={`w-2 h-2 rounded-full ${t.type === 'success' ? 'bg-semantic-up' : t.type === 'error' ? 'bg-semantic-down' : 'bg-primary'}`} /> |
| <div> |
| <div className="text-title-sm text-ink">{t.title}</div> |
| {t.message && <div className="text-body-sm text-body mt-0.5">{t.message}</div>} |
| </div> |
| </div> |
| </div> |
| ))} |
| </div> |
| </ToastContext.Provider> |
| ); |
| } |
|
|
| |
| export function Num({ value, decimals = 2, prefix = '', suffix = '' }: { value: number; decimals?: number; prefix?: string; suffix?: string }) { |
| const [display, setDisplay] = useState(value); |
| useEffect(() => { |
| const diff = value - display; |
| if (Math.abs(diff) < 0.0001) { setDisplay(value); return; } |
| let step = 0; |
| const interval = setInterval(() => { |
| step++; |
| setDisplay(display + diff * (1 - Math.pow(1 - step / 16, 3))); |
| if (step >= 16) { setDisplay(value); clearInterval(interval); } |
| }, 30); |
| return () => clearInterval(interval); |
| }, [value]); |
| return <span className="number-mono">{prefix}{display.toLocaleString(undefined, { minimumFractionDigits: decimals, maximumFractionDigits: decimals })}{suffix}</span>; |
| } |
|
|
| |
| export function AssetRow({ icon, name, ticker, price, change, onClick }: { |
| icon: string; name: string; ticker: string; price: number; change?: number; onClick?: () => void; |
| }) { |
| return ( |
| <div className="asset-row px-4 cursor-pointer" onClick={onClick}> |
| <div className="asset-icon mr-3 text-sm font-bold text-ink">{icon}</div> |
| <div className="flex-1"> |
| <div className="text-title-sm text-ink">{name}</div> |
| <div className="text-caption text-muted">{ticker}</div> |
| </div> |
| <div className="text-right"> |
| <div className="number-mono text-number-display text-ink">${price.toLocaleString(undefined, { minimumFractionDigits: 2 })}</div> |
| {change !== undefined && ( |
| <div className={`number-mono text-caption ${change >= 0 ? 'text-semantic-up' : 'text-semantic-down'}`}> |
| {change >= 0 ? '+' : ''}{change.toFixed(2)}% |
| </div> |
| )} |
| </div> |
| </div> |
| ); |
| } |
|
|
| |
| export function ProductCard({ children, className = '' }: { children: React.ReactNode; className?: string }) { |
| return <div className={`card-dark ${className}`}>{children}</div>; |
| } |
|
|
| |
| export function StepIndicator({ steps, current }: { steps: string[]; current: number }) { |
| return ( |
| <div className="flex items-center justify-center gap-3 mb-12"> |
| {steps.map((label, i) => ( |
| <React.Fragment key={i}> |
| <div className="flex flex-col items-center gap-1.5"> |
| <div className={`w-8 h-8 rounded-full flex items-center justify-center text-caption-strong transition-colors ${ |
| i < current ? 'bg-primary text-on-primary' : |
| i === current ? 'bg-primary text-on-primary' : |
| 'bg-surface-strong text-muted' |
| }`}> |
| {i < current ? 'β' : i + 1} |
| </div> |
| <span className={`text-caption ${i <= current ? 'text-ink' : 'text-muted'}`}>{label}</span> |
| </div> |
| {i < steps.length - 1 && ( |
| <div className={`w-10 h-0.5 -mt-5 rounded-full ${i < current ? 'bg-primary' : 'bg-surface-strong'}`} /> |
| )} |
| </React.Fragment> |
| ))} |
| </div> |
| ); |
| } |
|
|
| |
| export function PipelineTrace({ steps }: { steps: Array<{ module: string; operation: string; input: string; output: string; durationMs: number }> }) { |
| if (!steps || steps.length === 0) return null; |
| return ( |
| <div className="bg-surface-soft rounded-xl p-4 mt-3"> |
| <div className="text-caption-strong text-muted uppercase tracking-wider mb-2">QVAC Pipeline Trace</div> |
| <div className="space-y-1.5"> |
| {steps.map((s, i) => ( |
| <div key={i} className="flex items-start gap-2 text-caption"> |
| <div className="w-1.5 h-1.5 rounded-full bg-primary mt-1.5 shrink-0" /> |
| <div className="flex-1 min-w-0"> |
| <span className="font-mono text-primary text-caption-strong">{s.module}</span> |
| <span className="text-muted mx-1">β</span> |
| <span className="text-body">{s.operation}</span> |
| <span className="text-muted-soft ml-1 number-mono">{s.durationMs}ms</span> |
| </div> |
| </div> |
| ))} |
| </div> |
| </div> |
| ); |
| } |
|
|
| |
| export function RiskBadge({ level, score }: { level: string; score: number }) { |
| const colors: Record<string, string> = { safe: 'badge-pill-green', caution: 'badge-pill', warning: 'badge-pill-red', danger: 'badge-pill-red' }; |
| return <span className={`badge-pill ${colors[level] || 'badge-pill'}`}>{level.toUpperCase()} Β· {score}</span>; |
| } |
|
|
| |
| export function Sparkline({ data, width = 120, height = 32, color = '#05b169' }: { |
| data: number[]; width?: number; height?: number; color?: string; |
| }) { |
| if (data.length < 2) return <div style={{ width, height }} />; |
| const min = Math.min(...data); const max = Math.max(...data); const range = max - min || 1; |
| const points = data.map((v, i) => { |
| const x = (i / (data.length - 1)) * width; |
| const y = height - ((v - min) / range) * (height - 4) - 2; |
| return `${x},${y}`; |
| }).join(' '); |
| const last = data[data.length - 1]; const prev = data[data.length - 2]; |
| const trending = last >= prev; |
| const c = trending ? '#05b169' : '#cf202f'; |
| return ( |
| <svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} className="overflow-visible"> |
| <polyline fill="none" stroke={c} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" points={points} /> |
| <circle cx={(data.length - 1) / (data.length - 1) * width} cy={height - ((last - min) / range) * (height - 4) - 2} r="2" fill={c} /> |
| </svg> |
| ); |
| } |
|
|
| |
| export function Kbd({ children }: { children: string }) { |
| return <kbd className="inline-flex items-center px-1.5 py-0.5 bg-surface-strong text-muted rounded-xs text-[10px] font-mono border border-hairline">{children}</kbd>; |
| } |
|
|