@tollbooth-dpyc/web 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,120 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * Funding and credential health, as a list of rows that are read fresh and
4
+ * stamped with when: `PatronFundingStatus` (sign-in proof, credit balance and
5
+ * tranche expiry) and `OperatorFundingStatus` (credentials, Authority balance,
6
+ * database, background jobs).
7
+ *
8
+ * `OperatorFundingStatus` renders nothing — and asks nothing operator-only —
9
+ * unless the signed-in npub is the operator npub `session_status` reports, the
10
+ * rule the fleet's copies used. That decides only what the page shows; the
11
+ * server enforces its own ACL.
12
+ *
13
+ * Mechanics only: the calls, the rules (`funding.ts`) and the states. Every
14
+ * visual choice is the site's, through `classNames`, with `ok` / `warning` /
15
+ * `blocked` added to a row, its state word and the heading's summary mark.
16
+ * `siteRows` adds the site's own checks (an upstream OAuth connection, say);
17
+ * `renderRow` draws a row yourself. Refresh is a chip.
18
+ */
19
+ import { useCallback, useEffect, useRef, useState } from "react";
20
+ import { composeAuthorityRow, composeCreditsRow, composeDurableJobsRow, composeOnboardingRow, composePersistenceRow, composeProofRow, isOperator, worstState, } from "../funding.js";
21
+ import { currentClaim } from "../identity.js";
22
+ import { canSignFor } from "../signedIn.js";
23
+ import { checkAuthorityBalance, checkBalance, checkProofStatus, getOperatorOnboardingStatus, serviceStatus, sessionStatus, } from "../standardTools.js";
24
+ import { formatDateTime } from "../timezone.js";
25
+ import { cx } from "./cx.js";
26
+ import { useTimezone } from "./useTimezone.js";
27
+ const STATE_LABELS = { ok: "ok", warning: "warning", blocked: "blocked" };
28
+ /**
29
+ * Rows from `read`, read on mount and on Refresh; null rows means "show
30
+ * nothing". The latest `read` is used, but a new one (a site passing
31
+ * `thresholds` or `siteRows` inline) does not trigger a re-read.
32
+ */
33
+ function useRows(read) {
34
+ const latest = useRef(read);
35
+ latest.current = read;
36
+ const [rows, setRows] = useState(null);
37
+ const [loading, setLoading] = useState(true);
38
+ const [error, setError] = useState(null);
39
+ const refresh = useCallback(async () => {
40
+ setLoading(true);
41
+ setError(null);
42
+ try {
43
+ setRows(await latest.current());
44
+ }
45
+ catch (e) {
46
+ setError(e.message);
47
+ }
48
+ finally {
49
+ setLoading(false);
50
+ }
51
+ }, []);
52
+ useEffect(() => {
53
+ void refresh();
54
+ }, [refresh]);
55
+ return { rows, loading, error, refresh };
56
+ }
57
+ function StatusList({ heading, intro, stateLabels = {}, renderRow, refreshLabel = "Refresh", classNames: c = {}, rows, loading, error, refresh, }) {
58
+ const [, zone] = useTimezone();
59
+ const overall = worstState(rows);
60
+ return (_jsxs("section", { className: c.root, children: [_jsxs("div", { className: c.header, children: [heading && _jsx("div", { className: c.heading, children: heading }), rows.length > 0 && !loading && (_jsx("span", { className: cx(c.overall, c[overall]), children: stateLabels[overall] ?? STATE_LABELS[overall] })), refreshLabel !== null && (_jsx("button", { type: "button", onClick: refresh, disabled: loading, className: c.chip, children: loading ? "Checking…" : refreshLabel }))] }), intro && _jsx("p", { className: c.intro, children: intro }), error && _jsx("p", { className: c.error, children: error }), loading && rows.length === 0 && _jsx("p", { className: c.loading, children: "Checking\u2026" }), rows.length > 0 && (_jsx("ul", { className: c.list, children: rows.map((r) => {
61
+ const checked = formatDateTime(r.checked_at, zone);
62
+ return (_jsx("li", { className: cx(c.row, c[r.state]), children: renderRow ? (renderRow(r, checked)) : (_jsxs(_Fragment, { children: [_jsx("span", { className: c.dependency, children: r.dependency }), " ", _jsx("span", { className: cx(c.state, c[r.state]), children: stateLabels[r.state] ?? STATE_LABELS[r.state] }), _jsx("p", { className: c.detail, children: r.detail }), _jsxs("p", { className: c.checked, children: ["checked ", checked] })] })) }, r.id));
63
+ }) }))] }));
64
+ }
65
+ function failedBalance(e) {
66
+ return { success: false, error: e.message };
67
+ }
68
+ async function proofKind() {
69
+ const claim = currentClaim();
70
+ if (canSignFor(claim))
71
+ return { kind: "session_nsec" };
72
+ if (!claim.proof)
73
+ return { kind: "none" };
74
+ const s = await checkProofStatus(claim.npub, claim.proof).catch(() => null);
75
+ const status = s?.status === "valid" || s?.status === "expired" ? s.status : "unknown";
76
+ return { kind: "dm_proof", status, expiresInSec: s?.expires_in_seconds ?? null };
77
+ }
78
+ /** The patron's own health: sign-in proof and credit balance. */
79
+ export function PatronFundingStatus({ thresholds, siteRows, heading = "Account health", ...rest }) {
80
+ const read = async () => {
81
+ const checkedAt = new Date().toISOString();
82
+ const [site, proof, bal] = await Promise.all([
83
+ siteRows ? siteRows(checkedAt) : Promise.resolve([]),
84
+ proofKind(),
85
+ checkBalance().catch(failedBalance),
86
+ ]);
87
+ return [...site, composeProofRow(proof, checkedAt), composeCreditsRow(bal, checkedAt, thresholds)];
88
+ };
89
+ const state = useRows(read);
90
+ return _jsx(StatusList, { heading: heading, ...rest, ...state, rows: state.rows ?? [], refresh: () => void state.refresh() });
91
+ }
92
+ /** What only the operator can fix. Renders nothing for anyone else. */
93
+ export function OperatorFundingStatus({ authorityThresholds, siteRows, heading = "Operator dependencies", ...rest }) {
94
+ const read = async () => {
95
+ const checkedAt = new Date().toISOString();
96
+ // Gate first: a patron fires none of the operator's probes.
97
+ const life = await sessionStatus();
98
+ if (!isOperator(currentClaim().npub, life))
99
+ return null;
100
+ const [site, svc, onb, auth] = await Promise.all([
101
+ siteRows ? siteRows(checkedAt) : Promise.resolve([]),
102
+ serviceStatus().catch(() => ({})),
103
+ getOperatorOnboardingStatus().catch((e) => ({ error: e.message })),
104
+ checkAuthorityBalance().catch(failedBalance),
105
+ ]);
106
+ const jobs = composeDurableJobsRow(svc, checkedAt);
107
+ return [
108
+ ...site,
109
+ composeOnboardingRow(onb, checkedAt),
110
+ composeAuthorityRow(auth, checkedAt, authorityThresholds),
111
+ composePersistenceRow(life, svc, onb, checkedAt),
112
+ ...(jobs ? [jobs] : []),
113
+ ];
114
+ };
115
+ const state = useRows(read);
116
+ // Nothing until the viewer is known to be the operator — no flash for patrons.
117
+ if (state.rows === null)
118
+ return null;
119
+ return _jsx(StatusList, { heading: heading, ...rest, ...state, rows: state.rows, refresh: () => void state.refresh() });
120
+ }
@@ -1,9 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { ariaSort, clampPage, lastPage, nextSort } from "../table.js";
3
- function cx(...parts) {
4
- const s = parts.filter(Boolean).join(" ");
5
- return s || undefined;
6
- }
3
+ import { cx } from "./cx.js";
7
4
  /** A header cell. Tapping the sorted column flips it; another column starts at `initialDir`. */
