Mikkel Manniche
Dansk English Deutsch

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.json
npx shadcn@latest add @manniche/currency-wallet
currency-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 CurrencyWallet
currency-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.

In neuem Tab öffnen

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

Abhängigkeiten

    Dateien

    • currency-wallet.tsx
    • currency-wallet.html

    Manniche Lab · Open Source

    Kostenlose Werkzeuge für KI-Agenten und Webentwicklung.