@tollbooth-dpyc/web 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,93 @@
1
+ /**
2
+ * A filter bar for a table: any of a search box, a date range and a panel of
3
+ * questions, with one Clear chip shown only while something is on.
4
+ *
5
+ * Merged from the fleet's two shapes — the search + date range (cypher,
6
+ * excalibur, roastify, after TaxSort) and the mark that opens a panel of
7
+ * toggles and numbers (goodearth). A site passes the groups it wants.
8
+ *
9
+ * Controlled and presentational: the parent owns the applied values and runs
10
+ * the query (server-side, or `filterRows` in the browser), so paging reflects
11
+ * the filtered set. The search is applied on Enter or its chip, never on each
12
+ * keystroke. Mechanics only: every visual choice is the site's, through
13
+ * `classNames`; the actions are chips.
14
+ */
15
+ import { type ReactNode } from "react";
16
+ export interface DateFieldOption {
17
+ value: string;
18
+ label: string;
19
+ }
20
+ /** One question the panel asks: a toggle, or a number with a unit beside it. */
21
+ export type FilterQuestion<F> = {
22
+ kind: "toggle";
23
+ key: keyof F;
24
+ label: string;
25
+ } | {
26
+ kind: "number";
27
+ key: keyof F;
28
+ label: string;
29
+ unit: string;
30
+ };
31
+ export interface TableFilterClassNames {
32
+ root?: string;
33
+ /** The search box and its chip. */
34
+ search?: string;
35
+ input?: string;
36
+ /** Every action: search, the panel's mark, Clear. */
37
+ chip?: string;
38
+ /** Added to the panel's mark while a question is answered. */
39
+ chipActive?: string;
40
+ /** The date-field select, the two dates and the dash between. */
41
+ dates?: string;
42
+ select?: string;
43
+ date?: string;
44
+ separator?: string;
45
+ /** The box holding the mark and its panel (positioned: the panel hangs from it). */
46
+ questions?: string;
47
+ /** What is on, beside the mark. */
48
+ summary?: string;
49
+ panel?: string;
50
+ /** One question's row. */
51
+ question?: string;
52
+ checkbox?: string;
53
+ number?: string;
54
+ unit?: string;
55
+ }
56
+ export interface TableFilterSearch {
57
+ /** The applied text. */
58
+ value: string;
59
+ onSearch: (text: string) => void;
60
+ placeholder?: string;
61
+ /** Tooltip on the box, e.g. that it takes a regular expression. */
62
+ title?: string;
63
+ }
64
+ export interface TableFilterDates {
65
+ from: string;
66
+ to: string;
67
+ onFrom: (v: string) => void;
68
+ onTo: (v: string) => void;
69
+ /** Which date the range reads, when a row has more than one. */
70
+ field?: string;
71
+ fields?: DateFieldOption[];
72
+ onField?: (v: string) => void;
73
+ }
74
+ export interface TableFilterQuestions<F> {
75
+ value: F;
76
+ onChange: (f: F) => void;
77
+ questions: readonly FilterQuestion<F>[];
78
+ /** What is on, in as few words as fit beside the mark; "" when nothing is. */
79
+ summary: string;
80
+ /** The mark's icon. Default a sliders glyph. */
81
+ icon?: ReactNode;
82
+ }
83
+ export interface TableFilterProps<F extends object> {
84
+ search?: TableFilterSearch;
85
+ dates?: TableFilterDates;
86
+ questions?: TableFilterQuestions<F>;
87
+ /** Turn everything off. */
88
+ onClear: () => void;
89
+ searchIcon?: ReactNode;
90
+ clearLabel?: ReactNode;
91
+ classNames?: TableFilterClassNames;
92
+ }
93
+ export default function TableFilter<F extends object>({ search, dates, questions, onClear, searchIcon, clearLabel, classNames: c, }: TableFilterProps<F>): import("react").JSX.Element;
@@ -0,0 +1,68 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * A filter bar for a table: any of a search box, a date range and a panel of
4
+ * questions, with one Clear chip shown only while something is on.
5
+ *
6
+ * Merged from the fleet's two shapes — the search + date range (cypher,
7
+ * excalibur, roastify, after TaxSort) and the mark that opens a panel of
8
+ * toggles and numbers (goodearth). A site passes the groups it wants.
9
+ *
10
+ * Controlled and presentational: the parent owns the applied values and runs
11
+ * the query (server-side, or `filterRows` in the browser), so paging reflects
12
+ * the filtered set. The search is applied on Enter or its chip, never on each
13
+ * keystroke. Mechanics only: every visual choice is the site's, through
14
+ * `classNames`; the actions are chips.
15
+ */
16
+ import { useEffect, useRef, useState } from "react";
17
+ import { Search, SlidersHorizontal, X } from "lucide-react";
18
+ import { filterActive } from "../table.js";
19
+ function cx(...parts) {
20
+ const s = parts.filter(Boolean).join(" ");
21
+ return s || undefined;
22
+ }
23
+ export default function TableFilter({ search, dates, questions, onClear, searchIcon = _jsx(Search, { size: "1em", "aria-hidden": true }), clearLabel = (_jsxs(_Fragment, { children: [_jsx(X, { size: "1em", "aria-hidden": true }), " Clear"] })), classNames: c = {}, }) {
24
+ const active = filterActive({ search: search?.value, dateFrom: dates?.from, dateTo: dates?.to }, questions?.summary ?? "");
25
+ return (_jsxs("div", { className: c.root, children: [search && _jsx(SearchBox, { search: search, icon: searchIcon, c: c }), dates && _jsx(DateRange, { dates: dates, c: c }), questions && _jsx(QuestionPanel, { q: questions, c: c }), active && (_jsx("button", { type: "button", onClick: onClear, className: c.chip, children: clearLabel }))] }));
26
+ }
27
+ function SearchBox({ search, icon, c }) {
28
+ // Edited freely, applied on Enter or the chip; follows the applied value
29
+ // when it changes elsewhere (Clear).
30
+ const [input, setInput] = useState(search.value);
31
+ useEffect(() => setInput(search.value), [search.value]);
32
+ return (_jsxs("div", { className: c.search, children: [_jsx("input", { type: "search", value: input, onChange: (e) => setInput(e.target.value), onKeyDown: (e) => {
33
+ if (e.key === "Enter")
34
+ search.onSearch(input);
35
+ }, placeholder: search.placeholder ?? "Search…", title: search.title, "aria-label": "Search", className: c.input }), _jsx("button", { type: "button", onClick: () => search.onSearch(input), title: "Search", "aria-label": "Search", className: c.chip, children: icon })] }));
36
+ }
37
+ function DateRange({ dates, c }) {
38
+ return (_jsxs("div", { className: c.dates, children: [dates.fields && dates.fields.length > 0 && dates.onField && (_jsx("select", { value: dates.field, onChange: (e) => dates.onField(e.target.value), title: "Which date the range filters", "aria-label": "Date to filter", className: c.select, children: dates.fields.map((o) => (_jsx("option", { value: o.value, children: o.label }, o.value))) })), _jsx("input", { type: "date", value: dates.from, max: dates.to || undefined, onChange: (e) => dates.onFrom(e.target.value), title: "From (inclusive)", "aria-label": "From date", className: c.date }), _jsx("span", { className: c.separator, "aria-hidden": true, children: "\u2013" }), _jsx("input", { type: "date", value: dates.to, min: dates.from || undefined, onChange: (e) => dates.onTo(e.target.value), title: "To (inclusive)", "aria-label": "To date", className: c.date })] }));
39
+ }
40
+ function QuestionPanel({ q, c }) {
41
+ const [open, setOpen] = useState(false);
42
+ const box = useRef(null);
43
+ // Dismissed by a tap outside or Escape.
44
+ useEffect(() => {
45
+ if (!open)
46
+ return;
47
+ const away = (e) => {
48
+ if (box.current && !box.current.contains(e.target))
49
+ setOpen(false);
50
+ };
51
+ const esc = (e) => {
52
+ if (e.key === "Escape")
53
+ setOpen(false);
54
+ };
55
+ document.addEventListener("pointerdown", away);
56
+ document.addEventListener("keydown", esc);
57
+ return () => {
58
+ document.removeEventListener("pointerdown", away);
59
+ document.removeEventListener("keydown", esc);
60
+ };
61
+ }, [open]);
62
+ const on = q.summary !== "";
63
+ const set = (key, v) => q.onChange({ ...q.value, [key]: v });
64
+ return (_jsxs("div", { className: c.questions, ref: box, children: [_jsxs("button", { type: "button", onClick: () => setOpen((o) => !o), "aria-expanded": open, title: on ? `Filtered: ${q.summary}` : "Filter", "aria-label": on ? `Filtered: ${q.summary}` : "Filter", className: cx(c.chip, on && c.chipActive), children: [q.icon ?? _jsx(SlidersHorizontal, { size: "1em", "aria-hidden": true }), on && _jsx("span", { className: c.summary, children: q.summary })] }), open && (_jsx("div", { className: c.panel, role: "group", "aria-label": "Filter", children: q.questions.map((question) => {
65
+ const id = `tf-${String(question.key)}`;
66
+ return question.kind === "toggle" ? (_jsxs("label", { className: c.question, children: [_jsx("input", { type: "checkbox", checked: !!q.value[question.key], onChange: (e) => set(question.key, e.target.checked), className: c.checkbox }), " ", question.label] }, id)) : (_jsxs("div", { className: c.question, children: [_jsx("label", { htmlFor: id, children: question.label }), " ", _jsx("input", { id: id, inputMode: "numeric", value: String(q.value[question.key] ?? ""), onChange: (e) => set(question.key, e.target.value), className: c.number }), " ", _jsx("span", { className: c.unit, children: question.unit })] }, id));
67
+ }) }))] }));
68
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Light / dark / system as a row of chips, the current one marked. Mechanics
3
+ * only: `classNames` styles it, `labels` names the choices (text or icons),
4
+ * and `themes` picks which are offered — ["dark", "light"] for a site with no
5
+ * "system" option.
6
+ */
7
+ import type { ReactNode } from "react";
8
+ import { type Theme } from "../theme.ts";
9
+ export interface ThemeToggleClassNames {
10
+ root?: string;
11
+ chip?: string;
12
+ /** Added to the chip of the current pick. */
13
+ active?: string;
14
+ }
15
+ export interface ThemeToggleProps {
16
+ themes?: readonly Theme[];
17
+ labels?: Partial<Record<Theme, ReactNode>>;
18
+ /** The pick when nothing is stored. Default "dark". */
19
+ fallback?: Theme;
20
+ classNames?: ThemeToggleClassNames;
21
+ }
22
+ export default function ThemeToggle({ themes, labels, fallback, classNames: c }: ThemeToggleProps): import("react").JSX.Element;
@@ -0,0 +1,11 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { THEMES } from "../theme.js";
3
+ import { useTheme } from "./useTheme.js";
4
+ const LABELS = { dark: "Dark", light: "Light", system: "System" };
5
+ export default function ThemeToggle({ themes = THEMES, labels = {}, fallback = "dark", classNames: c = {} }) {
6
+ const [theme, setTheme] = useTheme(fallback);
7
+ return (_jsx("div", { role: "radiogroup", "aria-label": "Theme", className: c.root, children: themes.map((t) => {
8
+ const on = t === theme;
9
+ return (_jsx("button", { type: "button", role: "radio", "aria-checked": on, "aria-label": LABELS[t], onClick: () => setTheme(t), className: [c.chip, on && c.active].filter(Boolean).join(" ") || undefined, children: labels[t] ?? LABELS[t] }, t));
10
+ }) }));
11
+ }
@@ -5,6 +5,9 @@
5
5
  * unknown, and unknown is not zero: showing 0 to someone with funds would send
6
6
  * them to buy credits they already own. The card offers round numbers to pay
7
7
  * in and never guesses what anything costs.
8
+ *
9
+ * The compact card. `WalletPage` is the full account view; both run the same
10
+ * top-up (`useTopUp`), which checks an open invoice on its own until it settles.
8
11
  */
9
12
  export default function WalletCard({ topUps }: {
10
13
  topUps?: number[];
@@ -6,23 +6,22 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
6
6
  * unknown, and unknown is not zero: showing 0 to someone with funds would send
7
7
  * them to buy credits they already own. The card offers round numbers to pay
8
8
  * in and never guesses what anything costs.
9
+ *
10
+ * The compact card. `WalletPage` is the full account view; both run the same
11
+ * top-up (`useTopUp`), which checks an open invoice on its own until it settles.
9
12
  */
10
13
  import { useCallback, useEffect, useState } from "react";
11
14
  import { RefreshCw, Zap } from "lucide-react";
12
15
  import { tollboothConfig } from "../config.js";
13
- import { checkBalance, checkPayment, purchaseCredits } from "../standardTools.js";
16
+ import { checkBalance } from "../standardTools.js";
17
+ import { formatSats } from "../wallet.js";
14
18
  import { card, ghost, iconButton, muted, primary } from "./ui.js";
19
+ import { useTopUp } from "./useTopUp.js";
15
20
  const DEFAULT_TOP_UPS = [1_000, 5_000, 20_000];
16
- function sats(n) {
17
- return n === null ? "—" : n.toLocaleString("en-US");
18
- }
19
21
  export default function WalletCard({ topUps = DEFAULT_TOP_UPS }) {
20
22
  const { appName } = tollboothConfig();
21
23
  const [balance, setBalance] = useState(null);
22
24
  const [reachable, setReachable] = useState(true);
23
- const [busy, setBusy] = useState(false);
24
- const [invoice, setInvoice] = useState(null);
25
- const [msg, setMsg] = useState("");
26
25
  const load = useCallback(() => {
27
26
  checkBalance()
28
27
  .then((r) => {
@@ -33,50 +32,17 @@ export default function WalletCard({ topUps = DEFAULT_TOP_UPS }) {
33
32
  .catch(() => setReachable(false));
34
33
  }, []);
35
34
  useEffect(load, [load]);
36
- async function topUp(amount) {
37
- setBusy(true);
38
- setMsg("");
39
- try {
40
- const r = await purchaseCredits(amount);
41
- if (r.error || !r.invoice_id) {
42
- setMsg(r.error ?? "The service did not return an invoice. Try again.");
43
- return;
44
- }
45
- setInvoice({
46
- id: r.invoice_id,
47
- link: r.checkout_link ?? r.lightning_invoice ?? r.payment_request ?? "",
48
- sats: r.amount_sats ?? amount,
49
- });
50
- }
51
- catch (e) {
52
- setMsg(e.message);
53
- }
54
- finally {
55
- setBusy(false);
56
- }
57
- }
58
- async function confirm() {
59
- if (!invoice)
60
- return;
61
- setBusy(true);
62
- setMsg("");
63
- try {
64
- const r = await checkPayment(invoice.id);
65
- if (r.status === "Settled") {
66
- setInvoice(null);
67
- setMsg(`Paid. ${sats(r.credits_granted ?? invoice.sats)} sats added.`);
68
- load();
69
- }
70
- else {
71
- setMsg(`Not paid yet — the invoice reads ${r.status ?? "unknown"}.`);
72
- }
73
- }
74
- catch (e) {
75
- setMsg(e.message);
76
- }
77
- finally {
78
- setBusy(false);
79
- }
80
- }
81
- return (_jsxs("section", { className: `${card} p-4`, children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsx("span", { className: `text-sm ${muted}`, children: "Balance" }), _jsx("button", { type: "button", onClick: load, title: "Refresh", "aria-label": "Refresh balance", className: iconButton, children: _jsx(RefreshCw, { size: 14 }) })] }), _jsxs("div", { className: "mt-1 flex items-baseline gap-2", children: [_jsx("span", { className: "text-2xl font-semibold tabular-nums", children: sats(reachable ? balance : null) }), _jsx("span", { className: `text-sm ${muted}`, children: "sats" })] }), !reachable && _jsxs("p", { className: `mt-2 text-xs ${muted}`, children: [appName, " did not answer, so no balance is shown."] }), !invoice ? (_jsx("div", { className: "mt-4 flex gap-2", children: topUps.map((n) => (_jsxs("button", { type: "button", disabled: busy, onClick: () => topUp(n), className: ghost, children: ["+", n.toLocaleString("en-US")] }, n))) })) : (_jsxs("div", { className: "mt-4 space-y-2", children: [_jsxs("a", { href: invoice.link, target: "_blank", rel: "noreferrer", className: `${primary} flex items-center justify-center gap-2`, children: [_jsx(Zap, { size: 16 }), " Pay ", invoice.sats.toLocaleString("en-US"), " sats"] }), _jsxs("div", { className: "flex gap-2", children: [_jsx("button", { type: "button", disabled: busy, onClick: confirm, className: ghost, children: "I've paid \u2014 check" }), _jsx("button", { type: "button", onClick: () => setInvoice(null), className: `${ghost} w-auto px-4`, children: "Cancel" })] })] })), msg && _jsx("p", { className: `mt-3 text-xs ${muted}`, children: msg })] }));
35
+ const { state, create, check, cancel } = useTopUp({ onSettled: load });
36
+ const busy = state.phase === "creating" || (state.phase === "awaiting" && state.checking);
37
+ const invoice = state.phase === "awaiting" ? state.invoice : null;
38
+ const msg = state.phase === "settled"
39
+ ? `Paid. ${formatSats(state.credited)} sats added.`
40
+ : state.phase === "failed"
41
+ ? state.message
42
+ : state.phase === "awaiting" && state.status
43
+ ? `Not paid yet — ${state.status}`
44
+ : state.phase === "idle"
45
+ ? (state.message ?? "")
46
+ : "";
47
+ return (_jsxs("section", { className: `${card} p-4`, children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsx("span", { className: `text-sm ${muted}`, children: "Balance" }), _jsx("button", { type: "button", onClick: load, title: "Refresh", "aria-label": "Refresh balance", className: iconButton, children: _jsx(RefreshCw, { size: 14 }) })] }), _jsxs("div", { className: "mt-1 flex items-baseline gap-2", children: [_jsx("span", { className: "text-2xl font-semibold tabular-nums", children: formatSats(reachable ? balance : null) }), _jsx("span", { className: `text-sm ${muted}`, children: "sats" })] }), !reachable && _jsxs("p", { className: `mt-2 text-xs ${muted}`, children: [appName, " did not answer, so no balance is shown."] }), !invoice ? (_jsx("div", { className: "mt-4 flex gap-2", children: topUps.map((n) => (_jsxs("button", { type: "button", disabled: busy, onClick: () => create(n), className: ghost, children: ["+", n.toLocaleString("en-US")] }, n))) })) : (_jsxs("div", { className: "mt-4 space-y-2", children: [_jsxs("a", { href: invoice.checkoutLink ?? (invoice.bolt11 ? `lightning:${invoice.bolt11}` : "#"), target: "_blank", rel: "noreferrer", className: `${primary} flex items-center justify-center gap-2`, children: [_jsx(Zap, { size: 16 }), " Pay ", invoice.sats.toLocaleString("en-US"), " sats"] }), _jsxs("div", { className: "flex gap-2", children: [_jsx("button", { type: "button", disabled: busy, onClick: check, className: ghost, children: "I've paid \u2014 check" }), _jsx("button", { type: "button", onClick: cancel, className: `${ghost} w-auto px-4`, children: "Cancel" })] })] })), msg && _jsx("p", { className: `mt-3 text-xs ${muted}`, children: msg })] }));
82
48
  }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * The patron's whole account at this operator: balance, a Lightning top-up,