8
5
  export function SortHeader({ label, col, activeCol, dir, onSort, initialDir = "desc", as = "th", classNames: c = {}, }) {
9
6
  const Cell = as;
@@ -10,9 +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. 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.
13
+ * `classNames`; the actions are chips. The panel is kept on screen: while open,
14
+ * it is slid sideways (`reclampPanel`) so no part of it hangs past either edge
15
+ * of a phone, however its mark grows or the window changes.
16
16
  */
17
17
  import { type ReactNode } from "react";
18
18
  export interface DateFieldOption {
@@ -11,19 +11,17 @@ 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. 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.
14
+ * `classNames`; the actions are chips. The panel is kept on screen: while open,
15
+ * it is slid sideways (`reclampPanel`) so no part of it hangs past either edge
16
+ * of a phone, however its mark grows or the window changes.
17
17
  */
18
18
  import { useEffect, useLayoutEffect, useRef, useState } from "react";
19
19
  import { Search, SlidersHorizontal, X } from "lucide-react";
20
- import { filterActive, nudgeIntoView } from "../table.js";
20
+ import { filterActive } from "../table.js";
21
+ import { oncePerFrame, reclampPanel } from "./panelPlacement.js";
22
+ import { cx } from "./cx.js";
21
23
  /** The panel keeps this many px from each edge of the viewport. */
22
24
  const EDGE = 8;
23
- function cx(...parts) {
24
- const s = parts.filter(Boolean).join(" ");
25
- return s || undefined;
26
- }
27
25
  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 = {}, }) {
28
26
  const active = filterActive({ search: search?.value, dateFrom: dates?.from, dateTo: dates?.to }, questions?.summary ?? "");
29
27
  const clear = active ? (_jsx("button", { type: "button", onClick: onClear, className: c.chip, children: clearLabel })) : null;
@@ -47,26 +45,46 @@ function QuestionPanel({ q, clear, c, }) {
47
45
  const [open, setOpen] = useState(false);
48
46
  const box = useRef(null);
49
47
  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.
48
+ const mark = useRef(null);
49
+ // Kept on screen, and kept there while open: the mark widens as questions
50
+ // are answered, the Clear chip joins the panel, the window turns or scrolls —
51
+ // each moves the panel, so each re-places it (at most once a frame). Slid by
52
+ // the `translate` property (a site's own `transform` is left alone); only a
53
+ // panel wider than the viewport gets a max-width.
53
54
  useLayoutEffect(() => {
54
55
  const el = panel.current;
55
56
  if (!open || !el)
56
57
  return;
58
+ let dx = 0;
57
59
  const place = () => {
58
- el.style.translate = "";
59
- el.style.maxWidth = "";
60
60
  const room = document.documentElement.clientWidth;
61
+ el.style.maxWidth = "";
61
62
  if (el.getBoundingClientRect().width > room - 2 * EDGE)
62
63
  el.style.maxWidth = `${room - 2 * EDGE}px`;
63
64
  const r = el.getBoundingClientRect();
64
- const dx = nudgeIntoView(r.left, r.width, room, EDGE);
65
- el.style.translate = dx ? `${dx}px 0` : "";
65
+ const next = reclampPanel(dx, r.left, r.width, room, EDGE);
66
+ if (next !== dx) {
67
+ dx = next;
68
+ el.style.translate = dx ? `${dx}px 0` : "";
69
+ }
66
70
  };
67
71
  place();
68
- window.addEventListener("resize", place);
69
- return () => window.removeEventListener("resize", place);
72
+ const frame = oncePerFrame(place);
73
+ const sized = new ResizeObserver(frame.schedule);
74
+ sized.observe(el);
75
+ for (const around of [mark.current, box.current])
76
+ if (around)
77
+ sized.observe(around);
78
+ window.addEventListener("resize", frame.schedule);
79
+ window.addEventListener("orientationchange", frame.schedule);
80
+ window.addEventListener("scroll", frame.schedule, { capture: true, passive: true });
81
+ return () => {
82
+ frame.cancel();
83
+ sized.disconnect();
84
+ window.removeEventListener("resize", frame.schedule);
85
+ window.removeEventListener("orientationchange", frame.schedule);
86
+ window.removeEventListener("scroll", frame.schedule, { capture: true });
87
+ };
70
88
  }, [open]);
71
89
  // Dismissed by a tap outside or Escape.
72
90
  useEffect(() => {
@@ -89,7 +107,7 @@ function QuestionPanel({ q, clear, c, }) {
89
107
  }, [open]);
90
108
  const on = q.summary !== "";
91
109
  const set = (key, v) => q.onChange({ ...q.value, [key]: v });
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) => {
110
+ return (_jsxs("div", { className: c.questions, ref: box, children: [_jsxs("button", { ref: mark, 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
111
  const id = `tf-${String(question.key)}`;
94
112
  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
113
  }), clear] }))] }));
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The display time zone as a select: "Automatic (<the browser's zone>)" and a
3
+ * list of zones. Choosing one stores it under `<prefix>:timezone` and every
4
+ * clock using `useTimezone` follows. Mechanics only — `classNames` styles it,
5
+ * `autoLabel` / `optionLabel` word it, `options` picks the zones offered. A
6
+ * stored zone that is not in `options` is still shown, so the select never
7
+ * lies about the pick.
8
+ */
9
+ export interface TimezoneOption {
10
+ value: string;
11
+ label: string;
12
+ }
13
+ export interface TimezonePickerClassNames {
14
+ root?: string;
15
+ label?: string;
16
+ select?: string;
17
+ }
18
+ export interface TimezonePickerProps {
19
+ /** The zones offered. Default `TIMEZONE_OPTIONS` (major cities and UTC). */
20
+ options?: readonly TimezoneOption[];
21
+ /** A visible label above the select; without one the select is labelled "Time zone". */
22
+ label?: string;
23
+ /** Text of the "auto" choice, given the browser's zone. Default "Automatic (<zone>)". */
24
+ autoLabel?: (detected: string) => string;
25
+ /** Text of each zone. Default its label. */
26
+ optionLabel?: (option: TimezoneOption) => string;
27
+ id?: string;
28
+ classNames?: TimezonePickerClassNames;
29
+ }
30
+ export default function TimezonePicker({ options, label, autoLabel, optionLabel, id, classNames: c, }: TimezonePickerProps): import("react").JSX.Element;
@@ -0,0 +1,19 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * The display time zone as a select: "Automatic (<the browser's zone>)" and a
4
+ * list of zones. Choosing one stores it under `<prefix>:timezone` and every
5
+ * clock using `useTimezone` follows. Mechanics only — `classNames` styles it,
6
+ * `autoLabel` / `optionLabel` word it, `options` picks the zones offered. A
7
+ * stored zone that is not in `options` is still shown, so the select never
8
+ * lies about the pick.
9
+ */
10
+ import { TIMEZONE_OPTIONS, detectBrowserTimeZone } from "../timezone.js";
11
+ import { useTimezone } from "./useTimezone.js";
12
+ const defaultAuto = (detected) => `Automatic (${detected})`;
13
+ const defaultOption = (o) => o.label;
14
+ export default function TimezonePicker({ options = TIMEZONE_OPTIONS, label, autoLabel = defaultAuto, optionLabel = defaultOption, id = "tb-timezone", classNames: c = {}, }) {
15
+ const [pref, , setPref] = useTimezone();
16
+ const listed = pref === "auto" || options.some((o) => o.value === pref);
17
+ const shown = listed ? options : [...options, { value: pref, label: pref }];
18
+ return (_jsxs("div", { className: c.root, children: [label && (_jsx("label", { htmlFor: id, className: c.label, children: label })), _jsxs("select", { id: id, value: pref, onChange: (e) => setPref(e.target.value), "aria-label": label ? undefined : "Time zone", className: c.select, children: [_jsx("option", { value: "auto", children: autoLabel(detectBrowserTimeZone()) }), shown.map((o) => (_jsx("option", { value: o.value, children: optionLabel(o) }, o.value)))] })] }));
19
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The account-statement card: balance and lifetime totals, what the last N
3
+ * days spent and bought, and the tools the sats went to.
4
+ *
5
+ * Mechanics only — the `account_statement` call, loading and error states and
6
+ * the arithmetic (`usageFacts`). Every visual choice is the site's, through
7
+ * `classNames`; `figures` picks which numbers show and in what order, `labels`
8
+ * words them, and `renderRow` draws a tool row yourself (a display name for a
9
+ * raw tool name, say). Refresh is a chip.
10
+ */
11
+ import { type ReactNode } from "react";
12
+ import { type ToolSpend } from "../usage.ts";
13
+ export type UsageFigure = "balance" | "deposited" | "consumed" | "expired" | "spent" | "calls" | "credited";
14
+ export interface UsageSummaryClassNames {
15
+ root?: string;
16
+ /** The heading row: title and the Refresh chip. */
17
+ header?: string;
18
+ heading?: string;
19
+ chip?: string;
20
+ figures?: string;
21
+ figure?: string;
22
+ value?: string;
23
+ label?: string;
24
+ /** The heading over the tools. */
25
+ subheading?: string;
26
+ list?: string;
27
+ row?: string;
28
+ tool?: string;
29
+ calls?: string;
30
+ sats?: string;
31
+ loading?: string;
32
+ error?: string;
33
+ empty?: string;
34
+ }
35
+ export interface UsageSummaryProps {
36
+ /** The period asked of the server. Default 30. */
37
+ days?: number;
38
+ /** Default "Last <days> days". */
39
+ heading?: ReactNode;
40
+ /** The numbers shown, in order. Default all; "expired" only when above zero. */
41
+ figures?: readonly UsageFigure[];
42
+ labels?: Partial<Record<UsageFigure, ReactNode>>;
43
+ /** How many tools to list. 0 hides the list. Default 5. */
44
+ topTools?: number;
45
+ /** Default "Top tools". */
46
+ toolsHeading?: ReactNode;
47
+ /** Draw a tool row yourself. */
48
+ renderRow?: (tool: ToolSpend) => ReactNode;
49
+ /** Shown when the period has no paid calls. Default "No paid calls in this period." */
50
+ empty?: ReactNode;
51
+ /** Content of the Refresh chip; null for none. Default "Refresh". */
52
+ refreshLabel?: ReactNode;
53
+ classNames?: UsageSummaryClassNames;
54
+ }
55
+ export default function UsageSummary({ days, heading, figures, labels, topTools, toolsHeading, renderRow, empty, refreshLabel, classNames: c, }: UsageSummaryProps): import("react").JSX.Element;
@@ -0,0 +1,53 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * The account-statement card: balance and lifetime totals, what the last N
4
+ * days spent and bought, and the tools the sats went to.
5
+ *
6
+ * Mechanics only — the `account_statement` call, loading and error states and
7
+ * the arithmetic (`usageFacts`). Every visual choice is the site's, through
8
+ * `classNames`; `figures` picks which numbers show and in what order, `labels`
9
+ * words them, and `renderRow` draws a tool row yourself (a display name for a
10
+ * raw tool name, say). Refresh is a chip.
11
+ */
12
+ import { useCallback, useEffect, useState } from "react";
13
+ import { getAccountStatement } from "../standardTools.js";
14
+ import { usageFacts } from "../usage.js";
15
+ const ALL = ["balance", "deposited", "consumed", "expired", "spent", "calls", "credited"];
16
+ const LABELS = {
17
+ balance: "Balance",
18
+ deposited: "Deposited",
19
+ consumed: "Consumed",
20
+ expired: "Expired",
21
+ spent: "Spent",
22
+ calls: "Calls",
23
+ credited: "Bought",
24
+ };
25
+ export default function UsageSummary({ days = 30, heading, figures = ALL, labels = {}, topTools = 5, toolsHeading = "Top tools", renderRow, empty = "No paid calls in this period.", refreshLabel = "Refresh", classNames: c = {}, }) {
26
+ const [facts, setFacts] = useState(null);
27
+ const [loading, setLoading] = useState(true);
28
+ const [error, setError] = useState(null);
29
+ const load = useCallback(async () => {
30
+ setLoading(true);
31
+ setError(null);
32
+ try {
33
+ const stmt = await getAccountStatement(days);
34
+ const f = usageFacts(stmt, days, topTools);
35
+ if (f)
36
+ setFacts(f);
37
+ else
38
+ setError(stmt.error ?? "No statement available.");
39
+ }
40
+ catch (e) {
41
+ setError(e.message);
42
+ }
43
+ finally {
44
+ setLoading(false);
45
+ }
46
+ }, [days, topTools]);
47
+ useEffect(() => {
48
+ void load();
49
+ }, [load]);
50
+ const shown = facts ? figures.filter((f) => f !== "expired" || facts.expired > 0) : [];
51
+ return (_jsxs("section", { className: c.root, children: [_jsxs("div", { className: c.header, children: [_jsx("div", { className: c.heading, children: heading ?? `Last ${days} days` }), refreshLabel !== null && (_jsx("button", { type: "button", onClick: () => void load(), disabled: loading, className: c.chip, children: loading ? "Loading…" : refreshLabel }))] }), error ? (_jsx("p", { className: c.error, children: error })) : !facts ? (loading && _jsx("p", { className: c.loading, children: "Loading\u2026" })) : (_jsxs(_Fragment, { children: [_jsx("div", { className: c.figures, children: shown.map((f) => (_jsxs("div", { className: c.figure, children: [_jsx("div", { className: c.value, children: facts[f].toLocaleString() }), _jsx("div", { className: c.label, children: labels[f] ?? LABELS[f] })] }, f))) }), topTools > 0 &&
52
+ (facts.tools.length === 0 ? (empty && _jsx("p", { className: c.empty, children: empty })) : (_jsxs(_Fragment, { children: [toolsHeading && _jsx("div", { className: c.subheading, children: toolsHeading }), _jsx("ul", { className: c.list, children: facts.tools.map((t) => (_jsx("li", { className: c.row, children: renderRow ? (renderRow(t)) : (_jsxs(_Fragment, { children: [_jsx("span", { className: c.tool, children: t.tool }), " ", _jsxs("span", { className: c.calls, children: [t.calls.toLocaleString(), " call", t.calls === 1 ? "" : "s"] }), " ", _jsxs("span", { className: c.sats, children: [t.sats.toLocaleString(), " sats"] })] })) }, t.tool))) })] })))] }))] }));
53
+ }
@@ -20,13 +20,10 @@ import { checkBalance, getAccountStatement, } from "../standardTools.js";
20
20
  import { amountReducer, formatSats, parseSats, presetChosen } from "../wallet.js";
21
21
  import CouponsPanel, {} from "./CouponsPanel.js";
22
22
  import { useTopUp } from "./useTopUp.js";
23
+ import { cx } from "./cx.js";
23
24
  const DEFAULT_TOP_UPS = [1_000, 5_000, 25_000];
24
25
  const localDate = (iso) => new Date(iso).toLocaleDateString();
25
26
  const localDateTime = (iso) => new Date(iso).toLocaleString();
26
- function cx(...parts) {
27
- const s = parts.filter(Boolean).join(" ");
28
- return s || undefined;
29
- }
30
27
  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 = {}, }) {
31
28
  const [bal, setBal] = useState(null);
32
29
  const [balError, setBalError] = useState(null);
@@ -0,0 +1,2 @@
1
+ /** Join the class names that are set; undefined when none are, so no empty `class=""`. */
2
+ export declare function cx(...parts: (string | false | null | undefined)[]): string | undefined;
@@ -0,0 +1,5 @@
1
+ /** Join the class names that are set; undefined when none are, so no empty `class=""`. */
2
+ export function cx(...parts) {
3
+ const s = parts.filter(Boolean).join(" ");
4
+ return s || undefined;
5
+ }
@@ -1,8 +1,10 @@
1
1
  export { default as Avatar } from "./Avatar.tsx";
2
2
  export { default as AvatarPicker } from "./AvatarPicker.tsx";
3
+ export { default as BuildInfoPanel, type BuildInfoFrontend, type BuildInfoLicence, type BuildInfoPanelClassNames, type BuildInfoPanelProps, } from "./BuildInfoPanel.tsx";
3
4
  export { default as CouponsPanel, type CouponsPanelProps, type CouponsPanelClassNames } from "./CouponsPanel.tsx";
4
5
  export { default as DebugPanel, type DebugPanelProps } from "./DebugPanel.tsx";
5
6
  export { default as ErrorBoundary, type ErrorBoundaryProps, type ErrorBoundaryClassNames, type ErrorFallbackProps, } from "./ErrorBoundary.tsx";
7
+ export { OperatorFundingStatus, PatronFundingStatus, type FundingStatusClassNames, type FundingStatusProps, type OperatorFundingStatusProps, type PatronFundingStatusProps, } from "./FundingStatus.tsx";
6
8
  export { default as NpubGate } from "./NpubGate.tsx";
7
9
  export { default as NostrProfilePanel } from "./NostrProfilePanel.tsx";
8
10
  export { PageControls, SortHeader, TableShell, type PageControlsProps, type PageControlsClassNames, type SortHeaderProps, type SortHeaderClassNames, type TableShellClassNames, } from "./PagedTable.tsx";
@@ -10,9 +12,12 @@ export { default as QuoteScroller, type QuoteScrollerProps, type QuoteScrollerCl
10
12
  export { default as SessionKeyClaim } from "./SessionKeyClaim.tsx";
11
13
  export { default as TableFilter, type DateFieldOption, type FilterQuestion, type TableFilterProps, type TableFilterClassNames, type TableFilterDates, type TableFilterQuestions, type TableFilterSearch, } from "./TableFilter.tsx";
12
14
  export { default as ThemeToggle, type ThemeToggleProps, type ThemeToggleClassNames } from "./ThemeToggle.tsx";
15
+ export { default as TimezonePicker, type TimezoneOption, type TimezonePickerClassNames, type TimezonePickerProps, } from "./TimezonePicker.tsx";
16
+ export { default as UsageSummary, type UsageFigure, type UsageSummaryClassNames, type UsageSummaryProps, } from "./UsageSummary.tsx";
13
17
  export { default as WalletCard, type WalletCardProps, type WalletCardClassNames } from "./WalletCard.tsx";
14
18
  export { default as WalletPage, type WalletPageProps, type WalletPageClassNames } from "./WalletPage.tsx";
15
19
  export { useSession, type Session } from "./useSession.ts";
16
20
  export { useDebugLog } from "./useDebugLog.ts";
17
21
  export { useTheme } from "./useTheme.ts";
22
+ export { useTimezone } from "./useTimezone.ts";
18
23
  export { useTopUp, type TopUp, type UseTopUpOptions } from "./useTopUp.ts";
@@ -1,8 +1,10 @@
1
1
  export { default as Avatar } from "./Avatar.js";
2
2
  export { default as AvatarPicker } from "./AvatarPicker.js";
3
+ export { default as BuildInfoPanel, } from "./BuildInfoPanel.js";
3
4
  export { default as CouponsPanel } from "./CouponsPanel.js";
4
5
  export { default as DebugPanel } from "./DebugPanel.js";
5
6
  export { default as ErrorBoundary, } from "./ErrorBoundary.js";
7
+ export { OperatorFundingStatus, PatronFundingStatus, } from "./FundingStatus.js";
6
8
  export { default as NpubGate } from "./NpubGate.js";
7
9
  export { default as NostrProfilePanel } from "./NostrProfilePanel.js";
8
10
  export { PageControls, SortHeader, TableShell, } from "./PagedTable.js";
@@ -10,9 +12,12 @@ export { default as QuoteScroller } from "./QuoteScroller.js";
10
12
  export { default as SessionKeyClaim } from "./SessionKeyClaim.js";
11
13
  export { default as TableFilter, } from "./TableFilter.js";
12
14
  export { default as ThemeToggle } from "./ThemeToggle.js";
15
+ export { default as TimezonePicker, } from "./TimezonePicker.js";
16
+ export { default as UsageSummary, } from "./UsageSummary.js";
13
17
  export { default as WalletCard } from "./WalletCard.js";
14
18
  export { default as WalletPage } from "./WalletPage.js";
15
19
  export { useSession } from "./useSession.js";
16
20
  export { useDebugLog } from "./useDebugLog.js";
17
21
  export { useTheme } from "./useTheme.js";
22
+ export { useTimezone } from "./useTimezone.js";
18
23
  export { useTopUp } from "./useTopUp.js";
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Keeping TableFilter's questions panel on screen while it is open.
3
+ *
4
+ * The panel hangs from the filter mark, so anything that moves the mark moves
5
+ * the panel: ticking a question widens the mark (its summary), the Clear chip
6
+ * appears inside the panel, the window turns or scrolls. So the panel is
7
+ * re-placed on every one of those, not only when it opens.
8
+ *
9
+ * It is slid by the CSS `translate` property, which `getBoundingClientRect`
10
+ * already includes. Taking the slide in force back off gives where the site's
11
+ * CSS alone puts the panel, and the new slide is worked out from there — no
12
+ * need to clear the slide and force a second layout just to measure.
13
+ */
14
+ /**
15
+ * The slide (px) that keeps the panel `margin` px inside both edges.
16
+ * `measuredLeft` is where it is drawn now, with `currentDx` already applied.
17
+ */
18
+ export declare function reclampPanel(currentDx: number, measuredLeft: number, width: number, viewportWidth: number, margin?: number): number;
19
+ /**
20
+ * `run` at most once per animation frame, however many times the returned
21
+ * function is called before that frame. `cancel` drops a pending run.
22
+ */
23
+ export declare function oncePerFrame(run: () => void, request?: (cb: () => void) => number, release?: (id: number) => void): {
24
+ schedule: () => void;
25
+ cancel: () => void;
26
+ };
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Keeping TableFilter's questions panel on screen while it is open.
3
+ *
4
+ * The panel hangs from the filter mark, so anything that moves the mark moves
5
+ * the panel: ticking a question widens the mark (its summary), the Clear chip
6
+ * appears inside the panel, the window turns or scrolls. So the panel is
7
+ * re-placed on every one of those, not only when it opens.
8
+ *
9
+ * It is slid by the CSS `translate` property, which `getBoundingClientRect`
10
+ * already includes. Taking the slide in force back off gives where the site's
11
+ * CSS alone puts the panel, and the new slide is worked out from there — no
12
+ * need to clear the slide and force a second layout just to measure.
13
+ */
14
+ import { nudgeIntoView } from "../table.js";
15
+ /**
16
+ * The slide (px) that keeps the panel `margin` px inside both edges.
17
+ * `measuredLeft` is where it is drawn now, with `currentDx` already applied.
18
+ */
19
+ export function reclampPanel(currentDx, measuredLeft, width, viewportWidth, margin = 8) {
20
+ return nudgeIntoView(measuredLeft - currentDx, width, viewportWidth, margin);
21
+ }
22
+ /**
23
+ * `run` at most once per animation frame, however many times the returned
24
+ * function is called before that frame. `cancel` drops a pending run.
25
+ */
26
+ export function oncePerFrame(run, request = (cb) => requestAnimationFrame(cb), release = (id) => cancelAnimationFrame(id)) {
27
+ let pending = null;
28
+ return {
29
+ schedule() {
30
+ if (pending !== null)
31
+ return;
32
+ pending = request(() => {
33
+ pending = null;
34
+ run();
35
+ });
36
+ },
37
+ cancel() {
38
+ if (pending === null)
39
+ return;
40
+ release(pending);
41
+ pending = null;
42
+ },
43
+ };
44
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The display time zone as React state: `[pref, zone, setPref]` — the stored
3
+ * preference ("auto" or an IANA name), the zone it resolves to now, and a
4
+ * setter. Every component using it re-renders when any of them sets it, and
5
+ * when another tab does.
6
+ */
7
+ import { type TimezonePref } from "../timezone.ts";
8
+ export declare function useTimezone(): [TimezonePref, string, (next: TimezonePref) => void];
@@ -0,0 +1,34 @@
1
+ /**
2
+ * The display time zone as React state: `[pref, zone, setPref]` — the stored
3
+ * preference ("auto" or an IANA name), the zone it resolves to now, and a
4
+ * setter. Every component using it re-renders when any of them sets it, and
5
+ * when another tab does.
6
+ */
7
+ import { useCallback, useMemo, useSyncExternalStore } from "react";
8
+ import { onTimezoneChange, readTimezonePref, resolveTimeZone, timezoneStorageKey, writeTimezonePref, } from "../timezone.js";
9
+ function subscribe(changed) {
10
+ const stopPage = onTimezoneChange(changed);
11
+ const onStorage = (e) => {
12
+ try {
13
+ if (e.key === timezoneStorageKey())
14
+ changed();
15
+ }
16
+ catch {
17
+ /* not configured yet: nothing of ours changed */
18
+ }
19
+ };
20
+ globalThis.addEventListener?.("storage", onStorage);
21
+ return () => {
22
+ stopPage();
23
+ globalThis.removeEventListener?.("storage", onStorage);
24
+ };
25
+ }
26
+ const onServer = () => "auto";
27
+ export function useTimezone() {
28
+ const pref = useSyncExternalStore(subscribe, readTimezonePref, onServer);
29
+ const zone = useMemo(() => resolveTimeZone(pref), [pref]);
30
+ const setPref = useCallback((next) => {
31
+ writeTimezonePref(next);
32
+ }, []);
33
+ return [pref, zone, setPref];
34
+ }