From 6f45da7245ad8f1c8848aaa0d69166d335225ae3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 15:09:47 +0000 Subject: [PATCH 1/2] Show banks, spending limits and cards in the UI Settings gains a Bank section: each bank the server has set up, what it has left to fund cards with, and any whose settings are wrong. A nibblet's settings gain Spending, shown once there's a bank: a limit per currency and period, what's left of each, and its cards, each one that isn't closed with a Kill button and what it's waiting on (being made, or closing until its provider settles it). Cards update live as they're made, spent on and killed. The mock server has a Slash and a Bitcoin bank and a few of Pip's cards. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KGXXQcj3hZN2jiB6As6kk5 --- apps/web/src/components/SettingsPage.tsx | 2 + apps/web/src/components/bank/BankSection.tsx | 67 ++++++ .../src/components/bank/NibbletSpending.tsx | 216 ++++++++++++++++++ .../src/components/panels/NibbletSettings.tsx | 3 + apps/web/src/lib/bank.test.ts | 47 ++++ apps/web/src/lib/bank.ts | 86 +++++++ apps/web/src/lib/mock.ts | 118 +++++++++- apps/web/src/lib/servers.tsx | 8 + 8 files changed, 543 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/components/bank/BankSection.tsx create mode 100644 apps/web/src/components/bank/NibbletSpending.tsx create mode 100644 apps/web/src/lib/bank.test.ts create mode 100644 apps/web/src/lib/bank.ts diff --git a/apps/web/src/components/SettingsPage.tsx b/apps/web/src/components/SettingsPage.tsx index 97807a02..4ec40dc5 100644 --- a/apps/web/src/components/SettingsPage.tsx +++ b/apps/web/src/components/SettingsPage.tsx @@ -5,6 +5,7 @@ import type { CredentialStatus } from "@nibblet/protocol"; import type { ServerConnection } from "../lib/connection.ts"; import { AccountSection, RemoteAccessSection } from "./RemoteAccess.tsx"; import { TailnetKeysSection } from "./TailnetKeysSection.tsx"; +import { BankSection } from "./bank/BankSection.tsx"; import { keys, useServers } from "../lib/servers.tsx"; import { clockTime, relativeTime } from "../lib/time.ts"; import { Button } from "./ui/Button.tsx"; @@ -53,6 +54,7 @@ export function SettingsPage() { + diff --git a/apps/web/src/components/bank/BankSection.tsx b/apps/web/src/components/bank/BankSection.tsx new file mode 100644 index 00000000..c34dc389 --- /dev/null +++ b/apps/web/src/components/bank/BankSection.tsx @@ -0,0 +1,67 @@ +import { useQuery } from "@tanstack/react-query"; +import { CheckCircle2, XCircle } from "lucide-react"; +import type { ServerConnection } from "../../lib/connection.ts"; +import { money } from "../../lib/bank.ts"; +import { keys } from "../../lib/servers.tsx"; +import { SectionHeading } from "../ui/Field.tsx"; + +/** + * The banks nibblets' cards come from: card issuers and crypto treasuries, + * set up in the server's settings, and what each has left to fund cards with. + */ +export function BankSection({ connection }: { connection: ServerConnection }) { + const status = useQuery({ + queryKey: keys.bank(connection.id), + queryFn: () => connection.client.bank.status({}), + refetchInterval: 60_000, + }); + const providers = status.data?.providers ?? []; + const problems = status.data?.problems ?? []; + return ( + <> + Bank +

+ {providers.length === 0 && problems.length === 0 + ? "No bank is set up, so nibblets can't have cards. Add your Slash account (SLASH_API_KEY) or a crypto treasury (BITCOIN_TREASURY_KEY, ETHEREUM_TREASURY_KEY) to the server's .env and restart it." + : "Nibblets get cards from these, within the spending limit you give each one in its settings."} +

+ {providers.map((p) => ( +
+ {p.state === "ready" ? ( + + ) : ( + + )} +
+ + {p.label} + {p.asset.code} + + + {p.state === "error" + ? `Can't be reached: ${p.detail ?? "no reason given"}` + : p.balance !== null + ? `${money(p.balance, p.asset)} available` + : "Ready"} + +
+
+ ))} + {problems.map((p) => ( +
+ +
+ {p.id} + Not set up: {p.detail} +
+
+ ))} + + ); +} diff --git a/apps/web/src/components/bank/NibbletSpending.tsx b/apps/web/src/components/bank/NibbletSpending.tsx new file mode 100644 index 00000000..30c7239b --- /dev/null +++ b/apps/web/src/components/bank/NibbletSpending.tsx @@ -0,0 +1,216 @@ +import { useEffect, useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { CreditCard, Wallet } from "lucide-react"; +import { SpendingPeriod, type Card, type Nibblet } from "@nibblet/protocol"; +import type { ServerConnection } from "../../lib/connection.ts"; +import { + bankAssets, + describeCard, + draftOf, + limitsOf, + money, + periodLabels, + type LimitsDraft, +} from "../../lib/bank.ts"; +import { keys } from "../../lib/servers.tsx"; +import { relativeTime } from "../../lib/time.ts"; +import { Button } from "../ui/Button.tsx"; +import { FieldRow, Input, SectionHeading, Select } from "../ui/Field.tsx"; + +const periodOptions = SpendingPeriod.options.map((p) => ({ value: p, label: periodLabels[p] })); + +/** + * What a nibblet may spend, in each asset the server's banks deal in, and + * the cards it has. Only the person sets the limits; the nibblet asks for + * cards within them. Hidden when no bank is set up. + */ +export function NibbletSpending({ + connection, + nibblet, +}: { + connection: ServerConnection; + nibblet: Nibblet; +}) { + const status = useQuery({ + queryKey: keys.bank(connection.id), + queryFn: () => connection.client.bank.status({}), + }); + const cards = useQuery({ + queryKey: keys.cards(connection.id, nibblet.id), + queryFn: () => connection.client.bank.cards({ nibbletId: nibblet.id }), + }); + const allowances = useQuery({ + queryKey: keys.allowances(connection.id, nibblet.id), + queryFn: () => connection.client.bank.allowances({ nibbletId: nibblet.id }), + }); + const assets = useMemo(() => bankAssets(status.data?.providers ?? []), [status.data]); + const saved = useMemo( + () => draftOf(nibblet.spendingLimits, assets), + [nibblet.spendingLimits, assets], + ); + const [draft, setDraft] = useState(saved); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + useEffect(() => setDraft(saved), [saved]); + + if (assets.length === 0 && !cards.data?.length) return null; + + const dirty = JSON.stringify(draft) !== JSON.stringify(saved); + const save = async () => { + const result = limitsOf(draft, assets, nibblet.spendingLimits); + if ("error" in result) { + setError(result.error); + return; + } + setBusy(true); + setError(null); + try { + await connection.client.nibblets.update({ id: nibblet.id, spendingLimits: result.limits }); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + }; + + return ( + <> + Spending +

+ The most it may put on cards. Open cards count in full until they're killed. Leave a limit + empty for no cards in that currency. +

+ {assets.map((asset) => { + const entry = draft[asset.code] ?? { amount: "", period: "month" }; + const left = allowances.data?.find((a) => a.limit.asset === asset.code); + return ( + + + + setDraft({ ...draft, [asset.code]: { ...entry, amount: e.target.value } }) + } + /> +