@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.
- package/README.md +65 -4
- package/dist/buildInfo.d.ts +21 -0
- package/dist/buildInfo.js +35 -0
- package/dist/debugLog.js +3 -12
- package/dist/funding.d.ts +73 -0
- package/dist/funding.js +160 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/react/BuildInfoPanel.d.ts +50 -0
- package/dist/react/BuildInfoPanel.js +49 -0
- package/dist/react/CouponsPanel.js +1 -4
- package/dist/react/FundingStatus.d.ts +67 -0
- package/dist/react/FundingStatus.js +120 -0
- package/dist/react/PagedTable.js +1 -4
- package/dist/react/TableFilter.d.ts +3 -3
- package/dist/react/TableFilter.js +36 -18
- package/dist/react/TimezonePicker.d.ts +30 -0
- package/dist/react/TimezonePicker.js +19 -0
- package/dist/react/UsageSummary.d.ts +55 -0
- package/dist/react/UsageSummary.js +53 -0
- package/dist/react/WalletPage.js +1 -4
- package/dist/react/cx.d.ts +2 -0
- package/dist/react/cx.js +5 -0
- package/dist/react/index.d.ts +5 -0
- package/dist/react/index.js +5 -0
- package/dist/react/panelPlacement.d.ts +26 -0
- package/dist/react/panelPlacement.js +44 -0
- package/dist/react/useTimezone.d.ts +8 -0
- package/dist/react/useTimezone.js +34 -0
- package/dist/timezone.d.ts +82 -0
- package/dist/timezone.js +235 -0
- package/dist/usage.d.ts +34 -0
- package/dist/usage.js +53 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/dist/react/PagedTable.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
14
|
-
* it is slid sideways (`
|
|
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:
|
|
15
|
-
* it is slid sideways (`
|
|
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
|
|
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
|
-
|
|
51
|
-
//
|
|
52
|
-
//
|
|
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
|
|
65
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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
|
+
}
|
package/dist/react/WalletPage.js
CHANGED
|
@@ -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);
|
package/dist/react/cx.js
ADDED
package/dist/react/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/react/index.js
CHANGED
|
@@ -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
|
+
}
|