@tollbooth-dpyc/web 1.1.0 → 1.3.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.
package/README.md CHANGED
@@ -87,7 +87,7 @@ bootstrapTheme(); // main.tsx, after configureTollbooth: <prefix>:the
87
87
  formatDateTime={(iso) => formatInZone(iso, zone)}
88
88
  before={<FundingStatus />}
89
89
  coupons={{ classNames: { root: "card", chip: "chip" } }}
90
- classNames={{ section: "card p-5", figure: "text-3xl", chip: "chip", chipActive: "chip-on" }}
90
+ classNames={{ section: "card p-5", figure: "text-3xl", chip: "chip", chipActive: "chip-on", primary: "chip-accent" }}
91
91
  />
92
92
 
93
93
  <ThemeToggle themes={["dark", "light", "system"]} classNames={{ chip: "chip", active: "chip-on" }} />
@@ -98,8 +98,18 @@ bootstrapTheme(); // main.tsx, after configureTollbooth: <prefix>:the
98
98
  onClear={clearAll}
99
99
  classNames={{ root: "flex gap-2", input: "field", chip: "chip" }}
100
100
  />
101
+
102
+ <SortHeader as="div" label="Date" col="date" activeCol={col} dir={dir} onSort={sortBy} /> // a div grid
101
103
  ```
102
104
 
105
+ A preset amount in `WalletPage` / `WalletCard` only fills the amount; the
106
+ invoice is made by the explicit "Create invoice". `classNames.primary` styles
107
+ the action that moves a top-up forward (Redeem in `CouponsPanel`); every other
108
+ action stays a chip. `TableFilter`'s panel slides itself back on screen on a
109
+ phone; `clearPlacement="panel"` puts Clear inside it. Every standard-tool
110
+ wrapper takes the same optional `CallOptions` as `callTool`, last
111
+ (`listCanonicalIdentities({ bestEffort: true })`).
112
+
103
113
  `useTopUp` is the wallet's top-up on its own (purchase_credits → invoice →
104
114
  check_payment, polled while the tab is visible) for a site drawing its own.
105
115
 
package/dist/client.js CHANGED
@@ -31,6 +31,7 @@ const STANDARD_BOOTSTRAP = [
31
31
  "publish_nostr_profile",
32
32
  "get_operator_onboarding_status",
33
33
  "check_authority_balance",
34
+ "get_pricing_model",
34
35
  "list_canonical_identities",
35
36
  "check_proof_status",
36
37
  "get_patron_onboarding_status",
package/dist/debugLog.js CHANGED
@@ -28,9 +28,12 @@ const REDACTED = "[redacted]";
28
28
  const SECRET_FIELD = "(?:nsec|ncryptsec|dpop_token|proof|poison|password|passphrase|secret|token|authorization|api_?key|private_?key|client_secret|[a-z0-9_]*_(?:nsec|token|secret|password|passphrase|api_?key|private_?key))";
29
29
  // `"dpop_token":"…"`, `\"dpop_token\":\"…\"` (JSON inside a JSON string),
30
30
  // `dpop_token=…` (a query string) and `dpop_token: '…'` (a Python repr). A
31
- // quoted value is taken whole, spaces and all; a bare one up to a delimiter.
31
+ // quoted value is taken whole — spaces, escaped quotes and the other kind of
32
+ // quote included — up to its own closing quote or the end of a truncated line;
33
+ // a bare one up to a delimiter. Stopping at the first quote of either kind let
34
+ // the tail of a secret holding `'` or `\"` through.
32
35
  function fieldValue(names) {
33
- return new RegExp(`((?:\\\\*["'])?\\b${names}(?:\\\\*["'])?\\s*[:=]\\s*)(?:(\\\\*["'])[^"'\\\\]*|[^"'\\\\,}\\]&\\s]+)`, "gi");
36
+ return new RegExp(`((?:\\\\*["'])?\\b${names}(?:\\\\*["'])?\\s*[:=]\\s*)(?:(\\\\*["'])(?:\\\\.|\\\\$|(?!\\2)[^\\\\])*?(?=\\2|$)|[^"'\\\\,}\\]&\\s]+)`, "gi");
34
37
  }
35
38
  const FIELD_VALUE = fieldValue(SECRET_FIELD);
36
39
  // update_patron_credential / update_operator_credential carry the secret as
@@ -5,6 +5,14 @@
5
5
  * runtime tool name, e.g. "chart_snapshot_display"), and `created_at` (within
6
6
  * about 60 seconds of server time). The wheel verifies the Schnorr signature
7
7
  * inline — no relay round trip, no cached phrase.
8
+ *
9
+ * Every proof also carries a random `nonce` tag, exactly as the wheel's own
10
+ * `create_proof` does. Without it, two calls to the same tool in the same
11
+ * wall-clock second sign byte-identical events, their ids collide, and the
12
+ * verifier's replay guard refuses the second. The nonce is signed but inert:
13
+ * `verify_proof` reads only the `u` tag.
8
14
  */
15
+ /** 16 random bytes as 32 lowercase hex chars — Python's `secrets.token_hex(16)`. */
16
+ export declare function proofNonce(): string;
9
17
  /** The JSON-stringified signed event, exactly what `verify_proof` expects. */
10
18
  export declare function signInlineProof(runtimeToolName: string, secretKey?: Uint8Array): string;
@@ -5,16 +5,30 @@
5
5
  * runtime tool name, e.g. "chart_snapshot_display"), and `created_at` (within
6
6
  * about 60 seconds of server time). The wheel verifies the Schnorr signature
7
7
  * inline — no relay round trip, no cached phrase.
8
+ *
9
+ * Every proof also carries a random `nonce` tag, exactly as the wheel's own
10
+ * `create_proof` does. Without it, two calls to the same tool in the same
11
+ * wall-clock second sign byte-identical events, their ids collide, and the
12
+ * verifier's replay guard refuses the second. The nonce is signed but inert:
13
+ * `verify_proof` reads only the `u` tag.
8
14
  */
9
15
  import { finalizeEvent } from "nostr-tools";
10
16
  import { getSessionNsecBytes } from "./sessionNsec.js";
11
17
  const PROOF_KIND = 27235;
18
+ /** 16 random bytes as 32 lowercase hex chars — Python's `secrets.token_hex(16)`. */
19
+ export function proofNonce() {
20
+ const bytes = crypto.getRandomValues(new Uint8Array(16));
21
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
22
+ }
12
23
  /** The JSON-stringified signed event, exactly what `verify_proof` expects. */