3
+ * credit tranches, the statement and coupons.
4
+ *
5
+ * Mechanics only. The package makes the calls (check_balance,
6
+ * purchase_credits → check_payment, account_statement, the coupon tools),
7
+ * holds the states and writes the words; every visual choice belongs to the
8
+ * site, through `classNames` — with none it is plain markup that inherits from
9
+ * the page. Actions are chips (`classNames.chip`). Anything only one site has
10
+ * (a funding-health panel, an operator view) goes in `before` or `children`.
11
+ *
12
+ * A balance nobody has read is a dash, never 0: showing 0 to someone with
13
+ * funds sends them to buy credits they already own.
14
+ */
15
+ import { type ReactNode } from "react";
16
+ import { type CouponsPanelProps } from "./CouponsPanel.tsx";
17
+ import { type UseTopUpOptions } from "./useTopUp.ts";
18
+ export interface WalletPageClassNames {
19
+ root?: string;
20
+ heading?: string;
21
+ /** Each block: balance, top-up, tranches, statement. */
22
+ section?: string;
23
+ sectionTitle?: string;
24
+ /** The balance figure and its unit. */
25
+ figure?: string;
26
+ unit?: string;
27
+ /** The deposited · consumed · expired · tranches line, and each item in it. */
28
+ stats?: string;
29
+ stat?: string;
30
+ /** A heads-up: a stale ledger, pending invoices, credit about to expire. */
31
+ notice?: string;
32
+ error?: string;
33
+ /** Every action. */
34
+ chip?: string;
35
+ /** Added to a chip that is the current choice. */
36
+ chipActive?: string;
37
+ /** A row of chips. */
38
+ chips?: string;
39
+ input?: string;
40
+ /** The open invoice's box, and its BOLT11 string. */
41
+ invoice?: string;
42
+ bolt11?: string;
43
+ /** The line saying where a payment stands. */
44
+ status?: string;
45
+ list?: string;
46
+ row?: string;
47
+ }
48
+ export interface WalletPageProps {
49
+ /** The page heading. Default "Wallet"; null for none. */
50
+ heading?: ReactNode;
51
+ /** Preset amounts, in sats, offered as chips. */
52
+ topUps?: number[];
53
+ /** Offer a box for any amount. Default true. */
54
+ customAmount?: boolean;
55
+ /** Polling for an open invoice (see `useTopUp`). */
56
+ poll?: Pick<UseTopUpOptions, "pollMs" | "maxChecks">;
57
+ /** Show the coupons panel, optionally with its own props. Default true. */
58
+ coupons?: boolean | CouponsPanelProps;
59
+ /** Offer the statement (loaded on request). Default true. */
60
+ statement?: boolean;
61
+ /** How a date and a date-time are written — pass the site's time-zone formatter. */
62
+ formatDate?: (iso: string) => string;
63
+ formatDateTime?: (iso: string) => string;
64
+ /** Site content above the balance, e.g. a funding-health panel. */
65
+ before?: ReactNode;
66
+ /** Site content at the end. */
67
+ children?: ReactNode;
68
+ classNames?: WalletPageClassNames;
69
+ }
70
+ export default function WalletPage({ heading, topUps, customAmount, poll, coupons, statement, formatDate, formatDateTime, before, children, classNames: c, }: WalletPageProps): import("react").JSX.Element;
@@ -0,0 +1,104 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * The patron's whole account at this operator: balance, a Lightning top-up,
4
+ * credit tranches, the statement and coupons.
5
+ *
6
+ * Mechanics only. The package makes the calls (check_balance,
7
+ * purchase_credits → check_payment, account_statement, the coupon tools),
8
+ * holds the states and writes the words; every visual choice belongs to the
9
+ * site, through `classNames` — with none it is plain markup that inherits from
10
+ * the page. Actions are chips (`classNames.chip`). Anything only one site has
11
+ * (a funding-health panel, an operator view) goes in `before` or `children`.
12
+ *
13
+ * A balance nobody has read is a dash, never 0: showing 0 to someone with
14
+ * funds sends them to buy credits they already own.
15
+ */
16
+ import { useCallback, useEffect, useState } from "react";
17
+ import { checkBalance, getAccountStatement, } from "../standardTools.js";
18
+ import { formatSats, parseSats } from "../wallet.js";
19
+ import CouponsPanel, {} from "./CouponsPanel.js";
20
+ import { useTopUp } from "./useTopUp.js";
21
+ const DEFAULT_TOP_UPS = [1_000, 5_000, 25_000];
22
+ const localDate = (iso) => new Date(iso).toLocaleDateString();
23
+ const localDateTime = (iso) => new Date(iso).toLocaleString();
24
+ function cx(...parts) {
25
+ const s = parts.filter(Boolean).join(" ");
26
+ return s || undefined;
27
+ }
28
+ export default function WalletPage({ heading = "Wallet", topUps = DEFAULT_TOP_UPS, customAmount = true, poll, coupons = true, statement = true, formatDate = localDate, formatDateTime = localDateTime, before, children, classNames: c = {}, }) {
29
+ const [bal, setBal] = useState(null);
30
+ const [balError, setBalError] = useState(null);
31
+ const refresh = useCallback(async () => {
32
+ try {
33
+ const r = await checkBalance();
34
+ if (r.error)
35
+ setBalError(r.error);
36
+ else {
37
+ setBal(r);
38
+ setBalError(null);
39
+ }
40
+ }
41
+ catch (e) {
42
+ setBalError(e.message);
43
+ }
44
+ }, []);
45
+ useEffect(() => {
46
+ void refresh();
47
+ }, [refresh]);
48
+ const couponProps = typeof coupons === "object" ? coupons : {};
49
+ return (_jsxs("div", { className: c.root, children: [heading && _jsx("h1", { className: c.heading, children: heading }), before, _jsx(Balance, { bal: bal, error: balError, refresh: refresh, formatDateTime: formatDateTime, c: c }), _jsx(TopUpSection, { topUps: topUps, customAmount: customAmount, poll: poll, onSettled: refresh, c: c }), bal?.tranches && bal.tranches.length > 0 && (_jsx(Tranches, { tranches: bal.tranches, formatDate: formatDate, c: c })), statement && _jsx(Statement, { formatDate: formatDate, c: c }), coupons !== false && _jsx(CouponsPanel, { ...couponProps }), children] }));
50
+ }
51
+ function Balance({ bal, error, refresh, formatDateTime, c, }) {
52
+ const pending = bal?.pending_invoices ?? 0;
53
+ const expiring = bal?.expiring_within_24h_sats ?? 0;
54
+ return (_jsxs("section", { className: c.section, children: [_jsx("div", { className: c.sectionTitle, children: "Balance" }), _jsxs("div", { children: [_jsx("span", { className: c.figure, children: formatSats(error ? null : bal?.balance_api_sats) }), " ", _jsx("span", { className: c.unit, children: "sats" })] }), bal && !error && (_jsxs("div", { className: c.stats, children: [_jsxs("span", { className: c.stat, children: ["deposited ", formatSats(bal.total_deposited_api_sats)] }), " ", _jsxs("span", { className: c.stat, children: ["consumed ", formatSats(bal.total_consumed_api_sats)] }), " ", !!bal.total_expired_api_sats && (_jsxs(_Fragment, { children: [_jsxs("span", { className: c.stat, children: ["expired ", formatSats(bal.total_expired_api_sats)] }), " "] })), _jsxs("span", { className: c.stat, children: [bal.active_tranches ?? 0, " active tranche", bal.active_tranches === 1 ? "" : "s"] })] })), error && _jsxs("p", { className: c.error, children: ["The balance could not be read: ", error] }), bal?.vault_unavailable && (_jsx("p", { className: c.notice, children: bal.warning ?? "The ledger is not available yet — the balance may be stale." })), pending > 0 && (_jsxs("p", { className: c.notice, children: [pending, " invoice", pending === 1 ? "" : "s", " awaiting payment"] })), expiring > 0 && _jsxs("p", { className: c.notice, children: [formatSats(expiring), " sats expire within 24 hours"] }), bal?.next_expiration_iso && (_jsxs("p", { className: c.notice, children: ["next expiry ", formatDateTime(bal.next_expiration_iso)] })), _jsx("div", { className: c.chips, children: _jsx("button", { type: "button", onClick: () => void refresh(), className: c.chip, children: "Refresh" }) })] }));
55
+ }
56
+ function TopUpSection({ topUps, customAmount, poll, onSettled, c, }) {
57
+ const { state, create, check, cancel, reset } = useTopUp({ ...poll, onSettled });
58
+ const [custom, setCustom] = useState("");
59
+ const [copied, setCopied] = useState(false);
60
+ const customSats = parseSats(custom);
61
+ const creating = state.phase === "creating";
62
+ async function copy(text) {
63
+ try {
64
+ await navigator.clipboard.writeText(text);
65
+ setCopied(true);
66
+ setTimeout(() => setCopied(false), 1500);
67
+ }
68
+ catch {
69
+ /* the string is on screen and selectable */
70
+ }
71
+ }
72
+ return (_jsxs("section", { className: c.section, children: [_jsx("div", { className: c.sectionTitle, children: "Top up with Lightning" }), (state.phase === "idle" || state.phase === "creating") && (_jsxs(_Fragment, { children: [_jsx("div", { className: c.chips, children: topUps.map((n) => (_jsxs("button", { type: "button", disabled: creating, onClick: () => create(n), className: cx(c.chip, creating && state.sats === n && c.chipActive), children: [n.toLocaleString("en-US"), " sats"] }, n))) }), customAmount && (_jsxs("div", { className: c.chips, children: [_jsx("input", { inputMode: "numeric", value: custom, onChange: (e) => setCustom(e.target.value.replace(/[^\d]/g, "")), onKeyDown: (e) => {
73
+ if (e.key === "Enter" && customSats)
74
+ create(customSats);
75
+ }, placeholder: "Any amount", "aria-label": "Amount in sats", className: c.input }), _jsx("button", { type: "button", disabled: creating || !customSats, onClick: () => customSats && create(customSats), className: c.chip, children: "Create invoice" })] })), creating && _jsx("p", { className: c.status, children: "Making a Lightning invoice\u2026" }), state.phase === "idle" && state.message && _jsx("p", { className: c.error, children: state.message })] })), state.phase === "awaiting" && (_jsxs("div", { className: c.invoice, children: [_jsxs("p", { className: c.status, children: ["Invoice for ", formatSats(state.invoice.sats), " sats"] }), state.invoice.bolt11 && _jsx("div", { className: c.bolt11, children: state.invoice.bolt11 }), _jsxs("div", { className: c.chips, children: [state.invoice.checkoutLink && (_jsx("a", { href: state.invoice.checkoutLink, target: "_blank", rel: "noreferrer", className: c.chip, children: "Open checkout" })), state.invoice.bolt11 && (_jsx("button", { type: "button", onClick: () => void copy(state.invoice.bolt11), className: c.chip, children: copied ? "Copied" : "Copy invoice" })), _jsx("button", { type: "button", disabled: state.checking, onClick: check, className: c.chip, children: state.checking ? "Checking…" : "I've paid — check" }), _jsx("button", { type: "button", onClick: cancel, className: c.chip, children: "Cancel" })] }), _jsx("p", { className: c.status, "aria-live": "polite", children: state.status ?? "Waiting for payment." })] })), state.phase === "settled" && (_jsxs(_Fragment, { children: [_jsxs("p", { className: c.status, "aria-live": "polite", children: ["Paid. ", formatSats(state.credited), " sats added."] }), _jsx("div", { className: c.chips, children: _jsx("button", { type: "button", onClick: reset, className: c.chip, children: "Done" }) })] })), state.phase === "failed" && (_jsxs(_Fragment, { children: [_jsx("p", { className: c.error, children: state.message }), _jsx("div", { className: c.chips, children: _jsx("button", { type: "button", onClick: reset, className: c.chip, children: "Try again" }) })] }))] }));
76
+ }
77
+ function Tranches({ tranches, formatDate, c, }) {
78
+ return (_jsxs("section", { className: c.section, children: [_jsx("div", { className: c.sectionTitle, children: "Credit tranches" }), _jsx("ul", { className: c.list, children: tranches.map((t) => (_jsxs("li", { className: c.row, children: [_jsxs("span", { children: [formatSats(t.remaining_sats), " / ", formatSats(t.amount_sats), " sats"] }), " ", _jsx("span", { children: t.expires_at ? `expires ${formatDate(t.expires_at)}` : "no expiry" })] }, t.id))) })] }));
79
+ }
80
+ function Statement({ formatDate, c }) {
81
+ const [stmt, setStmt] = useState(null);
82
+ const [loading, setLoading] = useState(false);
83
+ const [error, setError] = useState(null);
84
+ async function load() {
85
+ setLoading(true);
86
+ setError(null);
87
+ try {
88
+ const r = await getAccountStatement(30);
89
+ if (r.error)
90
+ setError(r.error);
91
+ else
92
+ setStmt(r);
93
+ }
94
+ catch (e) {
95
+ setError(e.message);
96
+ }
97
+ finally {
98
+ setLoading(false);
99
+ }
100
+ }
101
+ const purchases = stmt?.purchase_history ?? [];
102
+ const usage = stmt?.tool_usage_all_time ?? [];
103
+ return (_jsxs("section", { className: c.section, children: [_jsx("div", { className: c.sectionTitle, children: "Statement" }), _jsx("div", { className: c.chips, children: _jsx("button", { type: "button", disabled: loading, onClick: () => void load(), className: c.chip, children: loading ? "Loading…" : stmt ? "Refresh statement" : "Load statement" }) }), error && _jsx("p", { className: c.error, children: error }), stmt && (_jsxs(_Fragment, { children: [_jsx("div", { className: c.sectionTitle, children: "Purchases" }), purchases.length === 0 ? (_jsx("p", { className: c.status, children: "No purchases yet." })) : (_jsx("ul", { className: c.list, children: purchases.map((p) => (_jsxs("li", { className: c.row, children: [_jsx("span", { children: p.created_at ? formatDate(p.created_at) : "—" }), " ", _jsxs("span", { children: [formatSats(p.amount_sats), " sats"] }), " ", _jsxs("span", { children: [p.status, p.api_sats_credited ? ` · ${formatSats(p.api_sats_credited)} credited` : ""] })] }, p.invoice_id))) })), _jsx("div", { className: c.sectionTitle, children: "Usage" }), usage.length === 0 ? (_jsx("p", { className: c.status, children: "No paid calls yet." })) : (_jsx("ul", { className: c.list, children: usage.map((u) => (_jsxs("li", { className: c.row, children: [_jsx("span", { children: u.tool }), " ", _jsxs("span", { children: [u.calls.toLocaleString("en-US"), " call", u.calls === 1 ? "" : "s", " \u00B7 ", formatSats(u.api_sats), " sats"] })] }, u.tool))) }))] }))] }));
104
+ }
@@ -1,10 +1,18 @@
1
1
  export { default as Avatar } from "./Avatar.tsx";
