@tollbooth-dpyc/web 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +63 -1
- package/dist/client.d.ts +5 -1
- package/dist/client.js +15 -2
- package/dist/errorReport.d.ts +5 -0
- package/dist/errorReport.js +14 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.js +6 -1
- package/dist/networkError.d.ts +29 -0
- package/dist/networkError.js +61 -0
- package/dist/react/CouponsPanel.d.ts +52 -0
- package/dist/react/CouponsPanel.js +98 -0
- package/dist/react/ErrorBoundary.d.ts +55 -0
- package/dist/react/ErrorBoundary.js +49 -0
- package/dist/react/PagedTable.d.ts +64 -0
- package/dist/react/PagedTable.js +28 -0
- package/dist/react/TableFilter.d.ts +93 -0
- package/dist/react/TableFilter.js +68 -0
- package/dist/react/ThemeToggle.d.ts +22 -0
- package/dist/react/ThemeToggle.js +11 -0
- package/dist/react/WalletCard.d.ts +3 -0
- package/dist/react/WalletCard.js +19 -53
- package/dist/react/WalletPage.d.ts +70 -0
- package/dist/react/WalletPage.js +104 -0
- package/dist/react/index.d.ts +8 -0
- package/dist/react/index.js +8 -0
- package/dist/react/useTheme.d.ts +7 -0
- package/dist/react/useTheme.js +44 -0
- package/dist/react/useTopUp.d.ts +30 -0
- package/dist/react/useTopUp.js +74 -0
- package/dist/standardTools.d.ts +110 -7
- package/dist/standardTools.js +12 -0
- package/dist/table.d.ts +52 -0
- package/dist/table.js +95 -0
- package/dist/theme.d.ts +40 -0
- package/dist/theme.js +76 -0
- package/dist/wallet.d.ts +85 -0
- package/dist/wallet.js +153 -0
- package/package.json +1 -1
- package/theme.css +22 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A Lightning top-up as a hook: purchase_credits → an invoice → check_payment
|
|
3
|
+
* until it settles, expires or the patron gives up.
|
|
4
|
+
*
|
|
5
|
+
* The state machine is `topUpReducer` (framework-free, tested). This adds the
|
|
6
|
+
* calls and the polling: while an invoice is out and the tab is visible,
|
|
7
|
+
* check_payment runs every `pollMs` — it is free and safe to repeat — up to
|
|
8
|
+
* `maxChecks` times, after which the patron's own "check" still works.
|
|
9
|
+
*/
|
|
10
|
+
import { useCallback, useEffect, useReducer, useRef, useState } from "react";
|
|
11
|
+
import { checkPayment, purchaseCredits } from "../standardTools.js";
|
|
12
|
+
import { shouldPoll, TOP_UP_IDLE, topUpReducer } from "../wallet.js";
|
|
13
|
+
function messageOf(e) {
|
|
14
|
+
return e instanceof Error ? e.message : String(e);
|
|
15
|
+
}
|
|
16
|
+
function tabHidden() {
|
|
17
|
+
return typeof document !== "undefined" && document.visibilityState === "hidden";
|
|
18
|
+
}
|
|
19
|
+
export function useTopUp({ pollMs = 5000, maxChecks = 60, onSettled } = {}) {
|
|
20
|
+
const [state, dispatch] = useReducer(topUpReducer, TOP_UP_IDLE);
|
|
21
|
+
const latest = useRef(state);
|
|
22
|
+
latest.current = state;
|
|
23
|
+
const settledRef = useRef(onSettled);
|
|
24
|
+
settledRef.current = onSettled;
|
|
25
|
+
// Bumped when the tab comes back, so a poll skipped while hidden is re-armed.
|
|
26
|
+
const [wake, setWake] = useState(0);
|
|
27
|
+
const create = useCallback((sats) => {
|
|
28
|
+
const s = latest.current;
|
|
29
|
+
if (s.phase === "creating" || s.phase === "awaiting")
|
|
30
|
+
return;
|
|
31
|
+
dispatch({ type: "create", sats });
|
|
32
|
+
purchaseCredits(sats).then((result) => dispatch({ type: "created", result }), (e) => dispatch({ type: "error", message: messageOf(e) }));
|
|
33
|
+
}, []);
|
|
34
|
+
const check = useCallback(() => {
|
|
35
|
+
const s = latest.current;
|
|
36
|
+
if (s.phase !== "awaiting" || s.checking)
|
|
37
|
+
return;
|
|
38
|
+
const id = s.invoice.id;
|
|
39
|
+
dispatch({ type: "check" });
|
|
40
|
+
checkPayment(id).then((result) => {
|
|
41
|
+
// A late answer for an invoice the patron has since cancelled is dropped.
|
|
42
|
+
const now = latest.current;
|
|
43
|
+
if (now.phase === "awaiting" && now.invoice.id === id)
|
|
44
|
+
dispatch({ type: "checked", result });
|
|
45
|
+
}, (e) => dispatch({ type: "error", message: messageOf(e) }));
|
|
46
|
+
}, []);
|
|
47
|
+
const cancel = useCallback(() => dispatch({ type: "cancel" }), []);
|
|
48
|
+
const reset = useCallback(() => dispatch({ type: "reset" }), []);
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (!(pollMs > 0) || !shouldPoll(state, maxChecks))
|
|
51
|
+
return;
|
|
52
|
+
const t = setTimeout(() => {
|
|
53
|
+
if (tabHidden())
|
|
54
|
+
return; // re-armed by the visibility listener below
|
|
55
|
+
check();
|
|
56
|
+
}, pollMs);
|
|
57
|
+
return () => clearTimeout(t);
|
|
58
|
+
}, [state, pollMs, maxChecks, check, wake]);
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
if (typeof document === "undefined")
|
|
61
|
+
return;
|
|
62
|
+
const onVisible = () => {
|
|
63
|
+
if (!tabHidden())
|
|
64
|
+
setWake((n) => n + 1);
|
|
65
|
+
};
|
|
66
|
+
document.addEventListener("visibilitychange", onVisible);
|
|
67
|
+
return () => document.removeEventListener("visibilitychange", onVisible);
|
|
68
|
+
}, []);
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
if (state.phase === "settled")
|
|
71
|
+
settledRef.current?.(state.credited);
|
|
72
|
+
}, [state]);
|
|
73
|
+
return { state, create, check, cancel, reset };
|
|
74
|
+
}
|
package/dist/standardTools.d.ts
CHANGED
|
@@ -45,13 +45,36 @@ export interface ProofStatusResult {
|
|
|
45
45
|
error_code?: string;
|
|
46
46
|
}
|
|
47
47
|
export declare function checkProofStatus(patronNpub: string, dpopToken: string): Promise<ProofStatusResult>;
|
|
48
|
+
/** One credit tranche: a purchase (or grant) and what is left of it. */
|
|
49
|
+
export interface CreditTranche {
|
|
50
|
+
id: string;
|
|
51
|
+
amount_sats: number;
|
|
52
|
+
remaining_sats: number;
|
|
53
|
+
expires_at: string | null;
|
|
54
|
+
created_at: string | null;
|
|
55
|
+
}
|
|
48
56
|
export interface CheckBalanceResult {
|
|
49
57
|
success?: boolean;
|
|
50
58
|
balance_api_sats?: number;
|
|
51
59
|
total_deposited_api_sats?: number;
|
|
52
60
|
total_consumed_api_sats?: number;
|
|
61
|
+
total_expired_api_sats?: number;
|
|
62
|
+
pending_invoices?: number;
|
|
63
|
+
pending_invoice_ids?: string[];
|
|
64
|
+
last_deposit_at?: string | null;
|
|
53
65
|
next_expiration_iso?: string;
|
|
54
66
|
expiring_within_24h_sats?: number;
|
|
67
|
+
active_tranches?: number;
|
|
68
|
+
tranches?: CreditTranche[];
|
|
69
|
+
expired_tranches?: CreditTranche[];
|
|
70
|
+
seed_balance_granted?: boolean;
|
|
71
|
+
/** The ledger could not be read: the figures may be stale or zero. */
|
|
72
|
+
vault_unavailable?: boolean;
|
|
73
|
+
warning?: string;
|
|
74
|
+
today_usage?: Record<string, {
|
|
75
|
+
calls: number;
|
|
76
|
+
api_sats: number;
|
|
77
|
+
}>;
|
|
55
78
|
error?: string;
|
|
56
79
|
error_code?: string;
|
|
57
80
|
}
|
|
@@ -93,19 +116,99 @@ export interface CheckPaymentResult {
|
|
|
93
116
|
error_code?: string;
|
|
94
117
|
}
|
|
95
118
|
export declare function checkPayment(invoiceId: string): Promise<CheckPaymentResult>;
|
|
96
|
-
export interface
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
119
|
+
export interface StatementInvoice {
|
|
120
|
+
invoice_id: string;
|
|
121
|
+
status: string;
|
|
122
|
+
amount_sats: number;
|
|
123
|
+
api_sats_credited: number;
|
|
124
|
+
multiplier?: number;
|
|
125
|
+
created_at: string | null;
|
|
126
|
+
settled_at?: string;
|
|
127
|
+
}
|
|
128
|
+
export interface StatementTranche {
|
|
129
|
+
granted_at: string | null;
|
|
130
|
+
original_sats: number;
|
|
131
|
+
remaining_sats: number;
|
|
132
|
+
invoice_id: string | null;
|
|
133
|
+
expires_at?: string;
|
|
134
|
+
}
|
|
135
|
+
export interface StatementToolUsage {
|
|
136
|
+
tool: string;
|
|
137
|
+
calls: number;
|
|
138
|
+
api_sats: number;
|
|
139
|
+
}
|
|
140
|
+
export interface StatementDay {
|
|
141
|
+
date: string;
|
|
142
|
+
total_calls: number;
|
|
143
|
+
total_api_sats: number;
|
|
144
|
+
tools: Record<string, {
|
|
103
145
|
calls: number;
|
|
104
146
|
api_sats: number;
|
|
105
147
|
}>;
|
|
148
|
+
}
|
|
149
|
+
/** The wheel's `account_statement`: history, tranches and usage, newest first. */
|
|
150
|
+
export interface AccountStatementResult {
|
|
151
|
+
success?: boolean;
|
|
152
|
+
generated_at?: string;
|
|
153
|
+
statement_period_days?: number;
|
|
154
|
+
account_summary?: {
|
|
155
|
+
balance_api_sats: number;
|
|
156
|
+
total_deposited_api_sats: number;
|
|
157
|
+
total_consumed_api_sats: number;
|
|
158
|
+
total_expired_api_sats: number;
|
|
159
|
+
};
|
|
160
|
+
purchase_history?: StatementInvoice[];
|
|
161
|
+
active_tranches?: StatementTranche[];
|
|
162
|
+
tool_usage_all_time?: StatementToolUsage[];
|
|
163
|
+
daily_usage?: StatementDay[];
|
|
106
164
|
error?: string;
|
|
165
|
+
error_code?: string;
|
|
107
166
|
}
|
|
108
167
|
export declare function getAccountStatement(days?: number): Promise<AccountStatementResult>;
|
|
168
|
+
export interface PatronCoupon {
|
|
169
|
+
coupon_id: string;
|
|
170
|
+
name: string;
|
|
171
|
+
discount_percent: number;
|
|
172
|
+
valid_from: string;
|
|
173
|
+
valid_until: string;
|
|
174
|
+
uses_per_patron: number | null;
|
|
175
|
+
use_count: number;
|
|
176
|
+
uses_remaining: number | null;
|
|
177
|
+
total_uses: number | null;
|
|
178
|
+
total_remaining: number | null;
|
|
179
|
+
/** active | window_closed | window_not_started | patron_limit | total_limit */
|
|
180
|
+
status: string;
|
|
181
|
+
}
|
|
182
|
+
export interface ListMyCouponsResult {
|
|
183
|
+
success?: boolean;
|
|
184
|
+
count?: number;
|
|
185
|
+
coupons?: PatronCoupon[];
|
|
186
|
+
error?: string;
|
|
187
|
+
error_code?: string;
|
|
188
|
+
}
|
|
189
|
+
export interface RedeemCouponResult {
|
|
190
|
+
success?: boolean;
|
|
191
|
+
coupon_id?: string;
|
|
192
|
+
name?: string;
|
|
193
|
+
discount_percent?: number;
|
|
194
|
+
valid_until?: string;
|
|
195
|
+
uses_remaining?: number | null;
|
|
196
|
+
uses_per_patron?: number | null;
|
|
197
|
+
error?: string;
|
|
198
|
+
error_code?: string;
|
|
199
|
+
}
|
|
200
|
+
export interface ForgetCouponResult {
|
|
201
|
+
success?: boolean;
|
|
202
|
+
coupon_id?: string;
|
|
203
|
+
error?: string;
|
|
204
|
+
error_code?: string;
|
|
205
|
+
}
|
|
206
|
+
/** The coupons this patron has redeemed here. Free. */
|
|
207
|
+
export declare function listMyCoupons(): Promise<ListMyCouponsResult>;
|
|
208
|
+
/** Redeem an operator's code once; the discount then applies on its own. Free. */
|
|
209
|
+
export declare function redeemCoupon(code: string): Promise<RedeemCouponResult>;
|
|
210
|
+
/** 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>;
|
|
109
212
|
export interface Kind0 {
|
|
110
213
|
name?: string;
|
|
111
214
|
display_name?: string;
|
package/dist/standardTools.js
CHANGED
|
@@ -56,6 +56,18 @@ export function checkPayment(invoiceId) {
|
|
|
56
56
|
export function getAccountStatement(days = 30) {
|
|
57
57
|
return callTool("account_statement", { days });
|
|
58
58
|
}
|
|
59
|
+
/** The coupons this patron has redeemed here. Free. */
|
|
60
|
+
export function listMyCoupons() {
|
|
61
|
+
return callTool("list_my_coupons", {});
|
|
62
|
+
}
|
|
63
|
+
/** 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 });
|
|
66
|
+
}
|
|
67
|
+
/** 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 });
|
|
70
|
+
}
|
|
59
71
|
export function getNostrProfile(npub) {
|
|
60
72
|
return callTool("get_nostr_profile", { npub });
|
|
61
73
|
}
|
package/dist/table.d.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Paging, sorting and filtering arithmetic for a table, without a framework.
|
|
3
|
+
*
|
|
4
|
+
* Pages are zero-based. A table may be paged by the server (it sends one page
|
|
5
|
+
* and a total) or in the browser (`pageRows` over the whole list); the
|
|
6
|
+
* arithmetic is the same either way.
|
|
7
|
+
*/
|
|
8
|
+
export type SortDir = "asc" | "desc";
|
|
9
|
+
/** The last page's index for `total` rows. Zero when there are none. */
|
|
10
|
+
export declare function lastPage(total: number, pageSize: number): number;
|
|
11
|
+
/** A page index that exists: whole, and between 0 and the last page. */
|
|
12
|
+
export declare function clampPage(page: number, total: number, pageSize: number): number;
|
|
13
|
+
export declare function pageCount(total: number, pageSize: number): number;
|
|
14
|
+
/** One page of an in-memory list. */
|
|
15
|
+
export declare function pageRows<T>(rows: readonly T[], page: number, pageSize: number): T[];
|
|
16
|
+
/**
|
|
17
|
+
* The sort after a header is tapped: the active column flips direction; a new
|
|
18
|
+
* column starts at `initial` (descending by default — newest, largest first).
|
|
19
|
+
*/
|
|
20
|
+
export declare function nextSort(col: string, activeCol: string, dir: SortDir, initial?: SortDir): {
|
|
21
|
+
col: string;
|
|
22
|
+
dir: SortDir;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* A case-insensitive test for `pattern`. It is read as a regular expression
|
|
26
|
+
* when it is one, and as plain text when it is not, so a stray "(" in a search
|
|
27
|
+
* box finds the bracket rather than throwing. An empty pattern matches all.
|
|
28
|
+
*/
|
|
29
|
+
export declare function searchMatcher(pattern: string): (text: string) => boolean;
|
|
30
|
+
/** The calendar day ("YYYY-MM-DD") of an ISO stamp or date, or null. */
|
|
31
|
+
export declare function dayOf(value: string | null | undefined): string | null;
|
|
32
|
+
/**
|
|
33
|
+
* Whether `value` falls in the inclusive range [from, to], compared by day.
|
|
34
|
+
* An empty bound is open. A row with no date is outside any range that has
|
|
35
|
+
* a bound, and inside one that has none.
|
|
36
|
+
*/
|
|
37
|
+
export declare function inDateRange(value: string | null | undefined, from: string, to: string): boolean;
|
|
38
|
+
export interface RowFilter {
|
|
39
|
+
search: string;
|
|
40
|
+
dateFrom: string;
|
|
41
|
+
dateTo: string;
|
|
42
|
+
}
|
|
43
|
+
export declare const NO_FILTER: RowFilter;
|
|
44
|
+
export declare function filterActive(f: Partial<RowFilter>, summary?: string): boolean;
|
|
45
|
+
/**
|
|
46
|
+
* Filter rows in the browser, for a table the server does not filter.
|
|
47
|
+
* `text` gives what the search reads; `date` the stamp the range reads.
|
|
48
|
+
*/
|
|
49
|
+
export declare function filterRows<T>(rows: readonly T[], f: Partial<RowFilter>, by: {
|
|
50
|
+
text: (row: T) => string;
|
|
51
|
+
date?: (row: T) => string | null | undefined;
|
|
52
|
+
}): T[];
|
package/dist/table.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Paging, sorting and filtering arithmetic for a table, without a framework.
|
|
3
|
+
*
|
|
4
|
+
* Pages are zero-based. A table may be paged by the server (it sends one page
|
|
5
|
+
* and a total) or in the browser (`pageRows` over the whole list); the
|
|
6
|
+
* arithmetic is the same either way.
|
|
7
|
+
*/
|
|
8
|
+
/** The last page's index for `total` rows. Zero when there are none. */
|
|
9
|
+
export function lastPage(total, pageSize) {
|
|
10
|
+
if (!(pageSize > 0) || !(total > 0))
|
|
11
|
+
return 0;
|
|
12
|
+
return Math.max(0, Math.ceil(total / pageSize) - 1);
|
|
13
|
+
}
|
|
14
|
+
/** A page index that exists: whole, and between 0 and the last page. */
|
|
15
|
+
export function clampPage(page, total, pageSize) {
|
|
16
|
+
const p = Number.isFinite(page) ? Math.trunc(page) : 0;
|
|
17
|
+
return Math.min(Math.max(0, p), lastPage(total, pageSize));
|
|
18
|
+
}
|
|
19
|
+
export function pageCount(total, pageSize) {
|
|
20
|
+
return lastPage(total, pageSize) + 1;
|
|
21
|
+
}
|
|
22
|
+
/** One page of an in-memory list. */
|
|
23
|
+
export function pageRows(rows, page, pageSize) {
|
|
24
|
+
if (!(pageSize > 0))
|
|
25
|
+
return [...rows];
|
|
26
|
+
const p = clampPage(page, rows.length, pageSize);
|
|
27
|
+
return rows.slice(p * pageSize, (p + 1) * pageSize);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The sort after a header is tapped: the active column flips direction; a new
|
|
31
|
+
* column starts at `initial` (descending by default — newest, largest first).
|
|
32
|
+
*/
|
|
33
|
+
export function nextSort(col, activeCol, dir, initial = "desc") {
|
|
34
|
+
if (col === activeCol)
|
|
35
|
+
return { col, dir: dir === "desc" ? "asc" : "desc" };
|
|
36
|
+
return { col, dir: initial };
|
|
37
|
+
}
|
|
38
|
+
// ── Filtering ──────────────────────────────────────────────────────────────
|
|
39
|
+
/**
|
|
40
|
+
* A case-insensitive test for `pattern`. It is read as a regular expression
|
|
41
|
+
* when it is one, and as plain text when it is not, so a stray "(" in a search
|
|
42
|
+
* box finds the bracket rather than throwing. An empty pattern matches all.
|
|
43
|
+
*/
|
|
44
|
+
export function searchMatcher(pattern) {
|
|
45
|
+
const p = pattern.trim();
|
|
46
|
+
if (!p)
|
|
47
|
+
return () => true;
|
|
48
|
+
try {
|
|
49
|
+
const re = new RegExp(p, "i");
|
|
50
|
+
return (text) => re.test(text);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
const needle = p.toLowerCase();
|
|
54
|
+
return (text) => text.toLowerCase().includes(needle);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** The calendar day ("YYYY-MM-DD") of an ISO stamp or date, or null. */
|
|
58
|
+
export function dayOf(value) {
|
|
59
|
+
if (!value)
|
|
60
|
+
return null;
|
|
61
|
+
const m = /^(\d{4}-\d{2}-\d{2})/.exec(value);
|
|
62
|
+
return m ? m[1] : null;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Whether `value` falls in the inclusive range [from, to], compared by day.
|
|
66
|
+
* An empty bound is open. A row with no date is outside any range that has
|
|
67
|
+
* a bound, and inside one that has none.
|
|
68
|
+
*/
|
|
69
|
+
export function inDateRange(value, from, to) {
|
|
70
|
+
if (!from && !to)
|
|
71
|
+
return true;
|
|
72
|
+
const day = dayOf(value);
|
|
73
|
+
if (!day)
|
|
74
|
+
return false;
|
|
75
|
+
if (from && day < from)
|
|
76
|
+
return false;
|
|
77
|
+
if (to && day > to)
|
|
78
|
+
return false;
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
export const NO_FILTER = { search: "", dateFrom: "", dateTo: "" };
|
|
82
|
+
export function filterActive(f, summary = "") {
|
|
83
|
+
return !!(f.search?.trim() || f.dateFrom || f.dateTo || summary);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Filter rows in the browser, for a table the server does not filter.
|
|
87
|
+
* `text` gives what the search reads; `date` the stamp the range reads.
|
|
88
|
+
*/
|
|
89
|
+
export function filterRows(rows, f, by) {
|
|
90
|
+
const match = searchMatcher(f.search ?? "");
|
|
91
|
+
const from = f.dateFrom ?? "";
|
|
92
|
+
const to = f.dateTo ?? "";
|
|
93
|
+
const ranged = !!(from || to) && !!by.date;
|
|
94
|
+
return rows.filter((r) => match(by.text(r)) && (!ranged || inDateRange(by.date(r), from, to)));
|
|
95
|
+
}
|
package/dist/theme.d.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Light, dark or the system's choice — kept under `<prefix>:theme` so a
|
|
3
|
+
* returning patron keeps their pick, and applied to <html> before first paint.
|
|
4
|
+
*
|
|
5
|
+
* Mechanics only; the colours are the site's. Applying a theme sets all three
|
|
6
|
+
* hooks a stylesheet might key on, so every fleet site's CSS keeps working:
|
|
7
|
+
* - the `dark` class (Tailwind `darkMode: "class"`, `@custom-variant dark`),
|
|
8
|
+
* - `data-theme="light" | "dark"` (the package's theme.css and token sheets),
|
|
9
|
+
* - `color-scheme` (native form controls and scrollbars).
|
|
10
|
+
* "system" resolves to the OS preference and follows it live.
|
|
11
|
+
*/
|
|
12
|
+
export type Theme = "dark" | "light" | "system";
|
|
13
|
+
export type EffectiveTheme = "dark" | "light";
|
|
14
|
+
export declare const THEMES: readonly Theme[];
|
|
15
|
+
export declare function isTheme(v: unknown): v is Theme;
|
|
16
|
+
/** The stored pick, or `fallback` when nothing (or nonsense) is stored. */
|
|
17
|
+
export declare function parseTheme(raw: string | null | undefined, fallback?: Theme): Theme;
|
|
18
|
+
export declare function themeStorageKey(): string;
|
|
19
|
+
export declare function readTheme(fallback?: Theme): Theme;
|
|
20
|
+
export declare function writeTheme(theme: Theme): void;
|
|
21
|
+
export declare function systemPrefersDark(): boolean;
|
|
22
|
+
export declare function resolveTheme(theme: Theme, prefersDark?: boolean): EffectiveTheme;
|
|
23
|
+
/** The part of <html> a theme touches — narrow, so it is testable without a DOM. */
|
|
24
|
+
export interface ThemeTarget {
|
|
25
|
+
classList: {
|
|
26
|
+
toggle(token: string, force?: boolean): unknown;
|
|
27
|
+
};
|
|
28
|
+
setAttribute(name: string, value: string): void;
|
|
29
|
+
style: {
|
|
30
|
+
colorScheme: string;
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
export declare function applyTheme(theme: Theme, target?: ThemeTarget, prefersDark?: boolean): EffectiveTheme;
|
|
34
|
+
/**
|
|
35
|
+
* Apply the stored theme. Call once in main.tsx, after `configureTollbooth`
|
|
36
|
+
* and before rendering, so the first frame has the right palette.
|
|
37
|
+
*/
|
|
38
|
+
export declare function bootstrapTheme(fallback?: Theme): Theme;
|
|
39
|
+
/** The pick after `current`, for a single chip that cycles. */
|
|
40
|
+
export declare function nextTheme(current: Theme, order?: readonly Theme[]): Theme;
|
package/dist/theme.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Light, dark or the system's choice — kept under `<prefix>:theme` so a
|
|
3
|
+
* returning patron keeps their pick, and applied to <html> before first paint.
|
|
4
|
+
*
|
|
5
|
+
* Mechanics only; the colours are the site's. Applying a theme sets all three
|
|
6
|
+
* hooks a stylesheet might key on, so every fleet site's CSS keeps working:
|
|
7
|
+
* - the `dark` class (Tailwind `darkMode: "class"`, `@custom-variant dark`),
|
|
8
|
+
* - `data-theme="light" | "dark"` (the package's theme.css and token sheets),
|
|
9
|
+
* - `color-scheme` (native form controls and scrollbars).
|
|
10
|
+
* "system" resolves to the OS preference and follows it live.
|
|
11
|
+
*/
|
|
12
|
+
import { tollboothConfig } from "./config.js";
|
|
13
|
+
export const THEMES = ["dark", "light", "system"];
|
|
14
|
+
export function isTheme(v) {
|
|
15
|
+
return v === "dark" || v === "light" || v === "system";
|
|
16
|
+
}
|
|
17
|
+
/** The stored pick, or `fallback` when nothing (or nonsense) is stored. */
|
|
18
|
+
export function parseTheme(raw, fallback = "dark") {
|
|
19
|
+
return isTheme(raw) ? raw : fallback;
|
|
20
|
+
}
|
|
21
|
+
export function themeStorageKey() {
|
|
22
|
+
return `${tollboothConfig().storagePrefix}:theme`;
|
|
23
|
+
}
|
|
24
|
+
export function readTheme(fallback = "dark") {
|
|
25
|
+
try {
|
|
26
|
+
return parseTheme(globalThis.localStorage?.getItem(themeStorageKey()), fallback);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return fallback; // blocked site data, or not configured yet
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export function writeTheme(theme) {
|
|
33
|
+
try {
|
|
34
|
+
globalThis.localStorage?.setItem(themeStorageKey(), theme);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
/* the choice still applies for this page */
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export function systemPrefersDark() {
|
|
41
|
+
try {
|
|
42
|
+
return !!globalThis.matchMedia?.("(prefers-color-scheme: dark)").matches;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
export function resolveTheme(theme, prefersDark = systemPrefersDark()) {
|
|
49
|
+
if (theme === "system")
|
|
50
|
+
return prefersDark ? "dark" : "light";
|
|
51
|
+
return theme;
|
|
52
|
+
}
|
|
53
|
+
export function applyTheme(theme, target, prefersDark) {
|
|
54
|
+
const effective = resolveTheme(theme, prefersDark);
|
|
55
|
+
const el = target ?? globalThis.document?.documentElement;
|
|
56
|
+
if (el) {
|
|
57
|
+
el.classList.toggle("dark", effective === "dark");
|
|
58
|
+
el.setAttribute("data-theme", effective);
|
|
59
|
+
el.style.colorScheme = effective;
|
|
60
|
+
}
|
|
61
|
+
return effective;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Apply the stored theme. Call once in main.tsx, after `configureTollbooth`
|
|
65
|
+
* and before rendering, so the first frame has the right palette.
|
|
66
|
+
*/
|
|
67
|
+
export function bootstrapTheme(fallback = "dark") {
|
|
68
|
+
const theme = readTheme(fallback);
|
|
69
|
+
applyTheme(theme);
|
|
70
|
+
return theme;
|
|
71
|
+
}
|
|
72
|
+
/** The pick after `current`, for a single chip that cycles. */
|
|
73
|
+
export function nextTheme(current, order = THEMES) {
|
|
74
|
+
const i = order.indexOf(current);
|
|
75
|
+
return order[(i + 1) % order.length] ?? order[0];
|
|
76
|
+
}
|
package/dist/wallet.d.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The wallet's logic without a framework: the top-up state machine, the reading
|
|
3
|
+
* of a check_payment answer, and the words a coupon row needs. Pure, so it is
|
|
4
|
+
* tested without React or a server.
|
|
5
|
+
*
|
|
6
|
+
* A top-up goes idle → creating → awaiting (an invoice is out) → settled, or
|
|
7
|
+
* back to idle on cancel, or to failed when the invoice expires, is invalid or
|
|
8
|
+
* the service refuses. While awaiting, check_payment may be polled: it is free,
|
|
9
|
+
* idempotent and safe to repeat, unlike receive_npub_proof.
|
|
10
|
+
*/
|
|
11
|
+
import type { CheckPaymentResult, PatronCoupon, PurchaseCreditsResult } from "./standardTools.ts";
|
|
12
|
+
/** One invoice the patron has been handed. */
|
|
13
|
+
export interface Invoice {
|
|
14
|
+
id: string;
|
|
15
|
+
/** BTCPay's checkout page, when the service gave one. */
|
|
16
|
+
checkoutLink: string | null;
|
|
17
|
+
/** The BOLT11 string, for a wallet that takes a paste. */
|
|
18
|
+
bolt11: string | null;
|
|
19
|
+
sats: number;
|
|
20
|
+
}
|
|
21
|
+
export type TopUpState = {
|
|
22
|
+
phase: "idle";
|
|
23
|
+
message: string | null;
|
|
24
|
+
} | {
|
|
25
|
+
phase: "creating";
|
|
26
|
+
sats: number;
|
|
27
|
+
} | {
|
|
28
|
+
phase: "awaiting";
|
|
29
|
+
invoice: Invoice;
|
|
30
|
+
checking: boolean;
|
|
31
|
+
status: string | null;
|
|
32
|
+
checks: number;
|
|
33
|
+
} | {
|
|
34
|
+
phase: "settled";
|
|
35
|
+
invoice: Invoice;
|
|
36
|
+
credited: number;
|
|
37
|
+
} | {
|
|
38
|
+
phase: "failed";
|
|
39
|
+
invoice: Invoice | null;
|
|
40
|
+
message: string;
|
|
41
|
+
};
|
|
42
|
+
export type TopUpEvent = {
|
|
43
|
+
type: "create";
|
|
44
|
+
sats: number;
|
|
45
|
+
} | {
|
|
46
|
+
type: "created";
|
|
47
|
+
result: PurchaseCreditsResult;
|
|
48
|
+
} | {
|
|
49
|
+
type: "check";
|
|
50
|
+
} | {
|
|
51
|
+
type: "checked";
|
|
52
|
+
result: CheckPaymentResult;
|
|
53
|
+
} | {
|
|
54
|
+
type: "error";
|
|
55
|
+
message: string;
|
|
56
|
+
} | {
|
|
57
|
+
type: "cancel";
|
|
58
|
+
} | {
|
|
59
|
+
type: "reset";
|
|
60
|
+
};
|
|
61
|
+
export declare const TOP_UP_IDLE: TopUpState;
|
|
62
|
+
/** Where an invoice stands, from BTCPay's status word. */
|
|
63
|
+
export type PaymentPhase = "waiting" | "settled" | "expired" | "invalid";
|
|
64
|
+
export declare function paymentPhase(status: string | undefined | null): PaymentPhase;
|
|
65
|
+
/** The invoice in a purchase_credits answer, or null when there is none to pay. */
|
|
66
|
+
export declare function invoiceFrom(result: PurchaseCreditsResult, asked: number): Invoice | null;
|
|
67
|
+
/** A whole number of sats worth invoicing, from what the patron typed; else null. */
|
|
68
|
+
export declare function parseSats(input: string | number): number | null;
|
|
69
|
+
export declare function topUpReducer(state: TopUpState, event: TopUpEvent): TopUpState;
|
|
70
|
+
/** Whether a poll should run now: an invoice is out, nothing is in flight, and the budget is not spent. */
|
|
71
|
+
export declare function shouldPoll(state: TopUpState, maxChecks: number): boolean;
|
|
72
|
+
/** Sats as the fleet writes them. Null is unknown, and unknown is a dash, never 0. */
|
|
73
|
+
export declare function formatSats(n: number | null | undefined): string;
|
|
74
|
+
/** The wheel's coupon status, in words a patron reads. */
|
|
75
|
+
export declare function couponStatusLabel(status: string): string;
|
|
76
|
+
export declare function couponUsesText(c: Pick<PatronCoupon, "uses_per_patron" | "uses_remaining">): string;
|
|
77
|
+
/** Whole days until the window closes, never below zero; null when the date is unreadable. */
|
|
78
|
+
export declare function couponDaysLeft(validUntil: string, now?: number): number | null;
|
|
79
|
+
export declare function couponExpiryText(validUntil: string, now?: number): string | null;
|
|
80
|
+
/** What a successful redeem_coupon says back. */
|
|
81
|
+
export declare function redeemedText(r: {
|
|
82
|
+
name?: string;
|
|
83
|
+
discount_percent?: number;
|
|
84
|
+
uses_remaining?: number | null;
|
|
85
|
+
}): string;
|