13
24
  export function signInlineProof(runtimeToolName, secretKey) {
14
25
  const signed = finalizeEvent({
15
26
  kind: PROOF_KIND,
16
27
  created_at: Math.floor(Date.now() / 1000),
17
- tags: [["u", runtimeToolName]],
28
+ tags: [
29
+ ["u", runtimeToolName],
30
+ ["nonce", proofNonce()],
31
+ ],
18
32
  content: "",
19
33
  }, secretKey ?? getSessionNsecBytes());
20
34
  return JSON.stringify(signed);
@@ -5,7 +5,8 @@
5
5
  * Mechanics only — the calls (list_my_coupons, redeem_coupon, forget_coupon),
6
6
  * the states and the words. Every visual choice is the site's, through
7
7
  * `classNames`; with none, the panel is plain markup inheriting from the page.
8
- * Actions (Redeem, Remove) are chips: `classNames.chip`.
8
+ * Actions (Redeem, Remove) are chips: `classNames.chip`; Redeem, the primary
9
+ * action, takes `classNames.primary` instead when a site gives it its accent.
9
10
  */
10
11
  import { type ReactNode } from "react";
11
12
  import { type PatronCoupon } from "../standardTools.ts";
@@ -18,6 +19,10 @@ export interface CouponsPanelClassNames {
18
19
  input?: string;
19
20
  /** Every action: Redeem, Remove. */
20
21
  chip?: string;
22
+ /** Redeem, in place of `chip`, for a site that gives it its accent. Default: `chip`. */
23
+ primary?: string;
24
+ /** The sub-labels `formHeading` and `listHeading`. */
25
+ subheading?: string;
21
26
  /** Added to the message after a redeem, with `ok` or `error`. */
22
27
  message?: string;
23
28
  ok?: string;
@@ -38,6 +43,10 @@ export interface CouponsPanelProps {
38
43
  intro?: ReactNode;
39
44
  /** Shown when the patron holds no coupons. */
40
45
  empty?: ReactNode;
46
+ /** A sub-label above the code box, e.g. "Redeem a code". Default none. */
47
+ formHeading?: ReactNode;
48
+ /** A sub-label above the held coupons, e.g. "Active". Shown only when there are some. Default none. */
49
+ listHeading?: ReactNode;
41
50
  placeholder?: string;
42
51
  /** Content of the Redeem chip. */
43
52
  redeemLabel?: ReactNode;
@@ -49,4 +58,4 @@ export interface CouponsPanelProps {
49
58
  /** Draw a coupon's row yourself; `forget` removes it (after confirmForget). */
50
59
  renderCoupon?: (coupon: PatronCoupon, forget: () => void) => ReactNode;
51
60
  }
52
- export default function CouponsPanel({ heading, intro, empty, placeholder, redeemLabel, forgetLabel, confirmForget, classNames: c, renderCoupon, }: CouponsPanelProps): import("react").JSX.Element;
61
+ export default function CouponsPanel({ heading, intro, empty, formHeading, listHeading, placeholder, redeemLabel, forgetLabel, confirmForget, classNames: c, renderCoupon, }: CouponsPanelProps): import("react").JSX.Element;
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  /**
3
3
  * The patron's coupons: redeem an operator's code once, see what is held, and
4
4
  * take one off the list.
@@ -6,7 +6,8 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
6
6
  * Mechanics only — the calls (list_my_coupons, redeem_coupon, forget_coupon),
7
7
  * the states and the words. Every visual choice is the site's, through
8
8
  * `classNames`; with none, the panel is plain markup inheriting from the page.
9
- * Actions (Redeem, Remove) are chips: `classNames.chip`.
9
+ * Actions (Redeem, Remove) are chips: `classNames.chip`; Redeem, the primary
10
+ * action, takes `classNames.primary` instead when a site gives it its accent.
10
11
  */
11
12
  import { useCallback, useEffect, useState } from "react";
12
13
  import { forgetCoupon, listMyCoupons, redeemCoupon } from "../standardTools.js";
@@ -21,7 +22,7 @@ function cx(...parts) {
21
22
  const s = parts.filter(Boolean).join(" ");
22
23
  return s || undefined;
23
24
  }
24
- export default function CouponsPanel({ heading = "My coupons", intro = DEFAULT_INTRO, empty = DEFAULT_EMPTY, placeholder = "Coupon code", redeemLabel = "Redeem", forgetLabel = "Remove", confirmForget = defaultConfirm, classNames: c = {}, renderCoupon, }) {
25
+ export default function CouponsPanel({ heading = "My coupons", intro = DEFAULT_INTRO, empty = DEFAULT_EMPTY, formHeading, listHeading, placeholder = "Coupon code", redeemLabel = "Redeem", forgetLabel = "Remove", confirmForget = defaultConfirm, classNames: c = {}, renderCoupon, }) {
25
26
  const [coupons, setCoupons] = useState([]);
26
27
  const [loading, setLoading] = useState(true);
27
28
  const [loadError, setLoadError] = useState(null);
@@ -86,10 +87,10 @@ export default function CouponsPanel({ heading = "My coupons", intro = DEFAULT_I
86
87
  setMsg({ ok: false, text: e.message });
87
88
  }
88
89
  }
89
- return (_jsxs("section", { className: c.root, children: [heading && _jsx("div", { className: c.heading, children: heading }), intro && _jsx("p", { className: c.intro, children: intro }), _jsxs("div", { className: c.form, children: [_jsx("input", { type: "text", value: code, onChange: (e) => setCode(e.target.value.toUpperCase()), onKeyDown: (e) => {
90
+ return (_jsxs("section", { className: c.root, children: [heading && _jsx("div", { className: c.heading, children: heading }), intro && _jsx("p", { className: c.intro, children: intro }), formHeading && _jsx("div", { className: c.subheading, children: formHeading }), _jsxs("div", { className: c.form, children: [_jsx("input", { type: "text", value: code, onChange: (e) => setCode(e.target.value.toUpperCase()), onKeyDown: (e) => {
90
91
  if (e.key === "Enter")
91
92
  void redeem();
92
- }, placeholder: placeholder, "aria-label": "Coupon code", autoCapitalize: "characters", autoCorrect: "off", spellCheck: false, disabled: redeeming, className: c.input }), _jsx("button", { type: "button", onClick: () => void redeem(), disabled: redeeming || !code.trim(), className: c.chip, children: redeeming ? "Redeeming…" : redeemLabel })] }), msg && (_jsx("p", { role: "status", className: cx(c.message, msg.ok ? c.ok : c.error), children: msg.text })), loading ? (_jsx("p", { className: c.loading, children: "Loading\u2026" })) : loadError ? (_jsx("p", { className: cx(c.message, c.error), children: loadError })) : coupons.length === 0 ? (empty && _jsx("p", { className: c.empty, children: empty })) : (_jsx("ul", { className: c.list, children: coupons.map((coupon) => renderCoupon ? (_jsx("li", { className: c.row, children: renderCoupon(coupon, () => void forget(coupon)) }, coupon.coupon_id)) : (_jsx(CouponRow, { coupon: coupon, c: c, forgetLabel: forgetLabel, onForget: () => void forget(coupon) }, coupon.coupon_id))) }))] }));
93
+ }, placeholder: placeholder, "aria-label": "Coupon code", autoCapitalize: "characters", autoCorrect: "off", spellCheck: false, disabled: redeeming, className: c.input }), _jsx("button", { type: "button", onClick: () => void redeem(), disabled: redeeming || !code.trim(), className: c.primary ?? c.chip, children: redeeming ? "Redeeming…" : redeemLabel })] }), msg && (_jsx("p", { role: "status", className: cx(c.message, msg.ok ? c.ok : c.error), children: msg.text })), loading ? (_jsx("p", { className: c.loading, children: "Loading\u2026" })) : loadError ? (_jsx("p", { className: cx(c.message, c.error), children: loadError })) : coupons.length === 0 ? (empty && _jsx("p", { className: c.empty, children: empty })) : (_jsxs(_Fragment, { children: [listHeading && _jsx("div", { className: c.subheading, children: listHeading }), _jsx("ul", { className: c.list, children: coupons.map((coupon) => renderCoupon ? (_jsx("li", { className: c.row, children: renderCoupon(coupon, () => void forget(coupon)) }, coupon.coupon_id)) : (_jsx(CouponRow, { coupon: coupon, c: c, forgetLabel: forgetLabel, onForget: () => void forget(coupon) }, coupon.coupon_id))) })] }))] }));
93
94
  }
94
95
  function CouponRow({ coupon, c, forgetLabel, onForget, }) {
95
96
  const active = coupon.status === "active";
@@ -10,7 +10,7 @@
10
10
  import type { ReactNode } from "react";
11
11
  import { type SortDir } from "../table.ts";
12
12
  export interface SortHeaderClassNames {
13
- /** The <th>. */
13
+ /** The header cell: the <th>, or the <div> when `as="div"`. */
14
14
  cell?: string;
15
15
  /** The button inside a sortable header. */
16
16
  button?: string;
@@ -28,10 +28,15 @@ export interface SortHeaderProps {
28
28
  onSort: (col: string, dir: SortDir) => void;
29
29
  /** The direction a newly chosen column starts in. Default "desc". */
30
30
  initialDir?: SortDir;
31
+ /**
32
+ * The element: a <th> in a table (default), or a <div role="columnheader">
33
+ * for a grid of divs — the sort, the button and aria-sort are the same.
34
+ */
35
+ as?: "th" | "div";
31
36
  classNames?: SortHeaderClassNames;
32
37
  }
33
38
  /** 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;
39
+ export declare function SortHeader({ label, col, activeCol, dir, onSort, initialDir, as, classNames: c, }: SortHeaderProps): import("react").JSX.Element;
35
40
  export interface PageControlsClassNames {
36
41
  root?: string;
37
42
  /** Every control. */
@@ -1,15 +1,18 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { clampPage, lastPage, nextSort } from "../table.js";
2
+ import { ariaSort, clampPage, lastPage, nextSort } from "../table.js";
3
3
  function cx(...parts) {
4
4
  const s = parts.filter(Boolean).join(" ");
5
5
  return s || undefined;
6
6
  }
7
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 });
8
+ export function SortHeader({ label, col, activeCol, dir, onSort, initialDir = "desc", as = "th", classNames: c = {}, }) {
9
+ const Cell = as;
10
+ const role = as === "div" ? "columnheader" : undefined;
11
+ if (!col) {
12
+ return (_jsx(Cell, { role: role, className: c.cell, children: label }));
13
+ }
11
14
  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: () => {
15
+ return (_jsx(Cell, { role: role, className: c.cell, "aria-sort": ariaSort(col, activeCol, dir), children: _jsxs("button", { type: "button", onClick: () => {
13
16
  const next = nextSort(col, activeCol, dir, initialDir);
14
17
  onSort(next.col, next.dir);
15
18
  }, className: cx(c.button, active && c.active), children: [label, active && (_jsx("span", { "aria-hidden": true, className: c.arrow, children: dir === "desc" ? " ▾" : " ▴" }))] }) }));
@@ -10,7 +10,9 @@
10
10
  * the query (server-side, or `filterRows` in the browser), so paging reflects
11
11
  * the filtered set. The search is applied on Enter or its chip, never on each
12
12
  * keystroke. Mechanics only: every visual choice is the site's, through
13
- * `classNames`; the actions are chips.
13
+ * `classNames`; the actions are chips. The panel is kept on screen: once open,
14
+ * it is slid sideways (`nudgeIntoView`) so no part of it hangs past either edge
15
+ * of a phone.
14
16
  */
15
17
  import { type ReactNode } from "react";
16
18
  export interface DateFieldOption {
@@ -86,8 +88,14 @@ export interface TableFilterProps<F extends object> {
86
88
  questions?: TableFilterQuestions<F>;
87
89
  /** Turn everything off. */
88
90
  onClear: () => void;
91
+ /**
92
+ * Where the Clear chip goes: beside the other controls (default), or inside
93
+ * the questions panel. "panel" needs a `questions` group; without one it is
94
+ * beside.
95
+ */
96
+ clearPlacement?: "beside" | "panel";
89
97
  searchIcon?: ReactNode;
90
98
  clearLabel?: ReactNode;
91
99
  classNames?: TableFilterClassNames;
92
100
  }
93
- export default function TableFilter<F extends object>({ search, dates, questions, onClear, searchIcon, clearLabel, classNames: c, }: TableFilterProps<F>): import("react").JSX.Element;
101
+ export default function TableFilter<F extends object>({ search, dates, questions, onClear, clearPlacement, searchIcon, clearLabel, classNames: c, }: TableFilterProps<F>): import("react").JSX.Element;
@@ -11,18 +11,24 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
11
11
  * the query (server-side, or `filterRows` in the browser), so paging reflects
12
12
  * the filtered set. The search is applied on Enter or its chip, never on each
13
13
  * keystroke. Mechanics only: every visual choice is the site's, through
14
- * `classNames`; the actions are chips.
14
+ * `classNames`; the actions are chips. The panel is kept on screen: once open,
15
+ * it is slid sideways (`nudgeIntoView`) so no part of it hangs past either edge
16
+ * of a phone.
15
17
  */
16
- import { useEffect, useRef, useState } from "react";
18
+ import { useEffect, useLayoutEffect, useRef, useState } from "react";
17
19
  import { Search, SlidersHorizontal, X } from "lucide-react";
18
- import { filterActive } from "../table.js";
20
+ import { filterActive, nudgeIntoView } from "../table.js";
21
+ /** The panel keeps this many px from each edge of the viewport. */
22
+ const EDGE = 8;
19
23
  function cx(...parts) {
20
24
  const s = parts.filter(Boolean).join(" ");
21
25
  return s || undefined;
22
26
  }
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 = {}, }) {
27
+ export default function TableFilter({ search, dates, questions, onClear, clearPlacement = "beside", searchIcon = _jsx(Search, { size: "1em", "aria-hidden": true }), clearLabel = (_jsxs(_Fragment, { children: [_jsx(X, { size: "1em", "aria-hidden": true }), " Clear"] })), classNames: c = {}, }) {
24
28
  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 }))] }));