2
2
  export { default as AvatarPicker } from "./AvatarPicker.tsx";
3
+ export { default as CouponsPanel, type CouponsPanelProps, type CouponsPanelClassNames } from "./CouponsPanel.tsx";
3
4
  export { default as DebugPanel, type DebugPanelProps } from "./DebugPanel.tsx";
5
+ export { default as ErrorBoundary, type ErrorBoundaryProps, type ErrorBoundaryClassNames, type ErrorFallbackProps, } from "./ErrorBoundary.tsx";
4
6
  export { default as NpubGate } from "./NpubGate.tsx";
5
7
  export { default as NostrProfilePanel } from "./NostrProfilePanel.tsx";
8
+ export { PageControls, SortHeader, TableShell, type PageControlsProps, type PageControlsClassNames, type SortHeaderProps, type SortHeaderClassNames, type TableShellClassNames, } from "./PagedTable.tsx";
6
9
  export { default as QuoteScroller, type QuoteScrollerProps, type QuoteScrollerClassNames } from "./QuoteScroller.tsx";
7
10
  export { default as SessionKeyClaim } from "./SessionKeyClaim.tsx";
11
+ export { default as TableFilter, type DateFieldOption, type FilterQuestion, type TableFilterProps, type TableFilterClassNames, type TableFilterDates, type TableFilterQuestions, type TableFilterSearch, } from "./TableFilter.tsx";
12
+ export { default as ThemeToggle, type ThemeToggleProps, type ThemeToggleClassNames } from "./ThemeToggle.tsx";
8
13
  export { default as WalletCard } from "./WalletCard.tsx";
