@tollbooth-dpyc/web 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +63 -1
  2. package/dist/client.d.ts +13 -1
  3. package/dist/client.js +18 -2
  4. package/dist/debugLog.js +5 -2
  5. package/dist/errorReport.d.ts +5 -0
  6. package/dist/errorReport.js +14 -0
  7. package/dist/index.d.ts +6 -1
  8. package/dist/index.js +6 -1
  9. package/dist/inlineProof.d.ts +8 -0
  10. package/dist/inlineProof.js +15 -1
  11. package/dist/networkError.d.ts +29 -0
  12. package/dist/networkError.js +61 -0
  13. package/dist/react/CouponsPanel.d.ts +52 -0
  14. package/dist/react/CouponsPanel.js +98 -0
  15. package/dist/react/ErrorBoundary.d.ts +55 -0
  16. package/dist/react/ErrorBoundary.js +49 -0
  17. package/dist/react/PagedTable.d.ts +64 -0
  18. package/dist/react/PagedTable.js +28 -0
  19. package/dist/react/TableFilter.d.ts +93 -0
  20. package/dist/react/TableFilter.js +68 -0
  21. package/dist/react/ThemeToggle.d.ts +22 -0
  22. package/dist/react/ThemeToggle.js +11 -0
  23. package/dist/react/WalletCard.d.ts +3 -0
  24. package/dist/react/WalletCard.js +19 -53
  25. package/dist/react/WalletPage.d.ts +70 -0
  26. package/dist/react/WalletPage.js +104 -0
  27. package/dist/react/index.d.ts +8 -0
  28. package/dist/react/index.js +8 -0
  29. package/dist/react/useSession.d.ts +2 -0
  30. package/dist/react/useSession.js +2 -1
  31. package/dist/react/useTheme.d.ts +7 -0
  32. package/dist/react/useTheme.js +44 -0
  33. package/dist/react/useTopUp.d.ts +30 -0
  34. package/dist/react/useTopUp.js +74 -0
  35. package/dist/standardTools.d.ts +325 -16
  36. package/dist/standardTools.js +58 -4
  37. package/dist/table.d.ts +52 -0
  38. package/dist/table.js +95 -0
  39. package/dist/theme.d.ts +40 -0
  40. package/dist/theme.js +76 -0
  41. package/dist/wallet.d.ts +85 -0
  42. package/dist/wallet.js +153 -0
  43. package/package.json +1 -1
  44. package/theme.css +22 -0
package/README.md CHANGED
@@ -10,7 +10,11 @@ features. It is the peer of the `tollbooth-dpyc` Python wheel.
10
10
  - **MCP client** — one shared connection to the operator, typed wrappers for
11
11
  the standard tools (balance, top-up, statement, price, profile)
12
12
  - **React components** — `NpubGate`, `NostrProfilePanel`, `SessionKeyClaim`, `WalletCard`,
13
- `AvatarPicker`, `DebugPanel`, `QuoteScroller`, `useSession`, `useDebugLog`
13
+ `WalletPage`, `CouponsPanel`, `TableFilter`, `SortHeader` / `PageControls` /
14
+ `TableShell`, `ThemeToggle`, `ErrorBoundary`, `AvatarPicker`, `DebugPanel`,
15
+ `QuoteScroller`, `useSession`, `useTopUp`, `useTheme`, `useDebugLog`
16
+ - **Mechanics, not looks** — the newer components take `classNames` per part
17
+ and add no typography or colour of their own; actions are chips
14
18
  - **Debug log** — every call, result and error from `callTool`, scrubbed of
15
19
  nsecs, hex keys, tokens and proofs before it is stored
16
20
  - **Pages proxy** — the `/mcp` Cloudflare Pages Function
@@ -41,6 +45,64 @@ import { makeMcpProxy } from "@tollbooth-dpyc/web/pages-proxy";
41
45
  export const onRequest = makeMcpProxy("https://chartremotely-mcp.fastmcp.app/mcp");