29
+ const clear = active ? (_jsx("button", { type: "button", onClick: onClear, className: c.chip, children: clearLabel })) : null;
30
+ const inPanel = clearPlacement === "panel" && !!questions;
31
+ 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, clear: inPanel ? clear : null, c: c }), !inPanel && clear] }));
26
32
  }
27
33
  function SearchBox({ search, icon, c }) {
28
34
  // Edited freely, applied on Enter or the chip; follows the applied value
@@ -37,9 +43,31 @@ function SearchBox({ search, icon, c }) {
37
43
  function DateRange({ dates, c }) {
38
44
  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
45
  }
40
- function QuestionPanel({ q, c }) {
46
+ function QuestionPanel({ q, clear, c, }) {
41
47
  const [open, setOpen] = useState(false);
42
48
  const box = useRef(null);
49
+ const panel = useRef(null);
50
+ // Kept on screen: measured where the site's CSS puts it, then slid by the
51
+ // `translate` property (a site's own `transform` is left alone). Only a panel
52
+ // wider than the viewport gets a max-width.
53
+ useLayoutEffect(() => {
54
+ const el = panel.current;
55
+ if (!open || !el)
56
+ return;
57
+ const place = () => {
58
+ el.style.translate = "";
59
+ el.style.maxWidth = "";
60
+ const room = document.documentElement.clientWidth;
61
+ if (el.getBoundingClientRect().width > room - 2 * EDGE)
62
+ el.style.maxWidth = `${room - 2 * EDGE}px`;
63
+ const r = el.getBoundingClientRect();
64
+ const dx = nudgeIntoView(r.left, r.width, room, EDGE);
65
+ el.style.translate = dx ? `${dx}px 0` : "";
66
+ };
67
+ place();
68
+ window.addEventListener("resize", place);
69
+ return () => window.removeEventListener("resize", place);
70
+ }, [open]);
43
71
  // Dismissed by a tap outside or Escape.
44
72
  useEffect(() => {
45
73
  if (!open)
@@ -61,8 +89,8 @@ function QuestionPanel({ q, c }) {
61
89
  }, [open]);
62
90
  const on = q.summary !== "";
63
91
  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
- }) }))] }));
92
+ 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 && (_jsxs("div", { className: c.panel, ref: panel, role: "group", "aria-label": "Filter", children: [q.questions.map((question) => {
93
+ const id = `tf-${String(question.key)}`;
94
+ 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));
95
+ }), clear] }))] }));
68
96
  }
@@ -4,11 +4,23 @@
4
4
  * Every figure is the service's answer or a dash. A balance nobody has read is
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
- * in and never guesses what anything costs.
7
+ * in and never guesses what anything costs. Tapping one only chooses it; the
8
+ * invoice is made by "Create invoice".
8
9
  *
9
10
  * The compact card. `WalletPage` is the full account view; both run the same
10
11
  * top-up (`useTopUp`), which checks an open invoice on its own until it settles.
11
12
  */
12
- export default function WalletCard({ topUps }: {
13
+ export interface WalletCardClassNames {
14
+ /** Each preset amount, and the invoice's check and cancel. Default: the package's outlined chip. */
15
+ chip?: string;
16
+ /** Added to the chosen preset. Default: an accent ring. */
17
+ chipActive?: string;
18
+ /** The action that moves a top-up forward — "Create invoice", then "Pay". Default: the accent button. */
19
+ primary?: string;
20
+ }
21
+ export interface WalletCardProps {
22
+ /** Preset amounts, in sats. A tap fills the amount; it does not invoice. */
13
23
  topUps?: number[];
14
- }): import("react").JSX.Element;
24
+ classNames?: WalletCardClassNames;
25
+ }
26
+ export default function WalletCard({ topUps, classNames }: WalletCardProps): import("react").JSX.Element;
@@ -5,20 +5,23 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
5
5
  * Every figure is the service's answer or a dash. A balance nobody has read is
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
- * in and never guesses what anything costs.
8
+ * in and never guesses what anything costs. Tapping one only chooses it; the
9
+ * invoice is made by "Create invoice".
9
10
  *
