@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
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,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);
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The parts of a sortable, paged table: a header cell that sorts, the
|
|
3
|
+
* First / Prev / Page N of M / Next / Last controls, and the frame.
|
|
4
|
+
*
|
|
5
|
+
* Mechanics only — the sort toggling and the paging arithmetic (`nextSort`,
|
|
6
|
+
* `lastPage`, `clampPage` in the framework-free half). How it looks is the
|
|
7
|
+
* site's, through `classNames`; the controls are chips. Paging can be the
|
|
8
|
+
* server's (pass its total) or the browser's (`pageRows`).
|
|
9
|
+
*/
|
|
10
|
+
import type { ReactNode } from "react";
|
|
11
|
+
import { type SortDir } from "../table.ts";
|
|
12
|
+
export interface SortHeaderClassNames {
|
|
13
|
+
/** The <th>. */
|
|
14
|
+
cell?: string;
|
|
15
|
+
/** The button inside a sortable header. */
|
|
16
|
+
button?: string;
|
|
17
|
+
/** Added to the button of the column being sorted. */
|
|
18
|
+
active?: string;
|
|
19
|
+
/** The ▾ / ▴ mark. */
|
|
20
|
+
arrow?: string;
|
|
21
|
+
}
|
|
22
|
+
export interface SortHeaderProps {
|
|
23
|
+
label: ReactNode;
|
|
24
|
+
/** The column's sort key. Omit for a header that does not sort. */
|
|
25
|
+
col?: string;
|
|
26
|
+
activeCol: string;
|
|
27
|
+
dir: SortDir;
|
|
28
|
+
onSort: (col: string, dir: SortDir) => void;
|
|
29
|
+
/** The direction a newly chosen column starts in. Default "desc". */
|
|
30
|
+
initialDir?: SortDir;
|
|
31
|
+
classNames?: SortHeaderClassNames;
|
|
32
|
+
}
|
|
33
|
+
/** A header cell. Tapping the sorted column flips it; another column starts at `initialDir`. */
|
|
34
|
+
export declare function SortHeader({ label, col, activeCol, dir, onSort, initialDir, classNames: c }: SortHeaderProps): import("react").JSX.Element;
|
|
35
|
+
export interface PageControlsClassNames {
|
|
36
|
+
root?: string;
|
|
37
|
+
/** Every control. */
|
|
38
|
+
chip?: string;
|
|
39
|
+
/** "Page N of M · K total". */
|
|
40
|
+
label?: string;
|
|
41
|
+
}
|
|
42
|
+
export interface PageControlsProps {
|
|
43
|
+
/** Zero-based. */
|
|
44
|
+
page: number;
|
|
45
|
+
pageSize: number;
|
|
46
|
+
total: number;
|
|
47
|
+
onPage: (page: number) => void;
|
|
48
|
+
/** Render nothing when everything fits on one page. Default false. */
|
|
49
|
+
hideSinglePage?: boolean;
|
|
50
|
+
classNames?: PageControlsClassNames;
|
|
51
|
+
}
|
|
52
|
+
/** First / ← Prev / Page N of M · K total / Next → / Last. */
|
|
53
|
+
export declare function PageControls({ page, pageSize, total, onPage, hideSinglePage, classNames: c }: PageControlsProps): import("react").JSX.Element | null;
|
|
54
|
+
export interface TableShellClassNames {
|
|
55
|
+
/** The frame. Give it `overflow-x: auto` to scroll a wide table — the
|
|
56
|
+
* package does not, since a scrolling box clips popovers inside it. */
|
|
57
|
+
root?: string;
|
|
58
|
+
table?: string;
|
|
59
|
+
}
|
|
60
|
+
/** The frame every paged table sits in: a box and the <table>. */
|
|
61
|
+
export declare function TableShell({ children, classNames: c }: {
|
|
62
|
+
children: ReactNode;
|
|
63
|
+
classNames?: TableShellClassNames;
|
|
64
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { clampPage, lastPage, nextSort } from "../table.js";
|
|
3
|
+
function cx(...parts) {
|
|
4
|
+
const s = parts.filter(Boolean).join(" ");
|
|
5
|
+
return s || undefined;
|
|
6
|
+
}
|
|
7
|
+
/** A header cell. Tapping the sorted column flips it; another column starts at `initialDir`. */
|
|
8
|
+
export function SortHeader({ label, col, activeCol, dir, onSort, initialDir = "desc", classNames: c = {} }) {
|
|
9
|
+
if (!col)
|
|
10
|
+
return _jsx("th", { className: c.cell, children: label });
|
|
11
|
+
const active = col === activeCol;
|
|
12
|
+
return (_jsx("th", { className: c.cell, "aria-sort": active ? (dir === "desc" ? "descending" : "ascending") : undefined, children: _jsxs("button", { type: "button", onClick: () => {
|
|
13
|
+
const next = nextSort(col, activeCol, dir, initialDir);
|
|
14
|
+
onSort(next.col, next.dir);
|
|
15
|
+
}, className: cx(c.button, active && c.active), children: [label, active && (_jsx("span", { "aria-hidden": true, className: c.arrow, children: dir === "desc" ? " ▾" : " ▴" }))] }) }));
|
|
16
|
+
}
|
|
17
|
+
/** First / ← Prev / Page N of M · K total / Next → / Last. */
|
|
18
|
+
export function PageControls({ page, pageSize, total, onPage, hideSinglePage = false, classNames: c = {} }) {
|
|
19
|
+
const last = lastPage(total, pageSize);
|
|
20
|
+
if (hideSinglePage && last === 0)
|
|
21
|
+
return null;
|
|
22
|
+
const p = clampPage(page, total, pageSize);
|
|
23
|
+
return (_jsxs("nav", { "aria-label": "Pages", className: c.root, children: [_jsx("button", { type: "button", className: c.chip, disabled: p === 0, onClick: () => onPage(0), title: "First page", "aria-label": "First page", children: "\u23EE" }), _jsx("button", { type: "button", className: c.chip, disabled: p === 0, onClick: () => onPage(p - 1), children: "\u2190 Prev" }), _jsxs("span", { className: c.label, children: ["Page ", p + 1, " of ", last + 1, " \u00B7 ", total.toLocaleString("en-US"), " total"] }), _jsx("button", { type: "button", className: c.chip, disabled: p >= last, onClick: () => onPage(p + 1), children: "Next \u2192" }), _jsx("button", { type: "button", className: c.chip, disabled: p >= last, onClick: () => onPage(last), title: "Last page", "aria-label": "Last page", children: "\u23ED" })] }));
|
|
24
|
+
}
|
|
25
|
+
/** The frame every paged table sits in: a box and the <table>. */
|
|
26
|
+
export function TableShell({ children, classNames: c = {} }) {
|
|
27
|
+
return (_jsx("div", { className: c.root, children: _jsx("table", { className: c.table, children: children }) }));
|
|
28
|
+
}
|