42
46
  ```
43
47
 
48
+ ### Network failures
49
+
50
+ `callTool` throws a `NetworkError` when the call never reached the service
51
+ (offline, fetch failed, timed out before an answer, the proxy could not reach
52
+ the operator) and a plain `Error` for anything the service said. Only the
53
+ first is safe to queue and send again:
54
+
55
+ ```ts
56
+ import { callTool, isNetworkError } from "@tollbooth-dpyc/web";
57
+
58
+ try {
59
+ await callTool("task_save", task);
60
+ } catch (e) {
61
+ if (isNetworkError(e)) outbox.push({ tool: "task_save", args: task }); // e.tool is the runtime name
62
+ else throw e;
63
+ }
64
+ ```
65
+
66
+ ### Wallet, coupons, tables, theme, error boundary
67
+
68
+ Mechanics only: the calls, states, paging and persistence are the package's;
69
+ every class is yours. Pass `classNames` (each component's type lists its
70
+ parts); without them the markup is plain and inherits from the page.
71
+
72
+ ```tsx
73
+ import { bootstrapTheme, filterRows, pageRows } from "@tollbooth-dpyc/web";
74
+ import {
75
+ CouponsPanel, ErrorBoundary, PageControls, SortHeader, TableFilter, TableShell,
76
+ ThemeToggle, WalletPage,
77
+ } from "@tollbooth-dpyc/web/react";
78
+
79
+ bootstrapTheme(); // main.tsx, after configureTollbooth: <prefix>:theme → <html>
80
+
81
+ <ErrorBoundary classNames={{ root: "p-6", chip: "chip" }}>
82
+ <App />
83
+ </ErrorBoundary>
84
+
85
+ <WalletPage
86
+ topUps={[1_000, 5_000, 25_000]}
87
+ formatDateTime={(iso) => formatInZone(iso, zone)}
88
+ before={<FundingStatus />}
89
+ coupons={{ classNames: { root: "card", chip: "chip" } }}
90
+ classNames={{ section: "card p-5", figure: "text-3xl", chip: "chip", chipActive: "chip-on" }}
91
+ />
92
+
93
+ <ThemeToggle themes={["dark", "light", "system"]} classNames={{ chip: "chip", active: "chip-on" }} />
94
+
95
+ <TableFilter
96
+ search={{ value: search, onSearch: setSearch, title: "Case-insensitive regular expression" }}
97
+ dates={{ from, to, onFrom: setFrom, onTo: setTo, field, fields, onField: setField }}
98
+ onClear={clearAll}
99
+ classNames={{ root: "flex gap-2", input: "field", chip: "chip" }}
100
+ />
101
+ ```
102
+
103
+ `useTopUp` is the wallet's top-up on its own (purchase_credits → invoice →
104
+ check_payment, polled while the tab is visible) for a site drawing its own.
105
+
44
106
  ### Debug panel
45
107
 
46
108
  `callTool` logs to a shared, scrubbed ring buffer; `DebugPanel` shows it as a
package/dist/client.d.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  * 3. Bootstrap tools take no envelope and are safe before sign-in.
11
11
  */
12
12
  import { type ImageBlock } from "./toolResult.ts";
13
+ export { isNetworkError, NetworkError } from "./networkError.ts";
13
14
  /** A paid call bounced for a lapsed or missing proof. The gate re-arms sign-in. */
14
15
  export declare class ProofRequiredError extends Error {
15
16
  constructor(message: string);
@@ -27,6 +28,14 @@ export interface CallOptions {
27
28
  * patron out, whatever it gets back.
28
29
  */
29
30
  bestEffort?: boolean;
31
+ /**
32
+ * A background read — a badge, a poll — that must not send the patron to
33
+ * the sign-in gate. A proof bounce still throws `ProofRequiredError`, but
34
+ * the session is left as it is and `onProofExpired` is not told; the next
35
+ * call the patron makes themselves meets the lapse and re-arms sign-in.
36
+ * Opt-in: by default a bounce signs the patron out and re-arms the gate.
37
+ */
38
+ quietProof?: boolean;
30
39
  timeoutMs?: number;
31
40
  }
32
41
  export interface ToolAnswer<T> {
@@ -36,7 +45,10 @@ export interface ToolAnswer<T> {
36
45
  /**
37
46
  * Call a tool by its short name ("snapshot_display", not
38
47
  * "chart_snapshot_display") and get both its answer and any images it sent.
48
+ *
49
+ * Throws a `NetworkError` when the call never reached the service (offline,
50
+ * fetch failed, timed out before an answer) — safe to queue and send again —
51
+ * and a plain `Error` for anything the service answered, which is not.
39
52
  */
40
53
  export declare function callToolWithContent<T = unknown>(tool: string, args?: Record<string, unknown>, opts?: CallOptions): Promise<ToolAnswer<T>>;
41
54
  export declare function callTool<T = unknown>(tool: string, args?: Record<string, unknown>, opts?: CallOptions): Promise<T>;
42
- export {};
package/dist/client.js CHANGED
@@ -15,6 +15,7 @@ import { tollboothConfig, toolName } from "./config.js";
15
15
  import { debugPush } from "./debugLog.js";
16
16
  import { clearStoredProof, forgetRecentLogin, getStoredNpub, getStoredProof, } from "./identity.js";
17
17
  import { signInlineProof } from "./inlineProof.js";
18
+ import { isTransportFailure, NetworkError } from "./networkError.js";
18
19
  import { clearSessionNsec, hasSessionNsec, sessionNsecNpub } from "./sessionNsec.js";
19
20
  import { errorText, looksFailed, proofBounceMessage, readToolResult, } from "./toolResult.js";
20
21
  /**
@@ -30,6 +31,7 @@ const STANDARD_BOOTSTRAP = [
30
31
  "publish_nostr_profile",
31
32
  "get_operator_onboarding_status",
32
33
  "check_authority_balance",
34
+ "get_pricing_model",
33
35
  "list_canonical_identities",
34
36
  "check_proof_status",
35
37
  "get_patron_onboarding_status",
@@ -84,6 +86,7 @@ function proofFor(tool) {
84
86
  }
85
87
  return getStoredProof();
86
88
  }
89
+ export { isNetworkError, NetworkError } from "./networkError.js";
87
90
  /** A paid call bounced for a lapsed or missing proof. The gate re-arms sign-in. */
88
91
  export class ProofRequiredError extends Error {
89
92
  constructor(message) {
@@ -114,6 +117,10 @@ function emitProofExpired(message) {
114
117
  /**
115
118
  * Call a tool by its short name ("snapshot_display", not
116
119
  * "chart_snapshot_display") and get both its answer and any images it sent.
120
+ *
121
+ * Throws a `NetworkError` when the call never reached the service (offline,
122
+ * fetch failed, timed out before an answer) — safe to queue and send again —
123
+ * and a plain `Error` for anything the service answered, which is not.
117
124
  */
118
125
  export async function callToolWithContent(tool, args = {}, opts = {}) {
119
126
  const cfg = tollboothConfig();
@@ -133,9 +140,16 @@ export async function callToolWithContent(tool, args = {}, opts = {}) {
133
140
  result = (await c.callTool({ name, arguments: merged }, undefined, { timeout: opts.timeoutMs ?? 120_000 }));
134
141
  }
135
142
  catch (e) {
143
+ const why = e instanceof Error ? e.message : String(e);
144
+ if (isTransportFailure(e)) {
145
+ // Never reached the service, or no answer came back: safe to retry.
146
+ if (!quiet)
147
+ debugPush("error", `${name}: unreachable — ${why}`);
148
+ throw new NetworkError(name, e);
149
+ }
136
150
  if (!quiet)
137
- debugPush("error", `${name}: ${e.message}`);
138
- throw new Error(`${name}: ${e.message}`);
151
+ debugPush("error", `${name}: ${why}`);
152
+ throw new Error(`${name}: ${why}`, { cause: e });
139
153
  }
140
154
  if (result.isError) {
141
155
  const text = errorText(result);
@@ -152,6 +166,8 @@ export async function callToolWithContent(tool, args = {}, opts = {}) {
152
166
  if (!opts.bestEffort) {
153
167
  const bounced = proofBounceMessage(payload);
154
168
  if (bounced !== null) {
169
+ if (opts.quietProof)
170
+ throw new ProofRequiredError(bounced);
155
171
  // The token just refused is the same one the recent-login shortcut
156
172
  // would replay, so it goes too, or a returning patron re-bounces.
157
173
  const npub = getStoredNpub();
package/dist/debugLog.js CHANGED
@@ -28,9 +28,12 @@ const REDACTED = "[redacted]";
28
28
  const SECRET_FIELD = "(?:nsec|ncryptsec|dpop_token|proof|poison|password|passphrase|secret|token|authorization|api_?key|private_?key|client_secret|[a-z0-9_]*_(?:nsec|token|secret|password|passphrase|api_?key|private_?key))";
29
29
  // `"dpop_token":"…"`, `\"dpop_token\":\"…\"` (JSON inside a JSON string),