10
11
  * The compact card. `WalletPage` is the full account view; both run the same
11
12
  * top-up (`useTopUp`), which checks an open invoice on its own until it settles.
12
13
  */
13
- import { useCallback, useEffect, useState } from "react";
14
+ import { useCallback, useEffect, useReducer, useState } from "react";
14
15
  import { RefreshCw, Zap } from "lucide-react";
15
16
  import { tollboothConfig } from "../config.js";
16
17
  import { checkBalance } from "../standardTools.js";
17
- import { formatSats } from "../wallet.js";
18
+ import { amountReducer, formatSats, parseSats, presetChosen } from "../wallet.js";
18
19
  import { card, ghost, iconButton, muted, primary } from "./ui.js";
19
20
  import { useTopUp } from "./useTopUp.js";
20
21
  const DEFAULT_TOP_UPS = [1_000, 5_000, 20_000];
21
- export default function WalletCard({ topUps = DEFAULT_TOP_UPS }) {
22
+ const cx = (...parts) => parts.filter(Boolean).join(" ");
23
+ export default function WalletCard({ topUps = DEFAULT_TOP_UPS, classNames = {} }) {
24
+ const c = { chip: ghost, chipActive: "ring-2 ring-[var(--tb-accent)]", primary, ...classNames };
22
25
  const { appName } = tollboothConfig();
23
26
  const [balance, setBalance] = useState(null);
24
27
  const [reachable, setReachable] = useState(true);
@@ -33,6 +36,8 @@ export default function WalletCard({ topUps = DEFAULT_TOP_UPS }) {
33
36
  }, []);
34
37
  useEffect(load, [load]);
35
38
  const { state, create, check, cancel } = useTopUp({ onSettled: load });
39
+ const [amount, pick] = useReducer(amountReducer, "");
40
+ const sats = parseSats(amount);
36
41
  const busy = state.phase === "creating" || (state.phase === "awaiting" && state.checking);
37
42
  const invoice = state.phase === "awaiting" ? state.invoice : null;
38
43
  const msg = state.phase === "settled"
@@ -44,5 +49,5 @@ export default function WalletCard({ topUps = DEFAULT_TOP_UPS }) {
44
49
  : state.phase === "idle"
45
50
  ? (state.message ?? "")
46
51
  : "";
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 })] }));
52
+ 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 ? (_jsxs("div", { className: "mt-4 space-y-2", children: [_jsx("div", { className: "flex gap-2", children: topUps.map((n) => (_jsxs("button", { type: "button", disabled: busy, "aria-pressed": presetChosen(amount, n), onClick: () => pick({ type: "preset", sats: n }), className: cx(c.chip, presetChosen(amount, n) && c.chipActive), children: ["+", n.toLocaleString("en-US")] }, n))) }), _jsx("button", { type: "button", disabled: busy || !sats, onClick: () => sats && create(sats), className: c.primary, children: "Create invoice" })] })) : (_jsxs("div", { className: "mt-4 space-y-2", children: [_jsxs("a", { href: invoice.checkoutLink ?? (invoice.bolt11 ? `lightning:${invoice.bolt11}` : "#"), target: "_blank", rel: "noreferrer", className: cx(c.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: c.chip, children: "I've paid \u2014 check" }), _jsx("button", { type: "button", onClick: cancel, className: cx(c.chip, "w-auto px-4"), children: "Cancel" })] })] })), msg && _jsx("p", { className: `mt-3 text-xs ${muted}`, children: msg })] }));
48
53
  }
@@ -6,7 +6,9 @@
6
6
  * purchase_credits → check_payment, account_statement, the coupon tools),
7
7
  * holds the states and writes the words; every visual choice belongs to the
8
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
9
+ * the page. Actions are chips (`classNames.chip`); the one that moves a top-up
10
+ * forward may keep an accent (`classNames.primary`). A preset amount only fills
11
+ * the amount — the invoice is made by "Create invoice". Anything only one site has
10
12
  * (a funding-health panel, an operator view) goes in `before` or `children`.
11
13
  *
12
14
  * A balance nobody has read is a dash, never 0: showing 0 to someone with
@@ -32,7 +34,13 @@ export interface WalletPageClassNames {
32
34
  error?: string;
33
35
  /** Every action. */
34
36
  chip?: string;
35
- /** Added to a chip that is the current choice. */
37
+ /**
38
+ * The action that moves a top-up forward — "Create invoice", then "Open
39
+ * checkout" — in place of `chip`, for a site that gives it its accent.
40
+ * Default: `chip`.
41
+ */
42
+ primary?: string;
43
+ /** Added to a chip that is the current choice (the chosen preset). */
36
44
  chipActive?: string;
37
45
  /** A row of chips. */
38
46
  chips?: string;
@@ -48,7 +56,7 @@ export interface WalletPageClassNames {
48
56
  export interface WalletPageProps {
49
57
  /** The page heading. Default "Wallet"; null for none. */
50
58
  heading?: ReactNode;
51
- /** Preset amounts, in sats, offered as chips. */
59
+ /** Preset amounts, in sats, offered as chips. A tap fills the amount; it does not invoice. */
52
60
  topUps?: number[];
53
61
  /** Offer a box for any amount. Default true. */
54
62
  customAmount?: boolean;
@@ -7,15 +7,17 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
7
7
  * purchase_credits → check_payment, account_statement, the coupon tools),
8
8
  * holds the states and writes the words; every visual choice belongs to the
9
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
10
+ * the page. Actions are chips (`classNames.chip`); the one that moves a top-up
11
+ * forward may keep an accent (`classNames.primary`). A preset amount only fills
12
+ * the amount — the invoice is made by "Create invoice". Anything only one site has
11
13
  * (a funding-health panel, an operator view) goes in `before` or `children`.
12
14
  *
13
15
  * A balance nobody has read is a dash, never 0: showing 0 to someone with
14
16
  * funds sends them to buy credits they already own.
15
17
  */
16
- import { useCallback, useEffect, useState } from "react";
18
+ import { useCallback, useEffect, useReducer, useState } from "react";
17
19
  import { checkBalance, getAccountStatement, } from "../standardTools.js";
18
- import { formatSats, parseSats } from "../wallet.js";
20
+ import { amountReducer, formatSats, parseSats, presetChosen } from "../wallet.js";
19
21
  import CouponsPanel, {} from "./CouponsPanel.js";
20
22
  import { useTopUp } from "./useTopUp.js";
21
23
  const DEFAULT_TOP_UPS = [1_000, 5_000, 25_000];
@@ -55,10 +57,15 @@ function Balance({ bal, error, refresh, formatDateTime, c, }) {
55
57
  }
56
58
  function TopUpSection({ topUps, customAmount, poll, onSettled, c, }) {
57
59
  const { state, create, check, cancel, reset } = useTopUp({ ...poll, onSettled });
58
- const [custom, setCustom] = useState("");
60
+ const [amount, pick] = useReducer(amountReducer, "");
59
61
  const [copied, setCopied] = useState(false);
60
- const customSats = parseSats(custom);
62
+ const sats = parseSats(amount);
61
63
  const creating = state.phase === "creating";
64
+ const primary = c.primary ?? c.chip;
65
+ function confirm() {
66
+ if (sats && !creating)
67
+ create(sats);
68
+ }
62
69
  async function copy(text) {
63
70
  try {
64
71
  await navigator.clipboard.writeText(text);
@@ -69,10 +76,13 @@ function TopUpSection({ topUps, customAmount, poll, onSettled, c, }) {
69
76
  /* the string is on screen and selectable */
70
77
  }
71
78
  }
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" }) })] }))] }));
79
+ 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, "aria-pressed": presetChosen(amount, n), onClick: () => pick({ type: "preset", sats: n }), className: cx(c.chip, presetChosen(amount, n) && c.chipActive), children: [n.toLocaleString("en-US"), " sats"] }, n))) }), _jsxs("div", { className: c.chips, children: [customAmount && (_jsx("input", { inputMode: "numeric", value: amount, disabled: creating, onChange: (e) => pick({ type: "typed", text: e.target.value }), onKeyDown: (e) => {
80
+ if (e.key === "Enter")
81
+ confirm();
82
+ }, placeholder: "Any amount", "aria-label": "Amount in sats", className: c.input })), _jsx("button", { type: "button", disabled: creating || !sats, onClick: confirm, className: primary, 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: primary, 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: () => {
83
+ pick({ type: "clear" });
84
+ reset();
85
+ }, 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
86
  }
77
87
  function Tranches({ tranches, formatDate, c, }) {
78
88
  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))) })] }));