14
+ export { default as WalletPage, type WalletPageProps, type WalletPageClassNames } from "./WalletPage.tsx";
9
15
  export { useSession, type Session } from "./useSession.ts";
10
16
  export { useDebugLog } from "./useDebugLog.ts";
17
+ export { useTheme } from "./useTheme.ts";
18
+ export { useTopUp, type TopUp, type UseTopUpOptions } from "./useTopUp.ts";
@@ -1,10 +1,18 @@
1
1
  export { default as Avatar } from "./Avatar.js";
2
2
  export { default as AvatarPicker } from "./AvatarPicker.js";
3
+ export { default as CouponsPanel } from "./CouponsPanel.js";
3
4
  export { default as DebugPanel } from "./DebugPanel.js";
5
+ export { default as ErrorBoundary, } from "./ErrorBoundary.js";
4
6
  export { default as NpubGate } from "./NpubGate.js";
5
7
  export { default as NostrProfilePanel } from "./NostrProfilePanel.js";
8
+ export { PageControls, SortHeader, TableShell, } from "./PagedTable.js";
6
9
  export { default as QuoteScroller } from "./QuoteScroller.js";
7
10
  export { default as SessionKeyClaim } from "./SessionKeyClaim.js";
11
+ export { default as TableFilter, } from "./TableFilter.js";
12
+ export { default as ThemeToggle } from "./ThemeToggle.js";
8
13
  export { default as WalletCard } from "./WalletCard.js";