30
30
  // `dpop_token=…` (a query string) and `dpop_token: '…'` (a Python repr). A
31
- // quoted value is taken whole, spaces and all; a bare one up to a delimiter.
31
+ // quoted value is taken whole — spaces, escaped quotes and the other kind of
32
+ // quote included — up to its own closing quote or the end of a truncated line;
33
+ // a bare one up to a delimiter. Stopping at the first quote of either kind let
34
+ // the tail of a secret holding `'` or `\"` through.
32
35
  function fieldValue(names) {
33
- return new RegExp(`((?:\\\\*["'])?\\b${names}(?:\\\\*["'])?\\s*[:=]\\s*)(?:(\\\\*["'])[^"'\\\\]*|[^"'\\\\,}\\]&\\s]+)`, "gi");
36
+ return new RegExp(`((?:\\\\*["'])?\\b${names}(?:\\\\*["'])?\\s*[:=]\\s*)(?:(\\\\*["'])(?:\\\\.|\\\\$|(?!\\2)[^\\\\])*?(?=\\2|$)|[^"'\\\\,}\\]&\\s]+)`, "gi");
34
37
  }
35
38
  const FIELD_VALUE = fieldValue(SECRET_FIELD);
36
39
  // update_patron_credential / update_operator_credential carry the secret as