@@ -10,7 +10,7 @@ export { default as QuoteScroller, type QuoteScrollerProps, type QuoteScrollerCl
10
10
  export { default as SessionKeyClaim } from "./SessionKeyClaim.tsx";
11
11
  export { default as TableFilter, type DateFieldOption, type FilterQuestion, type TableFilterProps, type TableFilterClassNames, type TableFilterDates, type TableFilterQuestions, type TableFilterSearch, } from "./TableFilter.tsx";
12
12
  export { default as ThemeToggle, type ThemeToggleProps, type ThemeToggleClassNames } from "./ThemeToggle.tsx";
13
- export { default as WalletCard } from "./WalletCard.tsx";
13
+ export { default as WalletCard, type WalletCardProps, type WalletCardClassNames } from "./WalletCard.tsx";
14
14
  export { default as WalletPage, type WalletPageProps, type WalletPageClassNames } from "./WalletPage.tsx";
15
15
  export { useSession, type Session } from "./useSession.ts";
16
16
  export { useDebugLog } from "./useDebugLog.ts";
@@ -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;
@@ -28,6 +28,7 @@ export function useSession() {
28
28
  setState(read());
29
29
  setNotice("");
30
30
  }, []);
31
+ const dismissNotice = useCallback(() => setNotice(""), []);
31
32
  useEffect(() => onProofExpired(() => {
32
33
  // A tab that can sign for THIS npub lost nothing.
33
34
  if (canSignFor(currentClaim()))
@@ -35,5 +36,5 @@ export function useSession() {
35
36
  setNotice("Your session lapsed while you were away. Sign in again to carry on.");
36
37
  setState(read());
37
38
  }), []);
38
- return { ...state, notice, refresh, signOut };
39
+ return { ...state, notice, dismissNotice, refresh, signOut };
39
40
  }
@@ -1,17 +1,53 @@
1
1
  /**
2
2
  * Typed wrappers for the tools every operator gets from
3
3
  * `register_standard_tools` in the tollbooth-dpyc wheel.
4
+ *
5
+ * Every wrapper takes the same optional `CallOptions` as `callTool`, last.
6
+ * A wrapper that is best-effort by default stays so unless the caller says
7
+ * otherwise; what the caller passes wins.
4
8
  */
9
+ import { type CallOptions } from "./client.ts";
10
+ /** What a patron must supply beyond the npub proof, per the wheel's `patron_auth_block`. */
11
+ export interface PatronAuth {
12
+ mode: "oauth" | "secure_courier" | "none";
13
+ patron_credentials_required: boolean;
14
+ /** mode "oauth": the provider's credential service. */
15
+ oauth_service?: string;
16
+ /** mode "secure_courier": the service a patron delivers secrets to. */
17
+ credential_service?: string;
18
+ }
19
+ /** The wheel's `service_status`: health and build facts, free and unauthenticated. */
5
20
  export interface ServiceStatus {
21
+ success?: boolean;
22
+ service?: string;
23
+ slug?: string;
24
+ version?: string;
25
+ tollbooth_dpyc_version?: string;
26
+ vault_configured?: boolean;
27
+ courier_has_vault?: boolean;
6
28
  operator_npub_hash?: string;
29
+ process_id?: number;
30
+ /** Deploy metadata from the environment (commit, build time, …), lower-cased keys. */
31
+ build_info?: Record<string, string> | null;
32
+ patron_auth?: PatronAuth;
33
+ /** Only on a server that runs background jobs. */
34
+ async_jobs?: {
35
+ docket_url_set: boolean;
36
+ backend: string;
37
+ durable_across_recycles: boolean;
38
+ };
39
+ durable_jobs?: {
40
+ modal_app: string | null;
41
+ detached_executor_active: boolean;
42
+ detached_executor_resolved: boolean;
43
+ detached_executor_error: string | null;
44
+ last_dispatch_error: string | null;
45
+ dispatching: boolean;
46
+ };
7
47
  lifecycle?: string;
8
48
  message?: string;
9
- version?: string;
10
- tollbooth_dpyc_version?: string;
11
- service?: string;
12
- slug?: string;
13
49
  }
