@tollbooth-dpyc/web 0.5.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 +78 -3
- 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/DebugPanel.d.ts +6 -0
- package/dist/react/DebugPanel.js +33 -6
- 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/debugSpacer.d.ts +15 -0
- package/dist/react/debugSpacer.js +21 -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
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
|
-
`
|
|
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,11 +45,74 @@ 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
|
|
47
|
-
bar along the bottom of the page with Copy and Clear. Put it once
|
|
48
|
-
|
|
109
|
+
bar along the bottom of the page with Copy and Clear. Put it once, **last** in
|
|
110
|
+
the app shell. The bar is fixed to the viewport, but it also keeps its own
|
|
111
|
+
room in the page: an in-flow spacer as tall as the bar is right now (collapsed
|
|
112
|
+
or open) plus the iOS safe-area inset, so the page's last line always scrolls
|
|
113
|
+
clear of it. Mounted last, that spacer is the page's last thing; mounted
|
|
114
|
+
earlier, the room opens up there instead. Anything the site alone needs goes
|
|
115
|
+
in as children, drawn above the log:
|
|
49
116
|
|
|
50
117
|
```tsx
|
|
51
118
|
import { configureDebugLog, debugPush } from "@tollbooth-dpyc/web";
|
|
@@ -115,6 +182,14 @@ them after:
|
|
|
115
182
|
:root { --tb-accent: #4cc38a; }
|
|
116
183
|
```
|
|
117
184
|
|
|
185
|
+
## Stability
|
|
186
|
+
|
|
187
|
+
From 1.0.0 the public API — everything exported from `@tollbooth-dpyc/web`,
|
|
188
|
+
`/react`, `/pages-proxy` and the `theme.css` tokens — follows semver. Patch and
|
|
189
|
+
minor releases are safe to take unattended (the fleet's Renovate preset
|
|
190
|
+
auto-merges them); a breaking change ships only as a new major, which Renovate
|
|
191
|
+
holds for review.
|
|
192
|
+
|
|
118
193
|
## Develop
|
|
119
194
|
|
|
120
195
|
```sh
|
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);
|
|
@@ -36,7 +37,10 @@ export interface ToolAnswer<T> {
|
|
|
36
37
|
/**
|
|
37
38
|
* Call a tool by its short name ("snapshot_display", not
|
|
38
39
|
* "chart_snapshot_display") and get both its answer and any images it sent.
|
|
40
|
+
*
|
|
41
|
+
* Throws a `NetworkError` when the call never reached the service (offline,
|
|
42
|
+
* fetch failed, timed out before an answer) — safe to queue and send again —
|
|
43
|
+
* and a plain `Error` for anything the service answered, which is not.
|
|
39
44
|
*/
|
|
40
45
|
export declare function callToolWithContent<T = unknown>(tool: string, args?: Record<string, unknown>, opts?: CallOptions): Promise<ToolAnswer<T>>;
|
|
41
46
|
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
|
/**
|
|
@@ -84,6 +85,7 @@ function proofFor(tool) {
|
|
|
84
85
|
}
|
|
85
86
|
return getStoredProof();
|
|
86
87
|
}
|
|
88
|
+
export { isNetworkError, NetworkError } from "./networkError.js";
|
|
87
89
|
/** A paid call bounced for a lapsed or missing proof. The gate re-arms sign-in. */
|
|
88
90
|
export class ProofRequiredError extends Error {
|
|
89
91
|
constructor(message) {
|
|
@@ -114,6 +116,10 @@ function emitProofExpired(message) {
|
|
|
114
116
|
/**
|
|
115
117
|
* Call a tool by its short name ("snapshot_display", not
|
|
116
118
|
* "chart_snapshot_display") and get both its answer and any images it sent.
|
|
119
|
+
*
|
|
120
|
+
* Throws a `NetworkError` when the call never reached the service (offline,
|
|
121
|
+
* fetch failed, timed out before an answer) — safe to queue and send again —
|
|
122
|
+
* and a plain `Error` for anything the service answered, which is not.
|
|
117
123
|
*/
|
|
118
124
|
export async function callToolWithContent(tool, args = {}, opts = {}) {
|
|
119
125
|
const cfg = tollboothConfig();
|
|
@@ -133,9 +139,16 @@ export async function callToolWithContent(tool, args = {}, opts = {}) {
|
|
|
133
139
|
result = (await c.callTool({ name, arguments: merged }, undefined, { timeout: opts.timeoutMs ?? 120_000 }));
|
|
134
140
|
}
|
|
135
141
|
catch (e) {
|
|
142
|
+
const why = e instanceof Error ? e.message : String(e);
|
|
143
|
+
if (isTransportFailure(e)) {
|
|
144
|
+
// Never reached the service, or no answer came back: safe to retry.
|
|
145
|
+
if (!quiet)
|
|
146
|
+
debugPush("error", `${name}: unreachable — ${why}`);
|
|
147
|
+
throw new NetworkError(name, e);
|
|
148
|
+
}
|
|
136
149
|
if (!quiet)
|
|
137
|
-
debugPush("error", `${name}: ${
|
|
138
|
-
throw new Error(`${name}: ${e
|
|
150
|
+
debugPush("error", `${name}: ${why}`);
|
|
151
|
+
throw new Error(`${name}: ${why}`, { cause: e });
|
|
139
152
|
}
|
|
140
153
|
if (result.isError) {
|
|
141
154
|
const text = errorText(result);
|
|
@@ -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";
|
|
@@ -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
|
+
}
|
|
@@ -13,6 +13,12 @@
|
|
|
13
13
|
*
|
|
14
14
|
* Touch-first: every control is a real button at least 40px square, nothing
|
|
15
15
|
* hides behind hover, and the page under the bar stays tappable.
|
|
16
|
+
*
|
|
17
|
+
* The panel keeps its own space: beside the fixed bar it renders an in-flow
|
|
18
|
+
* spacer as tall as the bar is right now (collapsed or open, measured with a
|
|
19
|
+
* ResizeObserver) plus the iOS safe-area inset, so the page's last line can
|
|
20
|
+
* always scroll clear of it. Mount it LAST in the app shell so that spacer is
|
|
21
|
+
* the last thing in the page's flow.
|
|
16
22
|
*/
|
|
17
23
|
import { type ReactNode } from "react";
|
|
18
24
|
export interface DebugPanelProps {
|
package/dist/react/DebugPanel.js
CHANGED
|
@@ -14,11 +14,18 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
|
|
|
14
14
|
*
|
|
15
15
|
* Touch-first: every control is a real button at least 40px square, nothing
|
|
16
16
|
* hides behind hover, and the page under the bar stays tappable.
|
|
17
|
+
*
|
|
18
|
+
* The panel keeps its own space: beside the fixed bar it renders an in-flow
|
|
19
|
+
* spacer as tall as the bar is right now (collapsed or open, measured with a
|
|
20
|
+
* ResizeObserver) plus the iOS safe-area inset, so the page's last line can
|
|
21
|
+
* always scroll clear of it. Mount it LAST in the app shell so that spacer is
|
|
22
|
+
* the last thing in the page's flow.
|
|
17
23
|
*/
|
|
18
|
-
import { useEffect, useState } from "react";
|
|
24
|
+
import { useEffect, useRef, useState } from "react";
|
|
19
25
|
import { onProofExpired } from "../client.js";
|
|
20
26
|
import { captureGlobalErrors, clearDebug, debugLogText, debugPush, debugSeverity, } from "../debugLog.js";
|
|
21
27
|
import { readStored, writeStored } from "../storage.js";
|
|
28
|
+
import { debugSpacerHeight, debugSpacerPx } from "./debugSpacer.js";
|
|
22
29
|
import { useDebugLog } from "./useDebugLog.js";
|
|
23
30
|
const OPEN_KEY = "debug-open";
|
|
24
31
|
const TYPE_INK = {
|
|
@@ -40,6 +47,26 @@ export default function DebugPanel({ title = "Debug", defaultOpen = false, captu
|
|
|
40
47
|
return saved === "" ? defaultOpen : saved === "1";
|
|
41
48
|
});
|
|
42
49
|
const [copied, setCopied] = useState("");
|
|
50
|
+
const barRef = useRef(null);
|
|
51
|
+
// Null until measured (and on the server, or with no ResizeObserver): the
|
|
52
|
+
// spacer then reserves the collapsed bar.
|
|
53
|
+
const [barHeight, setBarHeight] = useState(null);
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
const bar = barRef.current;
|
|
56
|
+
const RO = globalThis.ResizeObserver;
|
|
57
|
+
if (!bar || !RO)
|
|
58
|
+
return;
|
|
59
|
+
// The observer reports once on observe and after every resize — opening,
|
|
60
|
+
// closing, a new log line, rotation — batched to the frame, and a state
|
|
61
|
+
// write only when the whole-pixel height actually changes.
|
|
62
|
+
const ro = new RO((entries) => {
|
|
63
|
+
const box = entries[entries.length - 1]?.borderBoxSize?.[0];
|
|
64
|
+
const next = debugSpacerPx(box ? box.blockSize : bar.getBoundingClientRect().height);
|
|
65
|
+
setBarHeight((prev) => (prev === next ? prev : next));
|
|
66
|
+
});
|
|
67
|
+
ro.observe(bar);
|
|
68
|
+
return () => ro.disconnect();
|
|
69
|
+
}, []);
|
|
43
70
|
useEffect(() => {
|
|
44
71
|
if (!captureErrors)
|
|
45
72
|
return;
|
|
@@ -73,9 +100,9 @@ export default function DebugPanel({ title = "Debug", defaultOpen = false, captu
|
|
|
73
100
|
: notices > 0
|
|
74
101
|
? "bg-[var(--tb-notice-bg)] text-[var(--tb-notice-ink)] border-[var(--tb-notice-line)]"
|
|
75
102
|
: "bg-[var(--tb-surface-2)] text-[var(--tb-ink)] border-[var(--tb-line)]";
|
|
76
|
-
return (_jsxs("div", { className: "pointer-events-none fixed inset-x-0 bottom-0 z-50 flex flex-col items-end", children: [_jsxs("div", { className: "pointer-events-auto flex flex-wrap justify-end gap-1 pr-3", children: [open && (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", onClick: copy, className: plainTab, "aria-label": "Copy the whole log", children: copied === "ok" ? "Copied" : copied === "failed" ? "Copy failed" : "Copy" }), _jsx("button", { type: "button", onClick: clearDebug, className: plainTab, children: "Clear" })] })), _jsxs("button", { type: "button", onClick: toggle, "aria-expanded": open, className: `${tab} ${toggleTone}`, children: [open ? "Hide" : title, " (", log.length, failures > 0 ? ` · ${failures} err` : "", notices > 0 ? ` · ${notices} notice` : "", ")"] })] }), open && (_jsxs("div", { className: "pointer-events-auto max-h-[40vh] w-full overflow-y-auto border-t border-[var(--tb-line)] bg-[var(--tb-surface)] p-3 font-mono text-xs text-[var(--tb-ink)]", children: [children && _jsx("div", { className: "mb-2 border-b border-[var(--tb-line)] pb-2 font-sans", children: children }), log.length === 0 && _jsx("div", { className: "text-[var(--tb-muted)]", children: "No MCP activity yet." }), log.map((entry, i) => {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
103
|
+
return (_jsxs(_Fragment, { children: [_jsx("div", { "aria-hidden": "true", "data-tb-debug-spacer": "", style: { height: debugSpacerHeight(barHeight), flexShrink: 0 } }), _jsxs("div", { ref: barRef, className: "pointer-events-none fixed inset-x-0 bottom-0 z-50 flex flex-col items-end", children: [_jsxs("div", { className: "pointer-events-auto flex flex-wrap justify-end gap-1 pr-3", children: [open && (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", onClick: copy, className: plainTab, "aria-label": "Copy the whole log", children: copied === "ok" ? "Copied" : copied === "failed" ? "Copy failed" : "Copy" }), _jsx("button", { type: "button", onClick: clearDebug, className: plainTab, children: "Clear" })] })), _jsxs("button", { type: "button", onClick: toggle, "aria-expanded": open, className: `${tab} ${toggleTone}`, children: [open ? "Hide" : title, " (", log.length, failures > 0 ? ` · ${failures} err` : "", notices > 0 ? ` · ${notices} notice` : "", ")"] })] }), open && (_jsxs("div", { className: "pointer-events-auto max-h-[40vh] w-full overflow-y-auto border-t border-[var(--tb-line)] bg-[var(--tb-surface)] p-3 font-mono text-xs text-[var(--tb-ink)]", children: [children && _jsx("div", { className: "mb-2 border-b border-[var(--tb-line)] pb-2 font-sans", children: children }), log.length === 0 && _jsx("div", { className: "text-[var(--tb-muted)]", children: "No MCP activity yet." }), log.map((entry, i) => {
|
|
104
|
+
const sev = debugSeverity(entry);
|
|
105
|
+
const flag = sev === "ok" ? null : FLAGGED[sev];
|
|
106
|
+
return (_jsxs("div", { className: `-mx-1 flex gap-2 rounded px-1 py-0.5 ${flag?.row ?? ""}`, children: [_jsx("span", { className: "shrink-0 text-[var(--tb-muted)]", children: entry.ts }), _jsxs("span", { className: `w-14 shrink-0 ${flag?.ink ?? TYPE_INK[entry.type]}`, children: [sev === "notice" ? "notice" : entry.type, sev === "failure" && entry.type !== "error" ? " !" : ""] }), _jsx("span", { className: `min-w-0 break-all ${flag?.ink ?? ""}`, children: entry.message })] }, log.length - i));
|
|
107
|
+
})] }))] })] }));
|
|
81
108
|
}
|
|
@@ -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 {};
|