@tollbooth-dpyc/web 1.0.0 → 1.2.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.
Files changed (44) hide show
  1. package/README.md +63 -1
  2. package/dist/client.d.ts +13 -1
  3. package/dist/client.js +18 -2
  4. package/dist/debugLog.js +5 -2
  5. package/dist/errorReport.d.ts +5 -0
  6. package/dist/errorReport.js +14 -0
  7. package/dist/index.d.ts +6 -1
  8. package/dist/index.js +6 -1
  9. package/dist/inlineProof.d.ts +8 -0
  10. package/dist/inlineProof.js +15 -1
  11. package/dist/networkError.d.ts +29 -0
  12. package/dist/networkError.js +61 -0
  13. package/dist/react/CouponsPanel.d.ts +52 -0
  14. package/dist/react/CouponsPanel.js +98 -0
  15. package/dist/react/ErrorBoundary.d.ts +55 -0
  16. package/dist/react/ErrorBoundary.js +49 -0
  17. package/dist/react/PagedTable.d.ts +64 -0
  18. package/dist/react/PagedTable.js +28 -0
  19. package/dist/react/TableFilter.d.ts +93 -0
  20. package/dist/react/TableFilter.js +68 -0
  21. package/dist/react/ThemeToggle.d.ts +22 -0
  22. package/dist/react/ThemeToggle.js +11 -0
  23. package/dist/react/WalletCard.d.ts +3 -0
  24. package/dist/react/WalletCard.js +19 -53
  25. package/dist/react/WalletPage.d.ts +70 -0
  26. package/dist/react/WalletPage.js +104 -0
  27. package/dist/react/index.d.ts +8 -0
  28. package/dist/react/index.js +8 -0
  29. package/dist/react/useSession.d.ts +2 -0
  30. package/dist/react/useSession.js +2 -1
  31. package/dist/react/useTheme.d.ts +7 -0
  32. package/dist/react/useTheme.js +44 -0
  33. package/dist/react/useTopUp.d.ts +30 -0
  34. package/dist/react/useTopUp.js +74 -0
  35. package/dist/standardTools.d.ts +325 -16
  36. package/dist/standardTools.js +58 -4
  37. package/dist/table.d.ts +52 -0
  38. package/dist/table.js +95 -0
  39. package/dist/theme.d.ts +40 -0
  40. package/dist/theme.js +76 -0
  41. package/dist/wallet.d.ts +85 -0
  42. package/dist/wallet.js +153 -0
  43. package/package.json +1 -1
  44. package/theme.css +22 -0
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The parts of a sortable, paged table: a header cell that sorts, the
3
+ * First / Prev / Page N of M / Next / Last controls, and the frame.
4
+ *
5
+ * Mechanics only — the sort toggling and the paging arithmetic (`nextSort`,
6
+ * `lastPage`, `clampPage` in the framework-free half). How it looks is the
7
+ * site's, through `classNames`; the controls are chips. Paging can be the
8
+ * server's (pass its total) or the browser's (`pageRows`).
9
+ */
10
+ import type { ReactNode } from "react";
11
+ import { type SortDir } from "../table.ts";
12
+ export interface SortHeaderClassNames {
13
+ /** The <th>. */
14
+ cell?: string;
15
+ /** The button inside a sortable header. */
16
+ button?: string;
17
+ /** Added to the button of the column being sorted. */
18
+ active?: string;
19
+ /** The ▾ / ▴ mark. */
20
+ arrow?: string;
21
+ }
22
+ export interface SortHeaderProps {
23
+ label: ReactNode;
24
+ /** The column's sort key. Omit for a header that does not sort. */
25
+ col?: string;
26
+ activeCol: string;
27
+ dir: SortDir;
28
+ onSort: (col: string, dir: SortDir) => void;
29
+ /** The direction a newly chosen column starts in. Default "desc". */
30
+ initialDir?: SortDir;
31
+ classNames?: SortHeaderClassNames;
32
+ }
33
+ /** A header cell. Tapping the sorted column flips it; another column starts at `initialDir`. */
34
+ export declare function SortHeader({ label, col, activeCol, dir, onSort, initialDir, classNames: c }: SortHeaderProps): import("react").JSX.Element;
35
+ export interface PageControlsClassNames {
36
+ root?: string;
37
+ /** Every control. */
38
+ chip?: string;
39
+ /** "Page N of M · K total". */
40
+ label?: string;
41
+ }
42
+ export interface PageControlsProps {
43
+ /** Zero-based. */
44
+ page: number;
45
+ pageSize: number;
46
+ total: number;
47
+ onPage: (page: number) => void;
48
+ /** Render nothing when everything fits on one page. Default false. */
49
+ hideSinglePage?: boolean;
50
+ classNames?: PageControlsClassNames;
51
+ }
52
+ /** First / ← Prev / Page N of M · K total / Next → / Last. */
53
+ export declare function PageControls({ page, pageSize, total, onPage, hideSinglePage, classNames: c }: PageControlsProps): import("react").JSX.Element | null;
54
+ export interface TableShellClassNames {
55
+ /** The frame. Give it `overflow-x: auto` to scroll a wide table — the
56
+ * package does not, since a scrolling box clips popovers inside it. */
57
+ root?: string;
58
+ table?: string;
59
+ }
60
+ /** The frame every paged table sits in: a box and the <table>. */
61
+ export declare function TableShell({ children, classNames: c }: {
62
+ children: ReactNode;
63
+ classNames?: TableShellClassNames;
64
+ }): import("react").JSX.Element;
@@ -0,0 +1,28 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { clampPage, lastPage, nextSort } from "../table.js";
3
+ function cx(...parts) {
4
+ const s = parts.filter(Boolean).join(" ");
5
+ return s || undefined;
6
+ }
7
+ /** A header cell. Tapping the sorted column flips it; another column starts at `initialDir`. */
8
+ export function SortHeader({ label, col, activeCol, dir, onSort, initialDir = "desc", classNames: c = {} }) {
9
+ if (!col)
10
+ return _jsx("th", { className: c.cell, children: label });
11
+ const active = col === activeCol;
12
+ return (_jsx("th", { className: c.cell, "aria-sort": active ? (dir === "desc" ? "descending" : "ascending") : undefined, children: _jsxs("button", { type: "button", onClick: () => {
13
+ const next = nextSort(col, activeCol, dir, initialDir);
14
+ onSort(next.col, next.dir);
15
+ }, className: cx(c.button, active && c.active), children: [label, active && (_jsx("span", { "aria-hidden": true, className: c.arrow, children: dir === "desc" ? " ▾" : " ▴" }))] }) }));
16
+ }
17
+ /** First / ← Prev / Page N of M · K total / Next → / Last. */
18
+ export function PageControls({ page, pageSize, total, onPage, hideSinglePage = false, classNames: c = {} }) {
19
+ const last = lastPage(total, pageSize);
20
+ if (hideSinglePage && last === 0)
21
+ return null;
22
+ const p = clampPage(page, total, pageSize);
23
+ return (_jsxs("nav", { "aria-label": "Pages", className: c.root, children: [_jsx("button", { type: "button", className: c.chip, disabled: p === 0, onClick: () => onPage(0), title: "First page", "aria-label": "First page", children: "\u23EE" }), _jsx("button", { type: "button", className: c.chip, disabled: p === 0, onClick: () => onPage(p - 1), children: "\u2190 Prev" }), _jsxs("span", { className: c.label, children: ["Page ", p + 1, " of ", last + 1, " \u00B7 ", total.toLocaleString("en-US"), " total"] }), _jsx("button", { type: "button", className: c.chip, disabled: p >= last, onClick: () => onPage(p + 1), children: "Next \u2192" }), _jsx("button", { type: "button", className: c.chip, disabled: p >= last, onClick: () => onPage(last), title: "Last page", "aria-label": "Last page", children: "\u23ED" })] }));
24
+ }
25
+ /** The frame every paged table sits in: a box and the <table>. */
26
+ export function TableShell({ children, classNames: c = {} }) {
27
+ return (_jsx("div", { className: c.root, children: _jsx("table", { className: c.table, children: children }) }));
28
+ }
@@ -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";
@@ -12,6 +12,8 @@ export interface Session {
12
12
  canSign: boolean;
13
13
  /** Set when a cached proof lapses: a calm prompt to sign in again. */
14
14
  notice: string;
15
+ /** Clear the notice without signing in — the patron read it and moved on. */
16
+ dismissNotice: () => void;
15
17
  /** Call after a successful sign-in so the whole app notices. */
16
18
  refresh: () => void;
17
19
  signOut: () => void;