14
- export declare function serviceStatus(): Promise<ServiceStatus>;
50
+ export declare function serviceStatus(opts?: CallOptions): Promise<ServiceStatus>;
15
51
  export interface NpubProofResult {
16
52
  success?: boolean;
17
53
  proven_npub?: string;
@@ -31,12 +67,12 @@ export interface NpubProofResult {
31
67
  * in the DM is the one on their own screen; an impostor firing the same tool
32
68
  * from elsewhere cannot make the patron's open tab show the attacker's code.
33
69
  */
34
- export declare function requestNpubProof(patronNpub: string, verifyAt?: string, reason?: string): Promise<NpubProofResult>;
70
+ export declare function requestNpubProof(patronNpub: string, verifyAt?: string, reason?: string, opts?: CallOptions): Promise<NpubProofResult>;
35
71
  /**
36
72
  * Step 2: drain DMs for the signed reply to step 1. Call only after the human
37
73
  * says they replied — it is destructive, so never poll it.
38
74
  */
39
- export declare function receiveNpubProof(patronNpub: string, dpopToken: string): Promise<NpubProofResult>;
75
+ export declare function receiveNpubProof(patronNpub: string, dpopToken: string, opts?: CallOptions): Promise<NpubProofResult>;
40
76
  export interface ProofStatusResult {
41
77
  success?: boolean;
42
78
  status?: "valid" | "expired" | "unknown" | string;
@@ -44,7 +80,7 @@ export interface ProofStatusResult {
44
80
  error?: string;
45
81
  error_code?: string;
46
82
  }
47
- export declare function checkProofStatus(patronNpub: string, dpopToken: string): Promise<ProofStatusResult>;
83
+ export declare function checkProofStatus(patronNpub: string, dpopToken: string, opts?: CallOptions): Promise<ProofStatusResult>;
48
84
  /** One credit tranche: a purchase (or grant) and what is left of it. */
49
85
  export interface CreditTranche {
50
86
  id: string;
@@ -75,24 +111,49 @@ export interface CheckBalanceResult {
75
111
  calls: number;
76
112
  api_sats: number;
77
113
  }>;
114
+ invoice_summary?: {
115
+ total_invoices: number;
116
+ settled_count: number;
117
+ pending_count: number;
118
+ total_real_sats: number;
119
+ total_api_sats_credited: number;
120
+ };
78
121
  error?: string;
79
122
  error_code?: string;
80
123
  }
81
- export declare function checkBalance(): Promise<CheckBalanceResult>;
124
+ export declare function checkBalance(opts?: CallOptions): Promise<CheckBalanceResult>;
125
+ export interface ConstraintEffect {
126
+ type: string;
127
+ message?: string;
128
+ [key: string]: unknown;
129
+ }
130
+ /** The wheel's `check_price` preview. */
82
131
  export interface CheckPriceResult {
83
132
  success: boolean;
84
133
  tool_id?: string;
85
- base_cost_api_sats?: number;
86
- effective_cost_api_sats?: number;
134
+ tool_name?: string;
135
+ /** "flat" | "flat+multipliers" | "percent" */
136
+ pricing_type?: string;
137
+ base_cost_api_sats?: number | null;
138
+ effective_cost_api_sats?: number | null;
139
+ rate_percent?: number;
140
+ rate_param?: string;
141
+ min_cost_sats?: number;
142
+ multipliers?: Record<string, unknown>;
143
+ constraints_enabled?: boolean;
144
+ constraint_effects?: ConstraintEffect[];
145
+ hint?: string;
87
146
  error?: string;
88
147
  error_code?: string;
89
148
  }
90
149
  /**
91
150
  * The fare for one tool in api_sats, as the live pricing model resolves it,
92
- * constraints included — keyed by the tool's frozen UUID. Null when unreadable:
93
- * show the answer without a price rather than invent one.
151
+ * constraints included — keyed by the tool's frozen UUID (or its capability
152
+ * name). `toolKwargs` are the call's own parameters, for a tool priced by
153
+ * them (ad valorem, or a multiplier on a categorical argument). Null when
154
+ * unreadable: show the answer without a price rather than invent one.
94
155
  */
95
- export declare function checkPrice(toolId: string): Promise<number | null>;
156
+ export declare function checkPrice(toolId: string, toolKwargs?: Record<string, unknown>, opts?: CallOptions): Promise<number | null>;
96
157
  export interface PurchaseCreditsResult {
97
158
  success?: boolean;
98
159
  invoice_id?: string;
@@ -104,7 +165,7 @@ export interface PurchaseCreditsResult {
104
165
  error?: string;
105
166
  error_code?: string;
106
167
  }
107
- export declare function purchaseCredits(sats: number): Promise<PurchaseCreditsResult>;
168
+ export declare function purchaseCredits(sats: number, opts?: CallOptions): Promise<PurchaseCreditsResult>;
108
169
  export interface CheckPaymentResult {
109
170
  success?: boolean;
110
171
  status?: "New" | "Processing" | "Settled" | "Expired" | "Invalid" | string;
@@ -112,10 +173,14 @@ export interface CheckPaymentResult {
112
173
  invoice_id?: string;
113
174
  credits_granted?: number;
114
175
  balance_api_sats?: number;
176
+ amount_sats?: number;
177
+ /** false: the credit could not be written; nothing was granted. */
178
+ persisted?: boolean;
179
+ source?: string;
115
180
  error?: string;
116
181
  error_code?: string;
117
182
  }
118
- export declare function checkPayment(invoiceId: string): Promise<CheckPaymentResult>;
183
+ export declare function checkPayment(invoiceId: string, opts?: CallOptions): Promise<CheckPaymentResult>;
119
184
  export interface StatementInvoice {
120
185
  invoice_id: string;
121
186
  status: string;
@@ -164,7 +229,7 @@ export interface AccountStatementResult {
164
229
  error?: string;
165
230
  error_code?: string;
166
231
  }
167
- export declare function getAccountStatement(days?: number): Promise<AccountStatementResult>;
232
+ export declare function getAccountStatement(days?: number, opts?: CallOptions): Promise<AccountStatementResult>;
168
233
  export interface PatronCoupon {
169
234
  coupon_id: string;
170
235
  name: string;
@@ -204,11 +269,11 @@ export interface ForgetCouponResult {
204
269
  error_code?: string;
205
270
  }
206
271
  /** The coupons this patron has redeemed here. Free. */
207
- export declare function listMyCoupons(): Promise<ListMyCouponsResult>;
272
+ export declare function listMyCoupons(opts?: CallOptions): Promise<ListMyCouponsResult>;
208
273
  /** Redeem an operator's code once; the discount then applies on its own. Free. */
209
- export declare function redeemCoupon(code: string): Promise<RedeemCouponResult>;
274
+ export declare function redeemCoupon(code: string, opts?: CallOptions): Promise<RedeemCouponResult>;
210
275
  /** Take a coupon off the patron's list. The code stays re-redeemable while its window allows. */
211
- export declare function forgetCoupon(couponId: string): Promise<ForgetCouponResult>;
276
+ export declare function forgetCoupon(couponId: string, opts?: CallOptions): Promise<ForgetCouponResult>;
212
277
  export interface Kind0 {
213
278
  name?: string;
214
279
  display_name?: string;
@@ -225,7 +290,7 @@ export interface GetNostrProfileResult {
225
290
  profile?: Kind0;
226
291
  error?: string;
227
292
  }
228
- export declare function getNostrProfile(npub: string): Promise<GetNostrProfileResult>;
293
+ export declare function getNostrProfile(npub: string, opts?: CallOptions): Promise<GetNostrProfileResult>;
229
294
  export interface PublishNostrProfileResult {
230
295
  success: boolean;
231
296
  ok?: number;
@@ -234,4 +299,150 @@ export interface PublishNostrProfileResult {
234
299
  error?: string;
235
300
  }
236
301
  /** Relay a kind-0 the BROWSER signed. The wheel verifies it and fans it out. */
237
- export declare function publishNostrProfile(npub: string, signedEvent: string): Promise<PublishNostrProfileResult>;
302
+ export declare function publishNostrProfile(npub: string, signedEvent: string, opts?: CallOptions): Promise<PublishNostrProfileResult>;
303
+ /** Every lifecycle `session_status` reports. */
304
+ export type SessionLifecycle = "ready" | "warming_up" | "misconfigured" | "quota_exceeded" | "not_registered" | "no_identity";
305
+ /** The patron's stored upstream OAuth token, when the operator has a provider. */
306
+ export interface UpstreamOAuth {
307
+ service: string;
308
+ has_access_token: boolean;
309
+ has_refresh_token: boolean;
310
+ refresh_enabled: boolean;
311
+ access_token_expires_at?: number;
312
+ access_token_expires_in_seconds?: number;
313
+ }
314
+ export interface SessionStatusResult {
315
+ success?: boolean;
316
+ lifecycle?: SessionLifecycle;
317
+ message?: string;
318
+ operator_npub?: string;
319
+ /** Why it is not ready, from the failing layer. */
320
+ detail?: string;
321
+ operator_credential_service?: string;
322
+ patron_credential_service?: string;
323
+ upstream_oauth?: UpstreamOAuth;
324
+ /** The vault could not answer — not the same as "never authorized". */
325
+ upstream_oauth_unreadable?: string;
326
+ error?: string;
327
+ error_code?: string;
328
+ }
329
+ /**
330
+ * Is the operator ready to serve? Free, no proof. With `patronNpub`, also the
331
+ * patron's upstream OAuth token expiry, so a page can refresh ahead of time.
332
+ */
333
+ export declare function sessionStatus(patronNpub?: string, opts?: CallOptions): Promise<SessionStatusResult>;
334
+ export interface OnboardingItem {
335
+ field: string;
336
+ /** "identity" | "authority" | "secret" */
337
+ category: string;
338
+ /** "configured" | "missing" | "unknown" */
339
+ status: string;
340
+ how?: string;
341
+ lifecycle?: string;
342
+ delivered_at?: string | null;
343
+ }
344
+ /** The wheel's `get_operator_onboarding_status`: what the operator still has to set up. */
345
+ export interface OperatorOnboardingStatus {
346
+ ready?: boolean;
347
+ configured?: OnboardingItem[];
348
+ missing?: OnboardingItem[];
349
+ optional_missing?: OnboardingItem[];
350
+ summary?: string;
351
+ bootstrap_error?: string;
352
+ vault_ok?: boolean;
353
+ /** Set when the credential vault could not be read: nothing is known either way. */
354
+ vault_situation?: string;
355
+ credential_greeting?: string | null;
356
+ credential_service?: string | null;
357
+ operator_name?: string;
358
+ error?: string;
359
+ error_code?: string;
360
+ }
361
+ export declare function getOperatorOnboardingStatus(opts?: CallOptions): Promise<OperatorOnboardingStatus>;
362
+ /**
363
+ * The operator's own balance at its Authority — what certifies patron top-ups.
364
+ * The Authority's ledger, in the same shape as a patron's `check_balance`.
365
+ */
366
+ export declare function checkAuthorityBalance(opts?: CallOptions): Promise<CheckBalanceResult>;
367
+ export interface PricingStep {
368
+ id: string;
369
+ type: string;
370
+ params?: Record<string, unknown>;
371
+ patron_npubs?: string[];
372
+ }
373
+ export interface ToolPrice {
374
+ tool_id: string;
375
+ tool_name: string;
376
+ price_sats: number;
377
+ category: string;
378
+ intent: string;
379
+ priced: boolean;
380
+ price_type?: string;
381
+ price_formula?: unknown;
382
+ min_cost?: number;
383
+ max_cost?: number;
384
+ multipliers?: Record<string, unknown>;
385
+ chain?: PricingStep[];
386
+ }
387
+ /** The wheel's `get_pricing_model`. `status` is "ok" or "error". */
388
+ export interface PricingModelResult {
389
+ status?: string;
390
+ model_id?: string | null;
391
+ name?: string | null;
392
+ is_active?: boolean | null;
393
+ tools?: ToolPrice[] | null;
394
+ tranche_lifetime?: {
395
+ target_usage_pct: number;
396
+ ttl_days?: number;
397
+ min_days?: number;
398
+ max_days?: number;
399
+ };
400
+ error?: string;
401
+ }
402
+ /** The operator's active pricing model. Free, no proof. */
403
+ export declare function getPricingModel(opts?: CallOptions): Promise<PricingModelResult>;
404
+ export interface CanonicalIdentity {
405
+ tool_id: string;
406
+ mcp_name: string;
407
+ category: string;
408
+ intent: string;
409
+ capability: string;
410
+ registered: boolean;
411
+ reason?: string;
412
+ }
413
+ /** The wheel's `list_canonical_identities`: every tool's frozen UUID and wire name. */
414
+ export interface CanonicalIdentitiesResult {
415
+ success?: boolean;
416
+ operator_npub?: string;
417
+ count?: number;
418
+ unregistered_count?: number;
419
+ tools?: CanonicalIdentity[];
420
+ unregistered?: Array<{
421
+ mcp_name: string;
422
+ reason: string;
423
+ [key: string]: unknown;
424
+ }>;
425
+ error?: string;
426
+ }
427
+ export declare function listCanonicalIdentities(opts?: CallOptions): Promise<CanonicalIdentitiesResult>;
428
+ export interface PatronCredentialFieldsResult {
429
+ success?: boolean;
430
+ fields?: string[];
431
+ /** Per field, when it was delivered; null for secrets vaulted before stamps. */
432
+ delivered_at?: Record<string, string | null>;
433
+ count?: number;
434
+ error?: string;
435
+ error_code?: string;
436
+ }
437
+ export interface PatronCredentialWriteResult {
438
+ success?: boolean;
439
+ message?: string;
440
+ error?: string;
441
+ error_code?: string;
442
+ }
443
+ /** The names of the patron's stored credential fields — never their values. */
444
+ export declare function getPatronCredentialFields(opts?: CallOptions): Promise<PatronCredentialFieldsResult>;
445
+ /** Set one credential field, leaving the others as they are. */
446
+ export declare function updatePatronCredential(field: string, value: string, opts?: CallOptions): Promise<PatronCredentialWriteResult>;
447
+ /** Remove one credential field, leaving the others as they are. */
448
+ export declare function deletePatronCredential(field: string, opts?: CallOptions): Promise<PatronCredentialWriteResult>;
@@ -1,10 +1,14 @@
1
1
  /**
2
2
  * Typed wrappers for the tools every operator gets from
3
3
  * `register_standard_tools` in the tollbooth-dpyc wheel.
4
+ *
5
+ * Every wrapper takes the same optional `CallOptions` as `callTool`, last.
6
+ * A wrapper that is best-effort by default stays so unless the caller says
7
+ * otherwise; what the caller passes wins.
4
8
  */
5
9
  import { callTool } from "./client.js";
6
- export function serviceStatus() {
7
- return callTool("service_status", {}, { bestEffort: true });
10
+ export function serviceStatus(opts) {
11
+ return callTool("service_status", {}, { bestEffort: true, ...opts });
8
12
  }
9
13
  /**
10
14
  * Step 1 of DM sign-in: a Secure Courier challenge DM to the npub. Free.
@@ -14,67 +18,100 @@ export function serviceStatus() {
14
18
  * in the DM is the one on their own screen; an impostor firing the same tool
15
19
  * from elsewhere cannot make the patron's open tab show the attacker's code.
16
20
  */
17
- export function requestNpubProof(patronNpub, verifyAt, reason) {
21
+ export function requestNpubProof(patronNpub, verifyAt, reason, opts) {
18
22
  return callTool("request_npub_proof", {
19
23
  patron_npub: patronNpub,
20
24
  ...(verifyAt ? { verify_at: verifyAt } : {}),
21
25
  ...(reason ? { reason } : {}),
22
- });
26
+ }, opts);
23
27
  }
24
28
  /**
25
29
  * Step 2: drain DMs for the signed reply to step 1. Call only after the human
26
30
  * says they replied — it is destructive, so never poll it.
27
31
  */
28
- export function receiveNpubProof(patronNpub, dpopToken) {
29
- return callTool("receive_npub_proof", {
30
- patron_npub: patronNpub,
31
- dpop_token: dpopToken,
32
- });
32
+ export function receiveNpubProof(patronNpub, dpopToken, opts) {
33
+ return callTool("receive_npub_proof", { patron_npub: patronNpub, dpop_token: dpopToken }, opts);
33
34
  }
34
- export function checkProofStatus(patronNpub, dpopToken) {
35
- return callTool("check_proof_status", { patron_npub: patronNpub, dpop_token: dpopToken }, { bestEffort: true });
35
+ export function checkProofStatus(patronNpub, dpopToken, opts) {
36
+ return callTool("check_proof_status", { patron_npub: patronNpub, dpop_token: dpopToken }, { bestEffort: true, ...opts });
36
37
  }
37
- export function checkBalance() {
38
- return callTool("check_balance", {});
38
+ export function checkBalance(opts) {
39
+ return callTool("check_balance", {}, opts);
39
40
  }
40
41
  /**
41
42
  * The fare for one tool in api_sats, as the live pricing model resolves it,
42
- * constraints included — keyed by the tool's frozen UUID. Null when unreadable:
43
- * show the answer without a price rather than invent one.
43
+ * constraints included — keyed by the tool's frozen UUID (or its capability
44
+ * name). `toolKwargs` are the call's own parameters, for a tool priced by
45
+ * them (ad valorem, or a multiplier on a categorical argument). Null when
46
+ * unreadable: show the answer without a price rather than invent one.
44
47
  */
45
- export async function checkPrice(toolId) {
46
- const r = await callTool("check_price", { tool_id: toolId });
48
+ export async function checkPrice(toolId, toolKwargs, opts) {
49
+ const r = await callTool("check_price", { tool_id: toolId, ...(toolKwargs ? { tool_kwargs: JSON.stringify(toolKwargs) } : {}) }, opts);
47
50
  const v = r.effective_cost_api_sats ?? r.base_cost_api_sats;
48
51
  return typeof v === "number" ? v : null;
49
52
  }
50
- export function purchaseCredits(sats) {
51
- return callTool("purchase_credits", { amount_sats: sats });
53
+ export function purchaseCredits(sats, opts) {
54
+ return callTool("purchase_credits", { amount_sats: sats }, opts);
52
55
  }
53
- export function checkPayment(invoiceId) {
54
- return callTool("check_payment", { invoice_id: invoiceId });
56
+ export function checkPayment(invoiceId, opts) {
57
+ return callTool("check_payment", { invoice_id: invoiceId }, opts);
55
58
  }
56
- export function getAccountStatement(days = 30) {
57
- return callTool("account_statement", { days });
59
+ export function getAccountStatement(days = 30, opts) {
60
+ return callTool("account_statement", { days }, opts);
58
61
  }
59
62
  /** The coupons this patron has redeemed here. Free. */
60
- export function listMyCoupons() {
61
- return callTool("list_my_coupons", {});
63
+ export function listMyCoupons(opts) {
64
+ return callTool("list_my_coupons", {}, opts);
62
65
  }
63
66
  /** Redeem an operator's code once; the discount then applies on its own. Free. */
64
- export function redeemCoupon(code) {
65
- return callTool("redeem_coupon", { code });
67
+ export function redeemCoupon(code, opts) {
68
+ return callTool("redeem_coupon", { code }, opts);
66
69
  }
67
70
  /** Take a coupon off the patron's list. The code stays re-redeemable while its window allows. */
68
- export function forgetCoupon(couponId) {
69
- return callTool("forget_coupon", { coupon_id: couponId });
71
+ export function forgetCoupon(couponId, opts) {
72
+ return callTool("forget_coupon", { coupon_id: couponId }, opts);
70
73
  }
71
- export function getNostrProfile(npub) {
72
- return callTool("get_nostr_profile", { npub });
74
+ export function getNostrProfile(npub, opts) {
75
+ return callTool("get_nostr_profile", { npub }, opts);
73
76
  }
74
77
  /** Relay a kind-0 the BROWSER signed. The wheel verifies it and fans it out. */
75
- export function publishNostrProfile(npub, signedEvent) {
76
- return callTool("publish_nostr_profile", {
77
- npub,
78
- signed_event: signedEvent,
79
- });
78
+ export function publishNostrProfile(npub, signedEvent, opts) {
79
+ return callTool("publish_nostr_profile", { npub, signed_event: signedEvent }, opts);
80
+ }
81
+ /**
82
+ * Is the operator ready to serve? Free, no proof. With `patronNpub`, also the
83
+ * patron's upstream OAuth token expiry, so a page can refresh ahead of time.
84
+ */
85
+ export function sessionStatus(patronNpub, opts) {
86
+ return callTool("session_status", patronNpub ? { patron_npub: patronNpub } : {}, { bestEffort: true, ...opts });
87
+ }
88
+ export function getOperatorOnboardingStatus(opts) {
89
+ return callTool("get_operator_onboarding_status", {}, { bestEffort: true, ...opts });
90
+ }
91
+ /**
92
+ * The operator's own balance at its Authority — what certifies patron top-ups.
93
+ * The Authority's ledger, in the same shape as a patron's `check_balance`.
94
+ */
95
+ export function checkAuthorityBalance(opts) {
96
+ return callTool("check_authority_balance", {}, { bestEffort: true, ...opts });
97
+ }
98
+ /** The operator's active pricing model. Free, no proof. */
99
+ export function getPricingModel(opts) {
100
+ return callTool("get_pricing_model", {}, opts);
101
+ }
102
+ export function listCanonicalIdentities(opts) {
103
+ return callTool("list_canonical_identities", {}, opts);
104
+ }
105
+ /** The names of the patron's stored credential fields — never their values. */
106
+ export function getPatronCredentialFields(opts) {
107
+ return callTool("get_patron_credential_fields", {}, opts);
108
+ }
109
+ /** Set one credential field, leaving the others as they are. */
110
+ export function updatePatronCredential(field, value, opts) {
111
+ // `field` first: the log's scrubber keys on it to hide `value`.
112
+ return callTool("update_patron_credential", { field, value }, opts);
113
+ }
114
+ /** Remove one credential field, leaving the others as they are. */
115
+ export function deletePatronCredential(field, opts) {
116
+ return callTool("delete_patron_credential", { field }, opts);
80
117
  }
package/dist/table.d.ts CHANGED
@@ -21,6 +21,16 @@ export declare function nextSort(col: string, activeCol: string, dir: SortDir, i
21
21
  col: string;
22
22
  dir: SortDir;
23
23
  };
24
+ /** The `aria-sort` of a column header: set on the sorted column only. */
25
+ export declare function ariaSort(col: string, activeCol: string, dir: SortDir): "ascending" | "descending" | undefined;
26
+ /**
27
+ * How far to slide a popover sideways so it stays inside the viewport: the
28
+ * horizontal offset (px, negative is left) to add to where it would sit.
29
+ * `left` and `width` are its box as laid out before any slide. It keeps
30
+ * `margin` px from each edge; one wider than the room left is pinned to the
31
+ * left margin (give it a max-width to fit). Zero when it already fits.
32
+ */
33
+ export declare function nudgeIntoView(left: number, width: number, viewportWidth: number, margin?: number): number;
24
34
  /**
25
35
  * A case-insensitive test for `pattern`. It is read as a regular expression
26
36
  * when it is one, and as plain text when it is not, so a stray "(" in a search
package/dist/table.js CHANGED
@@ -35,6 +35,29 @@ export function nextSort(col, activeCol, dir, initial = "desc") {
35
35
  return { col, dir: dir === "desc" ? "asc" : "desc" };
36
36
  return { col, dir: initial };
37
37
  }
38
+ /** The `aria-sort` of a column header: set on the sorted column only. */
39
+ export function ariaSort(col, activeCol, dir) {
40
+ if (col !== activeCol)
41
+ return undefined;
42
+ return dir === "desc" ? "descending" : "ascending";
43
+ }
44
+ // ── A popover kept on screen ───────────────────────────────────────────────
45
+ /**
46
+ * How far to slide a popover sideways so it stays inside the viewport: the
47
+ * horizontal offset (px, negative is left) to add to where it would sit.
48
+ * `left` and `width` are its box as laid out before any slide. It keeps
49
+ * `margin` px from each edge; one wider than the room left is pinned to the
50
+ * left margin (give it a max-width to fit). Zero when it already fits.
51
+ */
52
+ export function nudgeIntoView(left, width, viewportWidth, margin = 8) {
53
+ const minLeft = margin;
54
+ const maxLeft = viewportWidth - margin - width;
55
+ if (maxLeft < minLeft || left < minLeft)
56
+ return minLeft - left;
57
+ if (left > maxLeft)
58
+ return maxLeft - left;
59
+ return 0;
60
+ }
38
61
  // ── Filtering ──────────────────────────────────────────────────────────────
39
62
  /**
40
63
  * A case-insensitive test for `pattern`. It is read as a regular expression
package/dist/wallet.d.ts CHANGED
@@ -66,6 +66,24 @@ export declare function paymentPhase(status: string | undefined | null): Payment
66
66
  export declare function invoiceFrom(result: PurchaseCreditsResult, asked: number): Invoice | null;
67
67
  /** A whole number of sats worth invoicing, from what the patron typed; else null. */
68
68
  export declare function parseSats(input: string | number): number | null;
69
+ /**
70
+ * The amount box. Tapping a preset only fills it; an invoice is made only by
71
+ * the explicit "Create invoice" action, which reads `parseSats(text)`. A tap is
72
+ * a choice, not a purchase — a patron who taps the wrong chip changes it
73
+ * before anything is asked of the service.
74
+ */
75
+ export type AmountEvent = {
76
+ type: "preset";
77
+ sats: number;
78
+ } | {
79
+ type: "typed";
80
+ text: string;
81
+ } | {
82
+ type: "clear";
83
+ };
84
+ export declare function amountReducer(text: string, event: AmountEvent): string;
85
+ /** Whether preset `sats` is what the amount box holds — the chip to mark as chosen. */
86
+ export declare function presetChosen(text: string, sats: number): boolean;
69
87
  export declare function topUpReducer(state: TopUpState, event: TopUpEvent): TopUpState;
70
88
  /** Whether a poll should run now: an invoice is out, nothing is in flight, and the budget is not spent. */
71
89
  export declare function shouldPoll(state: TopUpState, maxChecks: number): boolean;
package/dist/wallet.js CHANGED
@@ -38,6 +38,20 @@ export function parseSats(input) {
38
38
  const n = typeof input === "number" ? input : Number(String(input).replace(/[\s,_]/g, ""));
39
39
  return Number.isSafeInteger(n) && n > 0 ? n : null;
40
40
  }
41
+ export function amountReducer(text, event) {
42
+ switch (event.type) {
43
+ case "preset":
44
+ return parseSats(event.sats) === null ? text : String(event.sats);
45
+ case "typed":
46
+ return event.text.replace(/[^\d]/g, "");
47
+ case "clear":
48
+ return "";
49
+ }
50
+ }
51
+ /** Whether preset `sats` is what the amount box holds — the chip to mark as chosen. */
52
+ export function presetChosen(text, sats) {
53
+ return parseSats(text) === sats;
54
+ }
41
55
  export function topUpReducer(state, event) {
42
56
  switch (event.type) {
43
57
  case "create":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tollbooth-dpyc/web",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "description": "Shared browser SDK for Tollbooth DPYC operator front ends: npub sign-in, proofs, MCP client, profile and wallet",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Lonnie VanZandt",