14
+ export { default as WalletPage } from "./WalletPage.js";
9
15
  export { useSession } from "./useSession.js";
10
16
  export { useDebugLog } from "./useDebugLog.js";
17
+ export { useTheme } from "./useTheme.js";
18
+ export { useTopUp } from "./useTopUp.js";
@@ -0,0 +1,7 @@
1
+ /**
2
+ * The theme pick as React state: persisted under `<prefix>:theme`, applied to
3
+ * <html>, followed across tabs, and — in "system" — following the OS live.
4
+ * Call `bootstrapTheme()` in main.tsx too, so the first frame is right.
5
+ */
6
+ import { type Theme } from "../theme.ts";
7
+ export declare function useTheme(fallback?: Theme): [Theme, (next: Theme) => void];
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The theme pick as React state: persisted under `<prefix>:theme`, applied to
3
+ * <html>, followed across tabs, and — in "system" — following the OS live.
4
+ * Call `bootstrapTheme()` in main.tsx too, so the first frame is right.
5
+ */
6
+ import { useCallback, useEffect, useState } from "react";
7
+ import { applyTheme, readTheme, themeStorageKey, writeTheme } from "../theme.js";
8
+ export function useTheme(fallback = "dark") {
9
+ const [theme, setTheme] = useState(() => readTheme(fallback));
10
+ // Another tab changed it.
11
+ useEffect(() => {
12
+ let key;
13
+ try {
14
+ key = themeStorageKey();
15
+ }
16
+ catch {
17
+ return;
18
+ }
19
+ const onStorage = (e) => {
20
+ if (e.key !== key)
21
+ return;
22
+ const next = readTheme(fallback);
23
+ setTheme(next);
24
+ applyTheme(next);
25
+ };
26
+ window.addEventListener("storage", onStorage);
27
+ return () => window.removeEventListener("storage", onStorage);
28
+ }, [fallback]);
29
+ // In "system", the OS changed its mind.
30
+ useEffect(() => {
31
+ if (theme !== "system" || !window.matchMedia)
32
+ return;
33
+ const mq = window.matchMedia("(prefers-color-scheme: dark)");
34
+ const onChange = () => applyTheme("system");
35
+ mq.addEventListener("change", onChange);
36
+ return () => mq.removeEventListener("change", onChange);
37
+ }, [theme]);
38
+ const update = useCallback((next) => {
39
+ writeTheme(next);
40
+ applyTheme(next);
41
+ setTheme(next);
42
+ }, []);
43
+ return [theme, update];
44
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * A Lightning top-up as a hook: purchase_credits → an invoice → check_payment
3
+ * until it settles, expires or the patron gives up.
4
+ *
5
+ * The state machine is `topUpReducer` (framework-free, tested). This adds the
6
+ * calls and the polling: while an invoice is out and the tab is visible,
7
+ * check_payment runs every `pollMs` — it is free and safe to repeat — up to
8
+ * `maxChecks` times, after which the patron's own "check" still works.
9
+ */
10
+ import { type TopUpState } from "../wallet.ts";
11
+ export interface UseTopUpOptions {
12
+ /** How often to check an open invoice, in ms. 0 turns polling off. Default 5000. */
13
+ pollMs?: number;
14
+ /** Stop polling after this many checks. Default 60 (five minutes at the default rate). */
15
+ maxChecks?: number;
16
+ /** Called once when an invoice settles, with the sats credited — refresh a balance here. */
17
+ onSettled?: (credited: number) => void;
18
+ }
19
+ export interface TopUp {
20
+ state: TopUpState;
21
+ /** Ask the service for an invoice of `sats`. Ignored while one is being made or is out. */
22
+ create: (sats: number) => void;
23
+ /** Check the open invoice now. */
24
+ check: () => void;
25
+ /** Drop the open invoice (it simply expires at BTCPay). */
26
+ cancel: () => void;
27
+ /** Back to idle after a settlement or failure. */
28
+ reset: () => void;
29
+ }
30
+ export declare function useTopUp({ pollMs, maxChecks, onSettled }?: UseTopUpOptions): TopUp;