Vorlage · React · HTML
Currency wallet
Ein Wallet mit mehreren Währungen in Tiles: ein Währungsumschalter, ein Prämienbereich mit der nächsten Stufe, ein Ausgaben-Donut und Verteilungsbalken.
Vorschau
React
Installiere die Vorlage mit shadcn. Sie landet als normale Komponenten in deinem Projekt, die du selbst anpasst.
npx shadcn@latest add https://mikkelmanniche.dk/lab/r/currency-wallet.jsonnpx shadcn@latest add @manniche/currency-walletcurrency-wallet.tsx
// A multi-currency wallet in Tiles: a currency switch, a rewards panel, a spending donut and allocation bars.
import { useState } from 'react'
import { cn } from '@/lib/utils'
import { BigNumber, DeltaPill, Pills } from '@/registry/manniche/chart-kit/chart-kit'
import { DataTile, TileFact } from '@/registry/manniche/data-tile/data-tile'
import { Donut, type DonutDatum } from '@/registry/manniche/donut/donut'
export type WalletCurrency = {
/** ISO code, also the switch label: "EUR". */
code: string
balance: number
change: number
spending: DonutDatum[]
/** Share of the balance per holding, 0 to 1. */
allocation: { id: string; label: string; share: number }[]
/** `tier` is the current tier, `nextTier` the one `next` more points reach. */
rewards: { points: number; next: number; tier: string; nextTier: string; perks: string[] }
}
export type CurrencyWalletProps = {
data: WalletCurrency[]
defaultCurrency?: string
title?: string
note?: string
now?: Date
labels?: { currency?: string; balance?: string; rewards?: string; spending?: string; allocation?: string; points?: string; toNext?: (n: number, tier: string) => string }
className?: string
}
/** Currency wallet: switch the currency and every tile follows. */
export function CurrencyWallet({ data, defaultCurrency, title = 'Currency wallet', note = 'Example data.', labels, className }: CurrencyWalletProps) {
const l = {
currency: 'Currency',
balance: 'Balance',
rewards: 'Rewards',
spending: 'Spending',
allocation: 'Allocation',
points: 'points',
toNext: (n: number, tier: string) => `${n.toLocaleString('en-GB')} points to ${tier}`,
...labels,
}
const [code, setCode] = useState(defaultCurrency ?? data[0]?.code ?? '')
const cur = data.find((c) => c.code === code) ?? data[0]
if (!cur) return null
const fmt = { currency: cur.code, decimals: 2 }
const toGo = cur.rewards.points + cur.rewards.next
const progress = toGo > 0 ? Math.min(1, Math.max(0, cur.rewards.points / toGo)) : 0
return (
<div className={cn('@container w-full font-sans text-foreground', className)}>
<div className="grid gap-4 @3xl:grid-cols-6 @6xl:grid-cols-12">
<div className="flex flex-wrap items-center justify-between gap-3 @3xl:col-span-6 @6xl:col-span-12">
<h1 className="text-[22px] leading-tight font-semibold tracking-tight">{title}</h1>
<Pills label={l.currency} options={data.map((c) => ({ id: c.code, label: c.code }))} value={cur.code} onChange={setCode} />
</div>
<DataTile title={l.balance} className="@3xl:col-span-3 @6xl:col-span-4" footer={<span>{note}</span>}>
<BigNumber value={cur.balance} format={fmt} size="lg" />
<div className="mt-3">
<DeltaPill value={cur.change} format={{ decimals: 1, suffix: '%', sign: true }} />
</div>
</DataTile>
<DataTile title={l.rewards} inverted className="@3xl:col-span-3 @6xl:col-span-4">
<BigNumber value={cur.rewards.points} size="lg" />
<p className="mt-1 text-sm opacity-80">{l.points} · {cur.rewards.tier}</p>
{/* The sentence under the bar says the same, so the bar itself is hidden from screen readers. */}
<div aria-hidden className="mt-4 h-1.5 overflow-hidden rounded-full bg-current/20">
<div className="h-full origin-left rounded-full bg-current transition-transform duration-700 motion-reduce:transition-none" style={{ transform: `scaleX(${progress})` }} />
</div>
<p className="mt-2 text-xs opacity-80">{l.toNext(cur.rewards.next, cur.rewards.nextTier)}</p>
<ul className="mt-4 space-y-1 text-sm">
{cur.rewards.perks.map((p) => (
<li key={p}>{p}</li>
))}
</ul>
</DataTile>
<DataTile title={l.spending} className="@3xl:col-span-6 @6xl:col-span-4">
<Donut data={cur.spending} label={`${l.spending}, ${cur.code}`} format={fmt} />
</DataTile>
<DataTile title={l.allocation} className="@3xl:col-span-6 @6xl:col-span-12">
<ul className="grid gap-4 @3xl:grid-cols-2">
{cur.allocation.map((a) => (
<li key={a.id}>
<TileFact label={a.label} aside={<span className="tabular-nums">{Math.round(a.share * 100)}%</span>}>
<span aria-hidden className="mt-2 block h-2 w-full overflow-hidden rounded-full bg-muted">
<span className="block h-full origin-left rounded-full bg-chart-1 transition-transform duration-700 motion-reduce:transition-none" style={{ transform: `scaleX(${a.share})` }} />
</span>
</TileFact>
</li>
))}
</ul>
</DataTile>
</div>
</div>
)
}
export default CurrencyWalletcurrency-wallet-demo.tsx
import { CurrencyWallet, type WalletCurrency } from '@/components/currency-wallet'
// Example data for the demo, not real accounts or rates.
const data: WalletCurrency[] = [
{
code: 'EUR',
balance: 4210.5,
change: 2.4,
spending: [
{ label: 'Groceries', value: 310.2 },
{ label: 'Travel', value: 420 },
{ label: 'Eating out', value: 180.4 },
{ label: 'Bills', value: 255 },
],
allocation: [
{ id: 'spend', label: 'Spending', share: 0.4 },
{ id: 'save', label: 'Savings', share: 0.45 },
{ id: 'trip', label: 'Trip pot', share: 0.15 },
],
rewards: { points: 3820, next: 1180, tier: 'Silver', nextTier: 'Gold', perks: ['Free ATM withdrawals', 'Travel insurance'] },
},
{
code: 'USD',
balance: 1380,
change: -0.8,
spending: [
{ label: 'Travel', value: 540 },
{ label: 'Shopping', value: 210.5 },
{ label: 'Eating out', value: 96 },
],
allocation: [
{ id: 'spend', label: 'Spending', share: 0.7 },
{ id: 'save', label: 'Savings', share: 0.3 },
],
rewards: { points: 640, next: 360, tier: 'Silver', nextTier: 'Gold', perks: ['No fee on card payments abroad'] },
},
{
code: 'DKK',
balance: 18240,
change: 1.1,
spending: [
{ label: 'Groceries', value: 2410 },
{ label: 'Transport', value: 980 },
{ label: 'Bills', value: 3120 },
],
allocation: [
{ id: 'spend', label: 'Spending', share: 0.55 },
{ id: 'save', label: 'Savings', share: 0.45 },
],
rewards: { points: 1210, next: 790, tier: 'Silver', nextTier: 'Gold', perks: ['Free transfers between pots'] },
},
]
export default function CurrencyWalletDemo() {
return <CurrencyWallet data={data} />
}HTML
Die ganze Seite als eine Datei mit eingebautem CSS und ohne JavaScript. Umschalter, Tabs und Filter funktionieren allein mit HTML und CSS.
Die Farbe in deiner eigenen App
Wenn shadcn bereits eingerichtet ist, ergeben diese Variablen dieselbe Farbe. Hell in :root, dunkel in .dark.
:root {
--primary: oklch(0.52 0.2 262);
--primary-foreground: oklch(0.99 0 0);
--accent: oklch(0.95 0.034 262);
--accent-foreground: oklch(0.36 0.140 262);
--ring: oklch(0.52 0.2 262);
}
.dark {
--primary: oklch(0.74 0.150 262);
--primary-foreground: oklch(0.18 0.024 262);
--accent: oklch(0.29 0.050 262);
--accent-foreground: oklch(0.9 0.050 262);
--ring: oklch(0.74 0.150 262);
}Installieren
npx shadcn@latest add https://mikkelmanniche.dk/lab/r/currency-wallet.json