@@ -0,0 +1,5 @@
1
+ /**
2
+ * The text a crash screen offers to copy: the error, where it was thrown, the
3
+ * component stack, and the recent activity log (already scrubbed of secrets).
4
+ */
5
+ export declare function errorReport(error: unknown, componentStack?: string, log?: string): string;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The text a crash screen offers to copy: the error, where it was thrown, the
3
+ * component stack, and the recent activity log (already scrubbed of secrets).
4
+ */
5
+ export function errorReport(error, componentStack = "", log = "") {
6
+ const e = error instanceof Error ? error : null;
7
+ const message = e ? e.message : String(error ?? "(unknown)");
8
+ return [
9
+ `Error: ${message}`,
10
+ e?.stack ? `\n${e.stack}` : "",
11
+ componentStack ? `\nComponent stack:${componentStack}` : "",
12
+ log ? `\n\n--- recent activity ---\n${log}` : "",
13
+ ].join("");
14
+ }
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { configureTollbooth, tollboothConfig, toolName, type TollboothConfig } from "./config.ts";
2
- export { callTool, callToolWithContent, onProofExpired, ProofRequiredError, type CallOptions, type ToolAnswer, } from "./client.ts";
2
+ export { callTool, callToolWithContent, isNetworkError, NetworkError, onProofExpired, ProofRequiredError, type CallOptions, type ToolAnswer, } from "./client.ts";
3
3
  export type { ImageBlock } from "./toolResult.ts";
4
4
  export * from "./identity.ts";
5
5
  export * from "./standardTools.ts";
@@ -11,5 +11,10 @@ export { canSignFor, isProven, type Claim } from "./signedIn.ts";
11
11
  export { readSignInFailure, type Situation } from "./signInSituation.ts";
12
12
  export { canSignProfile, fetchProfile, publishProfile } from "./nostrProfile.ts";
13
13
  export * from "./avatar.ts";
14
+ export { isTransportFailure } from "./networkError.ts";
15
+ export * from "./wallet.ts";
16
+ export * from "./table.ts";
17
+ export * from "./theme.ts";
18
+ export { errorReport } from "./errorReport.ts";
14
19
  export { loadQuotes, peekQuotes, shuffle, validQuotes, type Quote } from "./quotes.ts";
15
20
  export { captureGlobalErrors, clearDebug, configureDebugLog, debugEntries, debugLogText, debugPush, debugSeverity, onDebug, redact, type DebugEntry, type DebugLogOptions, type DebugSeverity, } from "./debugLog.ts";
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export { configureTollbooth, tollboothConfig, toolName } from "./config.js";
2
- export { callTool, callToolWithContent, onProofExpired, ProofRequiredError, } from "./client.js";
2
+ export { callTool, callToolWithContent, isNetworkError, NetworkError, onProofExpired, ProofRequiredError, } from "./client.js";
3
3
  export * from "./identity.js";
4
4
  export * from "./standardTools.js";
5
5
  export { clearSessionNsec, getSessionNsec, getSessionNsecBytes, hasSessionNsec, sessionNsecNpub, setSessionNsec, } from "./sessionNsec.js";
@@ -10,5 +10,10 @@ export { canSignFor, isProven } from "./signedIn.js";
10
10
  export { readSignInFailure } from "./signInSituation.js";
11
11
  export { canSignProfile, fetchProfile, publishProfile } from "./nostrProfile.js";
12
12
  export * from "./avatar.js";
13
+ export { isTransportFailure } from "./networkError.js";
14
+ export * from "./wallet.js";
15
+ export * from "./table.js";
16
+ export * from "./theme.js";
17
+ export { errorReport } from "./errorReport.js";
13
18
  export { loadQuotes, peekQuotes, shuffle, validQuotes } from "./quotes.js";
14
19
  export { captureGlobalErrors, clearDebug, configureDebugLog, debugEntries, debugLogText, debugPush, debugSeverity, onDebug, redact, } from "./debugLog.js";
@@ -5,6 +5,14 @@
5
5
  * runtime tool name, e.g. "chart_snapshot_display"), and `created_at` (within
6
6
  * about 60 seconds of server time). The wheel verifies the Schnorr signature
7
7
  * inline — no relay round trip, no cached phrase.
8
+ *
9
+ * Every proof also carries a random `nonce` tag, exactly as the wheel's own
10
+ * `create_proof` does. Without it, two calls to the same tool in the same
11
+ * wall-clock second sign byte-identical events, their ids collide, and the
12
+ * verifier's replay guard refuses the second. The nonce is signed but inert:
13
+ * `verify_proof` reads only the `u` tag.
8
14
  */
15
+ /** 16 random bytes as 32 lowercase hex chars — Python's `secrets.token_hex(16)`. */
16
+ export declare function proofNonce(): string;
9
17
  /** The JSON-stringified signed event, exactly what `verify_proof` expects. */
10
18
  export declare function signInlineProof(runtimeToolName: string, secretKey?: Uint8Array): string;
@@ -5,16 +5,30 @@
5
5
  * runtime tool name, e.g. "chart_snapshot_display"), and `created_at` (within
6
6
  * about 60 seconds of server time). The wheel verifies the Schnorr signature
7
7
  * inline — no relay round trip, no cached phrase.
8
+ *
9
+ * Every proof also carries a random `nonce` tag, exactly as the wheel's own
10
+ * `create_proof` does. Without it, two calls to the same tool in the same
11
+ * wall-clock second sign byte-identical events, their ids collide, and the
12
+ * verifier's replay guard refuses the second. The nonce is signed but inert:
13
+ * `verify_proof` reads only the `u` tag.
8
14
  */
9
15
  import { finalizeEvent } from "nostr-tools";
10
16
  import { getSessionNsecBytes } from "./sessionNsec.js";
11
17
  const PROOF_KIND = 27235;
18
+ /** 16 random bytes as 32 lowercase hex chars — Python's `secrets.token_hex(16)`. */
19
+ export function proofNonce() {
20
+ const bytes = crypto.getRandomValues(new Uint8Array(16));
21
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
22
+ }
12
23
  /** The JSON-stringified signed event, exactly what `verify_proof` expects. */
13
24
  export function signInlineProof(runtimeToolName, secretKey) {
14
25
  const signed = finalizeEvent({
15
26
  kind: PROOF_KIND,
16
27
  created_at: Math.floor(Date.now() / 1000),
17
- tags: [["u", runtimeToolName]],
28
+ tags: [
29
+ ["u", runtimeToolName],
30
+ ["nonce", proofNonce()],
31
+ ],
18
32
  content: "",
19
33
  }, secretKey ?? getSessionNsecBytes());
20
34
  return JSON.stringify(signed);
@@ -0,0 +1,29 @@
1
+ /**
2
+ * "The call never reached the service" as its own kind of failure.
3
+ *
4
+ * A page that queues writes while offline (an outbox) must tell a call that
5
+ * never arrived — safe to send again later — from one the service answered
6
+ * with a refusal, which must not be replayed. `callTool` throws a
7
+ * `NetworkError` for the first kind only; everything the service said,
8
+ * including a tool's `isError` answer and an HTTP status it returned, stays a
9
+ * plain `Error`.
10
+ *
11
+ * Pure: it inspects the thrown value and imports nothing from the MCP SDK, so
12
+ * it is tested without a network.
13
+ */
14
+ export declare class NetworkError extends Error {
15
+ /** The runtime tool name the call was for, e.g. "goodearth_task_save". */
16
+ readonly tool: string;
17
+ constructor(tool: string, cause: unknown);
18
+ }
19
+ export declare function isNetworkError(e: unknown): e is NetworkError;
20
+ /**
21
+ * Whether a thrown value means the call did not reach the service (or no
22
+ * answer came back): the browser is offline, fetch itself failed, the request
23
+ * was aborted or timed out before a response, or the site's Pages proxy could
24
+ * not reach the operator (its 502 "Proxy error").
25
+ *
26
+ * `online` is `navigator.onLine`; when the browser knows it is offline, any
27
+ * failure is a network one.
28
+ */
29
+ export declare function isTransportFailure(e: unknown, online?: boolean): boolean;
@@ -0,0 +1,61 @@
1
+ /**
2
+ * "The call never reached the service" as its own kind of failure.
3
+ *
4
+ * A page that queues writes while offline (an outbox) must tell a call that
5
+ * never arrived — safe to send again later — from one the service answered
6
+ * with a refusal, which must not be replayed. `callTool` throws a
7
+ * `NetworkError` for the first kind only; everything the service said,
8
+ * including a tool's `isError` answer and an HTTP status it returned, stays a
9
+ * plain `Error`.
10
+ *
11
+ * Pure: it inspects the thrown value and imports nothing from the MCP SDK, so
12
+ * it is tested without a network.
13
+ */
14
+ export class NetworkError extends Error {
15
+ /** The runtime tool name the call was for, e.g. "goodearth_task_save". */
16
+ tool;
17
+ constructor(tool, cause) {
18
+ const why = cause instanceof Error ? cause.message : String(cause);
19
+ super(`${tool}: ${why}`, { cause });
20
+ this.name = "NetworkError";
21
+ this.tool = tool;
22
+ }
23
+ }
24
+ export function isNetworkError(e) {
25
+ return e instanceof NetworkError;
26
+ }
27
+ // What each browser's fetch says when the request never left, or never came
28
+ // back: Chrome "Failed to fetch", Safari "Load failed", Firefox "NetworkError
29
+ // when attempting to fetch resource.", Node/undici "fetch failed".
30
+ const FETCH_FAILED = /failed to fetch|load failed|networkerror|fetch failed|network request failed|network connection was lost/i;
31
+ // The SDK's own codes for "no answer came": the connection closed, or the
32
+ // request timed out waiting.
33
+ const MCP_CONNECTION_CLOSED = -32000;
34
+ const MCP_REQUEST_TIMEOUT = -32001;
35
+ /**
36
+ * Whether a thrown value means the call did not reach the service (or no
37
+ * answer came back): the browser is offline, fetch itself failed, the request
38
+ * was aborted or timed out before a response, or the site's Pages proxy could
39
+ * not reach the operator (its 502 "Proxy error").
40
+ *
41
+ * `online` is `navigator.onLine`; when the browser knows it is offline, any
42
+ * failure is a network one.
43
+ */
44
+ export function isTransportFailure(e, online = globalThis.navigator?.onLine ?? true) {
45
+ if (!online)
46
+ return true;
47
+ if (!(e instanceof Error))
48
+ return false;
49
+ if (e.name === "AbortError" || e.name === "TimeoutError")
50
+ return true;
51
+ if (e instanceof TypeError)
52
+ return FETCH_FAILED.test(e.message);
53
+ const code = e.code;
54
+ if (e.name === "McpError")
55
+ return code === MCP_REQUEST_TIMEOUT || code === MCP_CONNECTION_CLOSED;
56
+ // StreamableHTTPError: the HTTP status the page got back. Only the proxy's
57
+ // own 502 — its fetch to the operator threw — means the operator never saw it.
58
+ if (e.message.startsWith("Streamable HTTP error:"))
59
+ return code === 502 && /Proxy error/.test(e.message);
60
+ return false;
61
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The patron's coupons: redeem an operator's code once, see what is held, and
3
+ * take one off the list.
4
+ *
5
+ * Mechanics only — the calls (list_my_coupons, redeem_coupon, forget_coupon),
6
+ * the states and the words. Every visual choice is the site's, through
7
+ * `classNames`; with none, the panel is plain markup inheriting from the page.
8
+ * Actions (Redeem, Remove) are chips: `classNames.chip`.
9
+ */
10
+ import { type ReactNode } from "react";
11
+ import { type PatronCoupon } from "../standardTools.ts";
12
+ export interface CouponsPanelClassNames {
13
+ root?: string;
14
+ heading?: string;
15
+ intro?: string;
16
+ /** The code box and the Redeem chip. */
17
+ form?: string;
18
+ input?: string;
19
+ /** Every action: Redeem, Remove. */
20
+ chip?: string;
21
+ /** Added to the message after a redeem, with `ok` or `error`. */
22
+ message?: string;
23
+ ok?: string;
24
+ error?: string;
25
+ loading?: string;
26
+ empty?: string;
27
+ list?: string;
28
+ row?: string;
29
+ name?: string;
30
+ discount?: string;
31
+ /** The status · uses · expiry line. */
32
+ meta?: string;
33
+ /** Added to the status word when the coupon is active. */
34
+ active?: string;
35
+ }
36
+ export interface CouponsPanelProps {
37
+ heading?: ReactNode;
38
+ intro?: ReactNode;
39
+ /** Shown when the patron holds no coupons. */
40
+ empty?: ReactNode;
41
+ placeholder?: string;
42
+ /** Content of the Redeem chip. */
43
+ redeemLabel?: ReactNode;
44
+ /** Content of each row's Remove chip. */
45
+ forgetLabel?: ReactNode;
46
+ /** Asked before a coupon is removed. Default: the browser's confirm(). */
47
+ confirmForget?: (coupon: PatronCoupon) => boolean | Promise<boolean>;
48
+ classNames?: CouponsPanelClassNames;
49
+ /** Draw a coupon's row yourself; `forget` removes it (after confirmForget). */
50
+ renderCoupon?: (coupon: PatronCoupon, forget: () => void) => ReactNode;
51
+ }
52
+ export default function CouponsPanel({ heading, intro, empty, placeholder, redeemLabel, forgetLabel, confirmForget, classNames: c, renderCoupon, }: CouponsPanelProps): import("react").JSX.Element;
@@ -0,0 +1,98 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * The patron's coupons: redeem an operator's code once, see what is held, and
4
+ * take one off the list.
5
+ *
6
+ * Mechanics only — the calls (list_my_coupons, redeem_coupon, forget_coupon),
7
+ * the states and the words. Every visual choice is the site's, through
8
+ * `classNames`; with none, the panel is plain markup inheriting from the page.
9
+ * Actions (Redeem, Remove) are chips: `classNames.chip`.
10
+ */
11
+ import { useCallback, useEffect, useState } from "react";
12
+ import { forgetCoupon, listMyCoupons, redeemCoupon } from "../standardTools.js";
13
+ import { couponExpiryText, couponStatusLabel, couponUsesText, redeemedText } from "../wallet.js";
14
+ const DEFAULT_INTRO = "Redeem an operator's code once. The discount applies on its own to later paid calls until its uses or its window run out.";
15
+ const DEFAULT_EMPTY = "No coupons yet. Paste a code above to claim its discount.";
16
+ function defaultConfirm() {
17
+ return (typeof window === "undefined" ||
18
+ window.confirm("Remove this coupon from your list? You can redeem the code again while its window allows."));
19
+ }
20
+ function cx(...parts) {
21
+ const s = parts.filter(Boolean).join(" ");
22
+ return s || undefined;
23
+ }
24
+ export default function CouponsPanel({ heading = "My coupons", intro = DEFAULT_INTRO, empty = DEFAULT_EMPTY, placeholder = "Coupon code", redeemLabel = "Redeem", forgetLabel = "Remove", confirmForget = defaultConfirm, classNames: c = {}, renderCoupon, }) {
25
+ const [coupons, setCoupons] = useState([]);
26
+ const [loading, setLoading] = useState(true);
27
+ const [loadError, setLoadError] = useState(null);
28
+ const [code, setCode] = useState("");
29
+ const [redeeming, setRedeeming] = useState(false);
30
+ const [msg, setMsg] = useState(null);
31
+ const refresh = useCallback(async () => {
32
+ setLoading(true);
33
+ setLoadError(null);
34
+ try {
35
+ const r = await listMyCoupons();
36
+ if (r.error)
37
+ setLoadError(r.error);
38
+ else
39
+ setCoupons(r.coupons ?? []);
40
+ }
41
+ catch (e) {
42
+ setLoadError(e.message);
43
+ }
44
+ finally {
45
+ setLoading(false);
46
+ }
47
+ }, []);
48
+ useEffect(() => {
49
+ void refresh();
50
+ }, [refresh]);
51
+ async function redeem() {
52
+ const trimmed = code.trim();
53
+ if (!trimmed || redeeming)
54
+ return;
55
+ setRedeeming(true);
56
+ setMsg(null);
57
+ try {
58
+ const r = await redeemCoupon(trimmed);
59
+ if (r.success) {
60
+ setMsg({ ok: true, text: redeemedText(r) });
61
+ setCode("");
62
+ void refresh();
63
+ }
64
+ else {
65
+ setMsg({ ok: false, text: r.error ?? "That code could not be redeemed." });
66
+ }
67
+ }
68
+ catch (e) {
69
+ setMsg({ ok: false, text: e.message });
70
+ }
71
+ finally {
72
+ setRedeeming(false);
73
+ }
74
+ }
75
+ async function forget(coupon) {
76
+ if (!(await confirmForget(coupon)))
77
+ return;
78
+ try {
79
+ const r = await forgetCoupon(coupon.coupon_id);
80
+ if (r.error)
81
+ setMsg({ ok: false, text: r.error });
82
+ else
83
+ setCoupons((cs) => cs.filter((x) => x.coupon_id !== coupon.coupon_id));
84
+ }
85
+ catch (e) {
86
+ setMsg({ ok: false, text: e.message });
87
+ }
88
+ }
89
+ return (_jsxs("section", { className: c.root, children: [heading && _jsx("div", { className: c.heading, children: heading }), intro && _jsx("p", { className: c.intro, children: intro }), _jsxs("div", { className: c.form, children: [_jsx("input", { type: "text", value: code, onChange: (e) => setCode(e.target.value.toUpperCase()), onKeyDown: (e) => {
90
+ if (e.key === "Enter")
91
+ void redeem();
92
+ }, placeholder: placeholder, "aria-label": "Coupon code", autoCapitalize: "characters", autoCorrect: "off", spellCheck: false, disabled: redeeming, className: c.input }), _jsx("button", { type: "button", onClick: () => void redeem(), disabled: redeeming || !code.trim(), className: c.chip, children: redeeming ? "Redeeming…" : redeemLabel })] }), msg && (_jsx("p", { role: "status", className: cx(c.message, msg.ok ? c.ok : c.error), children: msg.text })), loading ? (_jsx("p", { className: c.loading, children: "Loading\u2026" })) : loadError ? (_jsx("p", { className: cx(c.message, c.error), children: loadError })) : coupons.length === 0 ? (empty && _jsx("p", { className: c.empty, children: empty })) : (_jsx("ul", { className: c.list, children: coupons.map((coupon) => renderCoupon ? (_jsx("li", { className: c.row, children: renderCoupon(coupon, () => void forget(coupon)) }, coupon.coupon_id)) : (_jsx(CouponRow, { coupon: coupon, c: c, forgetLabel: forgetLabel, onForget: () => void forget(coupon) }, coupon.coupon_id))) }))] }));
93
+ }
94
+ function CouponRow({ coupon, c, forgetLabel, onForget, }) {
95
+ const active = coupon.status === "active";
96
+ const expiry = active ? couponExpiryText(coupon.valid_until) : null;
97
+ return (_jsxs("li", { className: c.row, children: [_jsxs("div", { children: [_jsx("span", { className: c.name, children: coupon.name }), " ", _jsxs("span", { className: c.discount, children: [coupon.discount_percent, "% off"] }), _jsxs("div", { className: c.meta, children: [_jsx("span", { className: active ? c.active : undefined, children: couponStatusLabel(coupon.status) }), " · ", couponUsesText(coupon), expiry && ` · ${expiry}`] })] }), _jsx("button", { type: "button", onClick: onForget, title: "Remove from your list (the code can be redeemed again while its window allows)", className: c.chip, children: forgetLabel })] }));
98
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The last guard: a render-time throw shows a calm card instead of a white
3
+ * screen. Data paths catch their own failures; this catches what they miss.
4
+ *
5
+ * The crash is pushed to the package debug log (scrubbed, and persisted when
6
+ * the site turned that on), so it can still be read after a reload. The
7
+ * default card shows the error and offers Copy error + log / Try again /
8
+ * Reload chips. Mechanics only: `classNames` styles it, or `fallback` replaces
9
+ * it entirely.
10
+ */
11
+ import { Component, type ErrorInfo, type ReactNode } from "react";
12
+ export interface ErrorFallbackProps {
13
+ error: Error;
14
+ componentStack: string;
15
+ /** The error, its stacks and the recent activity log, ready to copy. */
16
+ report: () => string;
17
+ /** Render the children again. */
18
+ reset: () => void;
19
+ }
20
+ export interface ErrorBoundaryClassNames {
21
+ root?: string;
22
+ title?: string;
23
+ message?: string;
24
+ /** The <pre> with the error and component stack. */
25
+ detail?: string;
26
+ /** The row of chips. */
27
+ actions?: string;
28
+ chip?: string;
29
+ }
30
+ export interface ErrorBoundaryProps {
31
+ children: ReactNode;
32
+ /** Replace the default card. */
33
+ fallback?: ReactNode | ((p: ErrorFallbackProps) => ReactNode);
34
+ title?: ReactNode;
35
+ message?: ReactNode;
36
+ /** Show the error text and component stack on the card. Default true. */
37
+ showDetail?: boolean;
38
+ onError?: (error: Error, componentStack: string) => void;
39
+ classNames?: ErrorBoundaryClassNames;
40
+ }
41
+ interface State {
42
+ error: Error | null;
43
+ stack: string;
44
+ copied: boolean;
45
+ }
46
+ export default class ErrorBoundary extends Component<ErrorBoundaryProps, State> {
47
+ state: State;
48
+ static getDerivedStateFromError(error: Error): Partial<State>;
49
+ componentDidCatch(error: Error, info: ErrorInfo): void;
50
+ private report;
51
+ private reset;
52
+ private copy;
53
+ render(): ReactNode;
54
+ }
55
+ export {};
@@ -0,0 +1,49 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * The last guard: a render-time throw shows a calm card instead of a white
4
+ * screen. Data paths catch their own failures; this catches what they miss.
5
+ *
6
+ * The crash is pushed to the package debug log (scrubbed, and persisted when
7
+ * the site turned that on), so it can still be read after a reload. The
8
+ * default card shows the error and offers Copy error + log / Try again /
9
+ * Reload chips. Mechanics only: `classNames` styles it, or `fallback` replaces
10
+ * it entirely.
11
+ */
12
+ import { Component } from "react";
13
+ import { debugLogText, debugPush } from "../debugLog.js";
14
+ import { errorReport } from "../errorReport.js";
15
+ export default class ErrorBoundary extends Component {
16
+ state = { error: null, stack: "", copied: false };
17
+ static getDerivedStateFromError(error) {
18
+ return { error };
19
+ }
20
+ componentDidCatch(error, info) {
21
+ const stack = info.componentStack ?? "";
22
+ this.setState({ stack });
23
+ debugPush("error", `render crash: ${error.message}`);
24
+ console.error("Unhandled render error:", error, stack);
25
+ this.props.onError?.(error, stack);
26
+ }
27
+ report = () => errorReport(this.state.error, this.state.stack, debugLogText());
28
+ reset = () => this.setState({ error: null, stack: "", copied: false });
29
+ copy = () => {
30
+ navigator.clipboard?.writeText(this.report()).then(() => {
31
+ this.setState({ copied: true });
32
+ setTimeout(() => this.setState({ copied: false }), 1500);
33
+ }, () => { });
34
+ };
35
+ render() {
36
+ const { error, stack, copied } = this.state;
37
+ if (!error)
38
+ return this.props.children;
39
+ const { fallback, title = "Something went sideways", showDetail = true, classNames: c = {} } = this.props;
40
+ if (typeof fallback === "function") {
41
+ return fallback({ error, componentStack: stack, report: this.report, reset: this.reset });
42
+ }
43
+ if (fallback !== undefined)
44
+ return fallback;
45
+ const message = this.props.message ??
46
+ "The page hit an unexpected error. It is saved to the debug log — copy it into a bug report. Reloading usually clears it.";
47
+ return (_jsxs("div", { role: "alert", className: c.root, children: [_jsx("h1", { className: c.title, children: title }), _jsx("p", { className: c.message, children: message }), showDetail && (_jsxs("pre", { className: c.detail, children: [error.message, stack ? `\n${stack}` : ""] })), _jsxs("div", { className: c.actions, children: [_jsx("button", { type: "button", onClick: this.copy, className: c.chip, children: copied ? "Copied" : "Copy error + log" }), _jsx("button", { type: "button", onClick: this.reset, className: c.chip, children: "Try again" }), _jsx("button", { type: "button", onClick: () => window.location.reload(), className: c.chip, children: "Reload" })] })] }));
48
+ }
49
+ }