@firedrill-tools/stripe 0.1.1
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/LICENSE +201 -0
- package/README.md +433 -0
- package/firedrill/agent.target.json +17 -0
- package/firedrill/api-unavailable.scenario.json +11 -0
- package/firedrill/baseline.scenario.json +5 -0
- package/firedrill/conformance.suite.json +17 -0
- package/firedrill/rate-limited.scenario.json +11 -0
- package/firedrill/refund-committed-lost.scenario.json +11 -0
- package/firedrill/stripe-api-unavailable.drill.json +463 -0
- package/firedrill/stripe-denied.drill.json +74 -0
- package/firedrill/stripe-large-pages.drill.json +153 -0
- package/firedrill/stripe-live-mode.drill.json +136 -0
- package/firedrill/stripe-mcp-aliases.drill.json +406 -0
- package/firedrill/stripe-no-permissions.drill.json +1143 -0
- package/firedrill/stripe-rate-limited.drill.json +616 -0
- package/firedrill/stripe-refund-committed-lost.drill.json +139 -0
- package/firedrill/stripe-rest-flow.drill.json +1401 -0
- package/firedrill/stripe-restricted-key.drill.json +171 -0
- package/firedrill/tools/stripe/app/assets/ATTRIBUTION.md +36 -0
- package/firedrill/tools/stripe/app/assets/fonts/OFL.txt +93 -0
- package/firedrill/tools/stripe/app/assets/stripe-s.svg +1 -0
- package/firedrill/tools/stripe/app/assets/stripe.svg +1 -0
- package/firedrill/tools/stripe/app/site/app.js +456 -0
- package/firedrill/tools/stripe/app/site/assets/fonts/inter-latin.woff2 +0 -0
- package/firedrill/tools/stripe/app/site/assets/stripe-s.svg +1 -0
- package/firedrill/tools/stripe/app/site/assets/stripe.svg +1 -0
- package/firedrill/tools/stripe/app/site/icons.js +90 -0
- package/firedrill/tools/stripe/app/site/index.html +137 -0
- package/firedrill/tools/stripe/app/site/pages-billing.js +902 -0
- package/firedrill/tools/stripe/app/site/pages-catalog.js +314 -0
- package/firedrill/tools/stripe/app/site/pages-customers.js +416 -0
- package/firedrill/tools/stripe/app/site/pages-home.js +373 -0
- package/firedrill/tools/stripe/app/site/pages-payments.js +502 -0
- package/firedrill/tools/stripe/app/site/store.js +99 -0
- package/firedrill/tools/stripe/app/site/styles.css +2512 -0
- package/firedrill/tools/stripe/app/site/ui.js +767 -0
- package/firedrill/tools/stripe/app/site/widgets.js +707 -0
- package/firedrill/tools/stripe/behavior.mjs +148 -0
- package/firedrill/tools/stripe/lib/cards.mjs +53 -0
- package/firedrill/tools/stripe/lib/form.mjs +204 -0
- package/firedrill/tools/stripe/lib/ids.mjs +85 -0
- package/firedrill/tools/stripe/lib/money.mjs +35 -0
- package/firedrill/tools/stripe/lib/objects.mjs +229 -0
- package/firedrill/tools/stripe/lib/periods.mjs +41 -0
- package/firedrill/tools/stripe/lib/size.mjs +55 -0
- package/firedrill/tools/stripe/lib/state.mjs +230 -0
- package/firedrill/tools/stripe/lib/validate.mjs +184 -0
- package/firedrill/tools/stripe/lib/wire.mjs +98 -0
- package/firedrill/tools/stripe/ops/billing.mjs +914 -0
- package/firedrill/tools/stripe/ops/catalog.mjs +203 -0
- package/firedrill/tools/stripe/ops/customers.mjs +241 -0
- package/firedrill/tools/stripe/ops/dashboard.mjs +29 -0
- package/firedrill/tools/stripe/ops/payments.mjs +608 -0
- package/firedrill/tools/stripe/stripe.tool.json +28833 -0
- package/firedrill/world.json +8527 -0
- package/firedrill.json +5 -0
- package/package.json +64 -0
- package/starter.json +7999 -0
- package/test/conformance.mjs +1133 -0
|
@@ -0,0 +1,707 @@
|
|
|
1
|
+
// Page-building widgets shared by every screen: headers, sections, key/value grids, tables with cursor
|
|
2
|
+
// pagination, status tabs, pickers, timelines and the loading / empty / error / denied states.
|
|
3
|
+
import { icon } from "./icons.js";
|
|
4
|
+
import { canRead, indexAll, now } from "./store.js";
|
|
5
|
+
import { $, ToolError, action, avatar, button, call, cardChip, dateTime, describe, el, field, input, link, money, moneyCell, nextId, openModal, select, text } from "./ui.js";
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------------------------
|
|
8
|
+
// Layout
|
|
9
|
+
// ---------------------------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
export function pageInner(...children) {
|
|
12
|
+
return el("div", { class: "page-inner" }, children);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function pageHeader(title, { subtitle, actions = [], kind, breadcrumbs } = {}) {
|
|
16
|
+
const heading = el("div", { class: "page-heading" });
|
|
17
|
+
if (breadcrumbs) heading.append(breadcrumbsBar(breadcrumbs));
|
|
18
|
+
if (kind) heading.append(el("div", { class: "object-kind", text: kind }));
|
|
19
|
+
heading.append(typeof title === "string" ? el("h1", { class: "page-title", text: title }) : title);
|
|
20
|
+
if (subtitle) heading.append(typeof subtitle === "string" ? el("p", { class: "page-subtitle", text: subtitle }) : subtitle);
|
|
21
|
+
return el("header", { class: "page-header" }, [heading, el("div", { class: "page-actions" }, actions)]);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function breadcrumbsBar(items) {
|
|
25
|
+
const bar = el("nav", { class: "breadcrumbs", attrs: { "aria-label": "Breadcrumb" } });
|
|
26
|
+
items.forEach((item, index) => {
|
|
27
|
+
if (index > 0) bar.append(icon("chevron-right"));
|
|
28
|
+
bar.append(item.href ? el("a", { text: item.label, attrs: { href: item.href } }) : el("span", { text: item.label }));
|
|
29
|
+
});
|
|
30
|
+
return bar;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function section(title, body, { actions = [], count } = {}) {
|
|
34
|
+
const heading = el("h2", { class: "section-title", text: title });
|
|
35
|
+
if (count !== undefined) heading.append(el("span", { class: "section-count", text: String(count) }));
|
|
36
|
+
return el("section", { class: "section" }, [el("div", { class: "section-header" }, [heading, el("div", { class: "section-actions" }, actions)]), ...(Array.isArray(body) ? body : [body])]);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Key/value grid; `rows` = [[label, valueNodeOrText], …]; null values render as "—". */
|
|
40
|
+
export function kv(rows) {
|
|
41
|
+
const grid = el("dl", { class: "kv" });
|
|
42
|
+
for (const [label, value, options = {}] of rows) {
|
|
43
|
+
grid.append(el("dt", { class: "kv-label", text: label }));
|
|
44
|
+
const cell = el("dd", { class: `kv-value ${options.muted ? "muted" : ""}`.trim() });
|
|
45
|
+
if (value === null || value === undefined || value === "") cell.append(el("span", { class: "muted", text: "—" }));
|
|
46
|
+
else if (typeof value === "string" || typeof value === "number") cell.append(text(value));
|
|
47
|
+
else cell.append(value);
|
|
48
|
+
grid.append(cell);
|
|
49
|
+
}
|
|
50
|
+
return grid;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function metaBar(items) {
|
|
54
|
+
return el("div", { class: "meta-bar" }, items.filter(Boolean).map(([label, value]) => el("div", { class: "meta-item" }, [el("span", { class: "meta-label", text: label }), el("span", { class: "meta-value" }, typeof value === "string" ? text(value) : value)])));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function alert(message, tone = "neutral", { title, actions = [], iconName } = {}) {
|
|
58
|
+
const glyph = iconName ?? (tone === "danger" ? "x-circle" : tone === "warning" ? "alert-triangle" : tone === "success" ? "check-circle" : "info");
|
|
59
|
+
const body = el("div", { class: "alert-body" });
|
|
60
|
+
if (title) body.append(el("div", { class: "alert-title", text: title }));
|
|
61
|
+
body.append(typeof message === "string" ? el("div", { class: "alert-text", text: message }) : message);
|
|
62
|
+
const element = el("div", { class: `alert alert-${tone}`, attrs: { role: tone === "danger" ? "alert" : "status" } }, [icon(glyph), body]);
|
|
63
|
+
if (actions.length > 0) element.append(el("div", { class: "btn-group" }, actions));
|
|
64
|
+
return element;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function loading(label = "Loading…") {
|
|
68
|
+
return el("div", { class: "list-loading" }, [icon("spinner", "spin"), el("span", { text: label })]);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function empty(title, body, actionButton) {
|
|
72
|
+
return el("div", { class: "empty" }, [el("div", { class: "empty-title", text: title }), body ? el("div", { text: body }) : null, actionButton ?? null]);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Panel shown where the key lacks a permission group or the framework denied the operation. */
|
|
76
|
+
export function deniedPanel(error, group) {
|
|
77
|
+
const permission = error instanceof ToolError && error.is("PERMISSION_DENIED");
|
|
78
|
+
const level = error?.details?.level ?? "read";
|
|
79
|
+
const title = permission ? `This key does not have ${group ?? "the required"} permission` : "This key is not granted this operation";
|
|
80
|
+
const body = permission
|
|
81
|
+
? `A restricted key needs the '${group ?? "…"}: ${level}' permission to ${level === "write" ? "do this" : "view this section"}. Ask the account owner for a key with more access.`
|
|
82
|
+
: "The calling actor was not granted this operation in this Firedrill world. Add the grant to the actor to continue.";
|
|
83
|
+
return el("div", { class: "denied-panel" }, [icon("alert-circle"), el("div", { class: "denied-title", text: title }), el("div", { text: body })]);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function errorPanel(error, retry) {
|
|
87
|
+
const actions = retry ? [button("Retry", "secondary", { onClick: retry })] : [];
|
|
88
|
+
if (error instanceof ToolError && (error.denied || error.is("PERMISSION_DENIED"))) return deniedPanel(error, error.details?.group);
|
|
89
|
+
if (error instanceof ToolError && error.is("API_ERROR")) return alert("Stripe is temporarily unavailable (503). Nothing was changed.", "danger", { title: "Something went wrong", actions });
|
|
90
|
+
if (error instanceof ToolError && error.is("RATE_LIMITED")) return alert("Too many requests (429). Wait a moment and try again.", "warning", { title: "Rate limited", actions });
|
|
91
|
+
if (error instanceof ToolError && error.is("RESOURCE_MISSING")) return alert(error.message, "neutral", { title: "Not found", actions });
|
|
92
|
+
return alert(describe(error), "danger", { title: "Could not load this page", actions });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Render `task()` into `host` with loading / error states; returns the promise. */
|
|
96
|
+
export async function load(host, task, { retry } = {}) {
|
|
97
|
+
host.replaceChildren(loading());
|
|
98
|
+
try {
|
|
99
|
+
const content = await task();
|
|
100
|
+
host.replaceChildren(...(Array.isArray(content) ? content : [content]));
|
|
101
|
+
} catch (error) {
|
|
102
|
+
host.replaceChildren(errorPanel(error, retry));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ---------------------------------------------------------------------------------------------
|
|
107
|
+
// Tables with cursor pagination
|
|
108
|
+
// ---------------------------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Build a table. `columns`: [{ label, class, render(row) → node|string, key }]. `onOpen(row)` makes rows clickable
|
|
112
|
+
* (Enter/Space too); `href(row)` adds a real link on the first cell.
|
|
113
|
+
*/
|
|
114
|
+
export function table({ columns, rows, onOpen, href, rowClass, stack = true, emptyNode, select }) {
|
|
115
|
+
const wrap = el("div", { class: `table-wrap ${stack ? "stack" : ""}`.trim() });
|
|
116
|
+
const element = el("table", { class: `table ${select ? "has-select" : ""}`.trim() });
|
|
117
|
+
const head = el("tr");
|
|
118
|
+
const boxes = [];
|
|
119
|
+
let headBox;
|
|
120
|
+
let bulkBar;
|
|
121
|
+
const refreshBulk = () => {
|
|
122
|
+
const chosen = boxes.filter((box) => box.checked);
|
|
123
|
+
headBox.checked = chosen.length > 0 && chosen.length === boxes.length;
|
|
124
|
+
headBox.indeterminate = chosen.length > 0 && chosen.length < boxes.length;
|
|
125
|
+
bulkBar.hidden = chosen.length === 0;
|
|
126
|
+
bulkBar.firstChild.textContent = `${chosen.length} selected`;
|
|
127
|
+
};
|
|
128
|
+
if (select) {
|
|
129
|
+
headBox = el("input", { attrs: { type: "checkbox", "aria-label": `Select all ${select.label ?? "rows"}` } });
|
|
130
|
+
headBox.addEventListener("change", () => {
|
|
131
|
+
for (const box of boxes) box.checked = headBox.checked;
|
|
132
|
+
refreshBulk();
|
|
133
|
+
});
|
|
134
|
+
head.append(el("th", { class: "col-select", attrs: { scope: "col" } }, headBox));
|
|
135
|
+
}
|
|
136
|
+
for (const column of columns) head.append(el("th", { class: column.class, text: column.label, attrs: { scope: "col" } }));
|
|
137
|
+
element.append(el("thead", {}, head));
|
|
138
|
+
const body = el("tbody");
|
|
139
|
+
if (rows.length === 0 && emptyNode) {
|
|
140
|
+
wrap.append(emptyNode);
|
|
141
|
+
return wrap;
|
|
142
|
+
}
|
|
143
|
+
for (const row of rows) {
|
|
144
|
+
const tr = el("tr", { class: rowClass?.(row), attrs: { tabindex: onOpen ? 0 : undefined } });
|
|
145
|
+
if (select) {
|
|
146
|
+
const box = el("input", { attrs: { type: "checkbox", "aria-label": `Select ${select.rowLabel ? select.rowLabel(row) : "row"}` } });
|
|
147
|
+
box.addEventListener("change", refreshBulk);
|
|
148
|
+
boxes.push(box);
|
|
149
|
+
tr.append(el("td", { class: "col-select" }, box));
|
|
150
|
+
}
|
|
151
|
+
columns.forEach((column, index) => {
|
|
152
|
+
const value = column.render(row);
|
|
153
|
+
const cell = el("td", { class: column.class, attrs: { "data-label": column.label } });
|
|
154
|
+
if (index === 0 && href) {
|
|
155
|
+
const target = href(row);
|
|
156
|
+
const anchor = el("a", { class: "row-link", attrs: { href: target } });
|
|
157
|
+
anchor.append(typeof value === "string" ? text(value) : value);
|
|
158
|
+
cell.append(anchor);
|
|
159
|
+
} else if (value === null || value === undefined) cell.append(el("span", { class: "muted", text: "—" }));
|
|
160
|
+
else cell.append(typeof value === "string" || typeof value === "number" ? text(value) : value);
|
|
161
|
+
tr.append(cell);
|
|
162
|
+
});
|
|
163
|
+
if (onOpen) {
|
|
164
|
+
tr.addEventListener("click", (event) => {
|
|
165
|
+
if (event.target.closest("button, a, input, select, .menu")) return;
|
|
166
|
+
onOpen(row);
|
|
167
|
+
});
|
|
168
|
+
tr.addEventListener("keydown", (event) => {
|
|
169
|
+
if ((event.key === "Enter" || event.key === " ") && event.target === tr) {
|
|
170
|
+
event.preventDefault();
|
|
171
|
+
onOpen(row);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
} else tr.classList.add("static");
|
|
175
|
+
body.append(tr);
|
|
176
|
+
}
|
|
177
|
+
element.append(body);
|
|
178
|
+
wrap.append(element);
|
|
179
|
+
if (select) {
|
|
180
|
+
const actions = (select.actions ?? ["Export selected", "Edit", "Delete"]).map((label) => {
|
|
181
|
+
const button = el("button", { class: "btn btn-secondary btn-sm", text: label, title: `${label} (not simulated)`, attrs: { type: "button" } });
|
|
182
|
+
button.addEventListener("click", () => notSimulated(label, "Bulk actions on selected rows are not simulated by this Tool."));
|
|
183
|
+
return button;
|
|
184
|
+
});
|
|
185
|
+
bulkBar = el("div", { class: "bulk-bar", attrs: { role: "status", hidden: "" } }, [el("span", { class: "bulk-count", text: "0 selected" }), ...actions]);
|
|
186
|
+
bulkBar.hidden = true;
|
|
187
|
+
wrap.append(bulkBar);
|
|
188
|
+
}
|
|
189
|
+
return wrap;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Cursor-paginated list bound to a Tool list operation. `options`: { operation, args, limit, table (rows → node),
|
|
194
|
+
* emptyNode, host, pageSizeLabel }. Returns { element, reload }. Previous/Next use `ending_before`/`starting_after`.
|
|
195
|
+
*/
|
|
196
|
+
export function pagedList({ operation, args = {}, limit = 20, render, emptyNode, filterNote, transform }) {
|
|
197
|
+
const host = el("div", { class: "paged-list" });
|
|
198
|
+
const body = el("div", { class: "paged-body" });
|
|
199
|
+
const pager = el("div", { class: "pager" });
|
|
200
|
+
host.append(body, pager);
|
|
201
|
+
const state = { cursors: [], direction: "forward", items: [], hasMore: false, error: undefined };
|
|
202
|
+
|
|
203
|
+
async function fetchPage() {
|
|
204
|
+
body.replaceChildren(loading());
|
|
205
|
+
pager.replaceChildren();
|
|
206
|
+
try {
|
|
207
|
+
const cursor = state.cursors[state.cursors.length - 1];
|
|
208
|
+
const request = { ...args, limit };
|
|
209
|
+
if (cursor?.after) request.starting_after = cursor.after;
|
|
210
|
+
if (cursor?.before) request.ending_before = cursor.before;
|
|
211
|
+
const page = await call(operation, request);
|
|
212
|
+
let items = page.data;
|
|
213
|
+
if (transform) items = await transform(items);
|
|
214
|
+
state.items = items;
|
|
215
|
+
state.hasMore = page.has_more;
|
|
216
|
+
state.error = undefined;
|
|
217
|
+
renderPage();
|
|
218
|
+
} catch (error) {
|
|
219
|
+
state.error = error;
|
|
220
|
+
body.replaceChildren(errorPanel(error, () => void fetchPage()));
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function renderPage() {
|
|
225
|
+
const content = state.items.length === 0 ? (emptyNode?.() ?? empty("No results")) : render(state.items);
|
|
226
|
+
body.replaceChildren(content);
|
|
227
|
+
const count = el("span", { class: "pager-count", text: state.items.length === 0 ? "" : `${state.items.length} result${state.items.length === 1 ? "" : "s"}${state.hasMore || state.cursors.length > 0 ? " on this page" : ""}` });
|
|
228
|
+
if (filterNote) count.append(el("span", { class: "muted", text: ` · ${filterNote}` }));
|
|
229
|
+
const previous = button("Previous", "secondary", {
|
|
230
|
+
size: "sm",
|
|
231
|
+
disabled: state.cursors.length === 0,
|
|
232
|
+
onClick: () => {
|
|
233
|
+
state.cursors.pop();
|
|
234
|
+
void fetchPage();
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
const canNext = state.hasMore || (state.cursors.length > 0 && state.cursors[state.cursors.length - 1].before !== undefined);
|
|
238
|
+
const next = button("Next", "secondary", {
|
|
239
|
+
size: "sm",
|
|
240
|
+
disabled: !canNext || state.items.length === 0,
|
|
241
|
+
onClick: () => {
|
|
242
|
+
const last = state.items[state.items.length - 1];
|
|
243
|
+
state.cursors.push({ after: last.id });
|
|
244
|
+
void fetchPage();
|
|
245
|
+
},
|
|
246
|
+
});
|
|
247
|
+
pager.replaceChildren(count, el("div", { class: "btn-group" }, [previous, next]));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
void fetchPage();
|
|
251
|
+
return { element: host, reload: () => void fetchPage(), state };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Client-side paginated list over a bounded index (used where the provider offers no status filter). */
|
|
255
|
+
export function indexedList({ items, complete, pageSize = 20, render, emptyNode, note }) {
|
|
256
|
+
const host = el("div", { class: "paged-list" });
|
|
257
|
+
const body = el("div");
|
|
258
|
+
const pager = el("div", { class: "pager" });
|
|
259
|
+
host.append(body, pager);
|
|
260
|
+
let page = 0;
|
|
261
|
+
function draw() {
|
|
262
|
+
const slice = items.slice(page * pageSize, (page + 1) * pageSize);
|
|
263
|
+
body.replaceChildren(slice.length === 0 ? (emptyNode?.() ?? empty("No results")) : render(slice));
|
|
264
|
+
const label = items.length === 0 ? "" : `${items.length}${complete ? "" : "+"} result${items.length === 1 ? "" : "s"}`;
|
|
265
|
+
const count = el("span", { class: "pager-count", text: label });
|
|
266
|
+
if (note) count.append(el("span", { class: "muted", text: ` · ${note}` }));
|
|
267
|
+
if (!complete) count.append(el("span", { class: "muted", text: " · index bounded at 1,000 records" }));
|
|
268
|
+
const previous = button("Previous", "secondary", { size: "sm", disabled: page === 0, onClick: () => { page -= 1; draw(); } });
|
|
269
|
+
const next = button("Next", "secondary", { size: "sm", disabled: (page + 1) * pageSize >= items.length, onClick: () => { page += 1; draw(); } });
|
|
270
|
+
pager.replaceChildren(count, el("div", { class: "btn-group" }, [previous, next]));
|
|
271
|
+
}
|
|
272
|
+
draw();
|
|
273
|
+
return host;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Note shown wherever a bounded index (see store.indexAll) stopped before the end: never truncate silently. */
|
|
277
|
+
export function boundNote(index, noun, href) {
|
|
278
|
+
if (index.complete) return null;
|
|
279
|
+
return el("p", { class: "muted bound-note", attrs: { "data-bound": "true" } }, [text(`Showing the first ${index.items.length.toLocaleString("en-US")} ${noun}; more exist.`), href ? text(" ") : null, href ? link(href, `View all ${noun}`, { class: "link" }) : null]);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Server-paged merge of several newest-first list operations (e.g. charges and refunds). Each stream keeps its own
|
|
284
|
+
* `starting_after` cursor, so every record of every stream is reachable: no index bound. `streams`: [{ operation,
|
|
285
|
+
* args, map(record) → { at, … } }]. Previous restores the snapshot taken before Next.
|
|
286
|
+
*/
|
|
287
|
+
export function mergedList({ streams, pageSize = 20, render, emptyNode, note }) {
|
|
288
|
+
const host = el("div", { class: "paged-list" });
|
|
289
|
+
const body = el("div", { class: "paged-body" });
|
|
290
|
+
const pager = el("div", { class: "pager" });
|
|
291
|
+
host.append(body, pager);
|
|
292
|
+
const initial = () => streams.map(() => ({ buffer: [], after: undefined, exhausted: false }));
|
|
293
|
+
let cursor = initial();
|
|
294
|
+
const history = [];
|
|
295
|
+
let rows = [];
|
|
296
|
+
let nextCursor = cursor;
|
|
297
|
+
|
|
298
|
+
const clone = (states) => states.map((state) => ({ buffer: [...state.buffer], after: state.after, exhausted: state.exhausted }));
|
|
299
|
+
async function fill(states, index) {
|
|
300
|
+
const state = states[index];
|
|
301
|
+
if (state.buffer.length > 0 || state.exhausted) return;
|
|
302
|
+
const stream = streams[index];
|
|
303
|
+
const page = await call(stream.operation, { ...stream.args, limit: pageSize, ...(state.after === undefined ? {} : { starting_after: state.after }) });
|
|
304
|
+
state.buffer = page.data.map((record) => stream.map(record));
|
|
305
|
+
if (page.data.length > 0) state.after = page.data[page.data.length - 1].id;
|
|
306
|
+
state.exhausted = !page.has_more || page.data.length === 0;
|
|
307
|
+
}
|
|
308
|
+
async function fetchPage() {
|
|
309
|
+
body.replaceChildren(loading());
|
|
310
|
+
pager.replaceChildren();
|
|
311
|
+
try {
|
|
312
|
+
const states = clone(cursor);
|
|
313
|
+
const picked = [];
|
|
314
|
+
while (picked.length < pageSize) {
|
|
315
|
+
for (let index = 0; index < states.length; index += 1) await fill(states, index);
|
|
316
|
+
let best = -1;
|
|
317
|
+
states.forEach((state, index) => {
|
|
318
|
+
if (state.buffer.length > 0 && (best === -1 || state.buffer[0].at > states[best].buffer[0].at)) best = index;
|
|
319
|
+
});
|
|
320
|
+
if (best === -1) break;
|
|
321
|
+
picked.push(states[best].buffer.shift());
|
|
322
|
+
}
|
|
323
|
+
for (let index = 0; index < states.length; index += 1) await fill(states, index);
|
|
324
|
+
rows = picked;
|
|
325
|
+
nextCursor = states;
|
|
326
|
+
draw();
|
|
327
|
+
} catch (error) {
|
|
328
|
+
body.replaceChildren(errorPanel(error, () => void fetchPage()));
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
function draw() {
|
|
332
|
+
const hasMore = nextCursor.some((state) => state.buffer.length > 0);
|
|
333
|
+
body.replaceChildren(rows.length === 0 ? (emptyNode?.() ?? empty("No results")) : render(rows));
|
|
334
|
+
const count = el("span", { class: "pager-count", text: rows.length === 0 ? "" : `${rows.length} result${rows.length === 1 ? "" : "s"}${hasMore || history.length > 0 ? " on this page" : ""}` });
|
|
335
|
+
if (note) count.append(el("span", { class: "muted", text: ` · ${note}` }));
|
|
336
|
+
const previous = button("Previous", "secondary", { size: "sm", disabled: history.length === 0, onClick: () => { cursor = history.pop(); void fetchPage(); } });
|
|
337
|
+
const next = button("Next", "secondary", { size: "sm", disabled: !hasMore, onClick: () => { history.push(cursor); cursor = nextCursor; void fetchPage(); } });
|
|
338
|
+
pager.replaceChildren(count, el("div", { class: "btn-group" }, [previous, next]));
|
|
339
|
+
}
|
|
340
|
+
void fetchPage();
|
|
341
|
+
return { element: host, reload: () => void fetchPage() };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Status "stat tabs" (label + count) the list pages show above their tables. */
|
|
345
|
+
export function statTabs(tabs, selected, onSelect) {
|
|
346
|
+
const bar = el("div", { class: "stat-tabs", attrs: { role: "tablist" } });
|
|
347
|
+
for (const tab of tabs) {
|
|
348
|
+
const element = el("button", { class: `stat-tab ${tab.notSimulated ? "is-not-simulated" : ""}`.trim(), title: tab.title, attrs: { type: "button", role: "tab", "aria-selected": String(tab.key === selected) } }, [el("span", { class: "stat-tab-label", text: tab.label }), el("span", { class: "stat-tab-count", text: tab.count === undefined ? "–" : String(tab.count) })]);
|
|
349
|
+
if (tab.notSimulated) {
|
|
350
|
+
element.title = tab.title ?? `${tab.label} (not simulated)`;
|
|
351
|
+
element.addEventListener("click", () => notSimulated(tab.label, tab.notSimulated));
|
|
352
|
+
} else element.addEventListener("click", () => onSelect(tab.key));
|
|
353
|
+
bar.append(element);
|
|
354
|
+
}
|
|
355
|
+
return bar;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export function tabs(items, selected, onSelect) {
|
|
359
|
+
const bar = el("div", { class: "tabs", attrs: { role: "tablist" } });
|
|
360
|
+
for (const item of items) {
|
|
361
|
+
const element = el("button", { class: "tab", text: item.label, attrs: { type: "button", role: "tab", "aria-selected": String(item.key === selected) } });
|
|
362
|
+
if (item.notSimulated) {
|
|
363
|
+
element.title = `${item.label} (not simulated)`;
|
|
364
|
+
element.addEventListener("click", () => notSimulated(item.label, item.notSimulated));
|
|
365
|
+
} else element.addEventListener("click", () => onSelect(item.key));
|
|
366
|
+
bar.append(element);
|
|
367
|
+
}
|
|
368
|
+
return bar;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Dashboard filter chip: a dashed pill with a circled plus while unset; once set it turns solid and shows
|
|
373
|
+
* "(x) Label | value" where the circled x clears it.
|
|
374
|
+
*/
|
|
375
|
+
export function chip(label, { active = false, value, onClick, onClear } = {}) {
|
|
376
|
+
const element = el("button", { class: `chip ${active ? "is-active" : ""}`.trim(), attrs: { type: "button", "aria-label": active && value ? `${label}: ${value}` : `Filter by ${label.toLowerCase()}` } });
|
|
377
|
+
const glyph = icon(active ? "x-circle-outline" : "plus-circle", "chip-glyph");
|
|
378
|
+
element.append(glyph, el("span", { class: "chip-label", text: label }));
|
|
379
|
+
if (active && value) element.append(el("span", { class: "chip-sep", attrs: { "aria-hidden": "true" } }), el("span", { class: "chip-value", text: value }), icon("chevron-down", "chip-caret"));
|
|
380
|
+
if (onClick) element.addEventListener("click", (event) => {
|
|
381
|
+
if (active && onClear && event.target.closest(".chip-glyph")) {
|
|
382
|
+
event.stopPropagation();
|
|
383
|
+
onClear();
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
onClick(event);
|
|
387
|
+
});
|
|
388
|
+
return element;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Short panel for a Dashboard control whose feature this Tool does not simulate. It never shows invented data.
|
|
393
|
+
*/
|
|
394
|
+
export function notSimulated(feature, detail) {
|
|
395
|
+
openModal({
|
|
396
|
+
title: feature,
|
|
397
|
+
size: "modal-sm",
|
|
398
|
+
body: [
|
|
399
|
+
el("div", { class: "not-simulated" }, [icon("flask", "not-simulated-icon"), el("div", {}, [el("p", { class: "modal-text", text: `${feature} is not simulated by this Tool.` }), el("p", { class: "muted", text: detail ?? "This synthetic Stripe account only models customers, payment methods, payments, refunds, the product catalog, invoices, subscriptions and the balance. The control is shown so the Dashboard looks the way you know it." })])]),
|
|
400
|
+
],
|
|
401
|
+
actions: [{ label: "Close", kind: "secondary" }],
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** Wire a control to the not-simulated panel and give it the matching tooltip. */
|
|
406
|
+
export function markNotSimulated(control, feature, detail) {
|
|
407
|
+
control.title = control.title || `${feature} (not simulated)`;
|
|
408
|
+
control.dataset.notSimulated = "true";
|
|
409
|
+
control.addEventListener("click", (event) => {
|
|
410
|
+
event.preventDefault();
|
|
411
|
+
event.stopPropagation();
|
|
412
|
+
notSimulated(feature, detail);
|
|
413
|
+
});
|
|
414
|
+
return control;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** Right-hand list toolbar the Dashboard shows beside the filter chips (Export, Edit columns, …). */
|
|
418
|
+
export function listTools(extra = []) {
|
|
419
|
+
const tools = el("div", { class: "list-tools" });
|
|
420
|
+
for (const [label, glyph, detail] of [...extra, ["Export", "download", "Exports (CSV downloads and scheduled reports) are not produced by this synthetic account."], ["Edit columns", "columns", "Column customisation is not simulated; the table shows the Dashboard's default columns."]]) {
|
|
421
|
+
tools.append(markNotSimulated(button(label, "secondary", { size: "sm", icon: glyph }), label, detail));
|
|
422
|
+
}
|
|
423
|
+
return tools;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// ---------------------------------------------------------------------------------------------
|
|
427
|
+
// Cells
|
|
428
|
+
// ---------------------------------------------------------------------------------------------
|
|
429
|
+
|
|
430
|
+
export function customerCell(customer, { showAvatar = false } = {}) {
|
|
431
|
+
if (!customer) return el("span", { class: "muted", text: "Guest" });
|
|
432
|
+
if (typeof customer === "string") return link(`#/customers/${customer}`, customer, { class: "link-quiet" });
|
|
433
|
+
const name = customer.name || customer.email || customer.id;
|
|
434
|
+
const nodes = [el("span", { class: "customer-cell-text" }, [el("span", { class: "truncate", text: name }), customer.name && customer.email ? el("span", { class: "customer-cell-sub", text: customer.email }) : null])];
|
|
435
|
+
const anchor = link(`#/customers/${customer.id}`, "", { class: "link-quiet" });
|
|
436
|
+
anchor.append(el("span", { class: "customer-cell" }, showAvatar ? [avatar(name, customer.id, "sm"), ...nodes] : nodes));
|
|
437
|
+
return anchor;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
export function dateCell(seconds) {
|
|
441
|
+
return el("span", { class: "nowrap muted", text: dateTime(seconds, now()) });
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
export function amountWithStatus(amount, currency, status) {
|
|
445
|
+
return el("span", { class: "amount-status" }, [moneyCell(amount, currency), status]);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// ---------------------------------------------------------------------------------------------
|
|
449
|
+
// Timeline
|
|
450
|
+
// ---------------------------------------------------------------------------------------------
|
|
451
|
+
|
|
452
|
+
/** `events`: [{ title, at (seconds), text, tone, icon }] newest first. */
|
|
453
|
+
export function timeline(events) {
|
|
454
|
+
const list = el("ol", { class: "timeline" });
|
|
455
|
+
for (const event of events) {
|
|
456
|
+
const item = el("li", { class: "timeline-item" });
|
|
457
|
+
item.append(el("span", { class: `timeline-dot ${event.tone ?? ""}`.trim() }, icon(event.icon ?? "dot")));
|
|
458
|
+
item.append(el("div", { class: "timeline-title", text: event.title }));
|
|
459
|
+
if (event.at !== undefined) item.append(el("div", { class: "timeline-time", text: dateTime(event.at, now()) }));
|
|
460
|
+
if (event.text) item.append(el("div", { class: "timeline-text", text: event.text }));
|
|
461
|
+
list.append(item);
|
|
462
|
+
}
|
|
463
|
+
return list;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// ---------------------------------------------------------------------------------------------
|
|
467
|
+
// Pickers
|
|
468
|
+
// ---------------------------------------------------------------------------------------------
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Searchable customer combobox backed by `customers.list` (bounded index). `options.value` preselects an id.
|
|
472
|
+
* Returns the field element with `.value` (customer id or "") and `.customer` (the record).
|
|
473
|
+
*/
|
|
474
|
+
export function customerPicker({ value, onChange, placeholder = "Find or add a customer…" } = {}) {
|
|
475
|
+
const box = el("div", { class: "combobox" });
|
|
476
|
+
const control = input({ placeholder });
|
|
477
|
+
const listHost = el("div", { class: "combobox-list", attrs: { role: "listbox" } });
|
|
478
|
+
listHost.hidden = true;
|
|
479
|
+
box.append(control, listHost);
|
|
480
|
+
box.value = value ?? "";
|
|
481
|
+
box.customer = undefined;
|
|
482
|
+
let customers = [];
|
|
483
|
+
let loaded = false;
|
|
484
|
+
let complete = true;
|
|
485
|
+
let lookup = 0;
|
|
486
|
+
let active = -1;
|
|
487
|
+
|
|
488
|
+
async function ensure() {
|
|
489
|
+
if (loaded) return;
|
|
490
|
+
try {
|
|
491
|
+
const index = await indexAll("customers.list", {});
|
|
492
|
+
customers = index.items;
|
|
493
|
+
complete = index.complete;
|
|
494
|
+
loaded = true;
|
|
495
|
+
if (box.value && !box.customer) {
|
|
496
|
+
const match = customers.find((customer) => customer.id === box.value);
|
|
497
|
+
if (match) choose(match, false);
|
|
498
|
+
else if (!complete) await call("customers.retrieve", { customer: box.value }).then((record) => choose(record, false), () => undefined);
|
|
499
|
+
}
|
|
500
|
+
} catch (error) {
|
|
501
|
+
listHost.replaceChildren(el("div", { class: "combobox-empty", text: describe(error) }));
|
|
502
|
+
listHost.hidden = false;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
function label(customer) {
|
|
506
|
+
return customer.name || customer.email || customer.id;
|
|
507
|
+
}
|
|
508
|
+
function choose(customer, fire = true) {
|
|
509
|
+
box.value = customer.id;
|
|
510
|
+
box.customer = customer;
|
|
511
|
+
control.value = customer.email && customer.name ? `${customer.name} (${customer.email})` : label(customer);
|
|
512
|
+
listHost.hidden = true;
|
|
513
|
+
control.setAttribute("aria-expanded", "false");
|
|
514
|
+
if (fire) onChange?.(customer);
|
|
515
|
+
}
|
|
516
|
+
function draw() {
|
|
517
|
+
const raw = control.value.trim();
|
|
518
|
+
const query = raw.toLowerCase();
|
|
519
|
+
const hits = customers.filter((customer) => !query || label(customer).toLowerCase().includes(query) || (customer.email ?? "").toLowerCase().includes(query) || customer.id.toLowerCase() === query).slice(0, 8);
|
|
520
|
+
const token = (lookup += 1);
|
|
521
|
+
if (hits.length === 0 && !complete && query && (raw.includes("@") || raw.startsWith("cus_"))) {
|
|
522
|
+
// The local index stops at its bound: look the exact e-mail or id up on the server instead of claiming "no match".
|
|
523
|
+
showHits([], "Looking up…");
|
|
524
|
+
const request = raw.startsWith("cus_") ? call("customers.retrieve", { customer: raw }).then((record) => [record]) : call("customers.list", { email: raw, limit: 8 }).then((page) => page.data);
|
|
525
|
+
request.then((found) => { if (token === lookup) showHits(found, "No customer has this e-mail or id"); }, () => { if (token === lookup) showHits([], "No customer has this e-mail or id"); });
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
const bound = `No match among the first ${customers.length.toLocaleString("en-US")} customers; type an exact e-mail or cus_… id`;
|
|
529
|
+
showHits(hits, customers.length === 0 ? "No customers yet" : complete ? "No customers match" : bound);
|
|
530
|
+
if (!complete && hits.length > 0 && !query) listHost.append(el("div", { class: "combobox-empty", text: `Showing matches among the first ${customers.length.toLocaleString("en-US")} customers` }));
|
|
531
|
+
}
|
|
532
|
+
function showHits(hits, emptyText) {
|
|
533
|
+
listHost.replaceChildren();
|
|
534
|
+
if (hits.length === 0) listHost.append(el("div", { class: "combobox-empty", text: emptyText }));
|
|
535
|
+
hits.forEach((customer, index) => {
|
|
536
|
+
const option = el("button", { class: "combobox-option", attrs: { type: "button", role: "option", "aria-selected": String(index === active) } }, [avatar(label(customer), customer.id, "sm"), el("span", { class: "truncate", text: label(customer) }), customer.email ? el("span", { class: "combobox-option-sub", text: customer.email }) : null]);
|
|
537
|
+
option.addEventListener("mousedown", (event) => event.preventDefault());
|
|
538
|
+
option.addEventListener("click", () => choose(customer));
|
|
539
|
+
listHost.append(option);
|
|
540
|
+
});
|
|
541
|
+
listHost.hidden = false;
|
|
542
|
+
control.setAttribute("aria-expanded", "true");
|
|
543
|
+
}
|
|
544
|
+
control.addEventListener("focus", () => void ensure().then(draw));
|
|
545
|
+
control.addEventListener("input", () => {
|
|
546
|
+
box.value = "";
|
|
547
|
+
box.customer = undefined;
|
|
548
|
+
onChange?.(undefined);
|
|
549
|
+
active = -1;
|
|
550
|
+
draw();
|
|
551
|
+
});
|
|
552
|
+
control.addEventListener("blur", () => {
|
|
553
|
+
listHost.hidden = true;
|
|
554
|
+
control.setAttribute("aria-expanded", "false");
|
|
555
|
+
});
|
|
556
|
+
control.addEventListener("keydown", (event) => {
|
|
557
|
+
const options = [...listHost.querySelectorAll(".combobox-option")];
|
|
558
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
559
|
+
if (listHost.hidden) draw();
|
|
560
|
+
active = event.key === "ArrowDown" ? Math.min(active + 1, options.length - 1) : Math.max(active - 1, 0);
|
|
561
|
+
options.forEach((option, index) => option.setAttribute("aria-selected", String(index === active)));
|
|
562
|
+
event.preventDefault();
|
|
563
|
+
} else if (event.key === "Enter" && !listHost.hidden && active >= 0) {
|
|
564
|
+
event.preventDefault();
|
|
565
|
+
options[active]?.click();
|
|
566
|
+
} else if (event.key === "Escape") {
|
|
567
|
+
listHost.hidden = true;
|
|
568
|
+
}
|
|
569
|
+
});
|
|
570
|
+
control.setAttribute("role", "combobox");
|
|
571
|
+
control.setAttribute("aria-expanded", "false");
|
|
572
|
+
control.setAttribute("aria-autocomplete", "list");
|
|
573
|
+
if (value) void ensure();
|
|
574
|
+
box.control = control;
|
|
575
|
+
box.reset = () => {
|
|
576
|
+
box.value = "";
|
|
577
|
+
box.customer = undefined;
|
|
578
|
+
control.value = "";
|
|
579
|
+
};
|
|
580
|
+
return box;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/** Stripe's published test cards, offered wherever a payment method can be chosen. */
|
|
584
|
+
export const TEST_CARDS = Object.freeze([
|
|
585
|
+
{ id: "pm_card_visa", label: "Visa •••• 4242", note: "Succeeds" },
|
|
586
|
+
{ id: "pm_card_visa_debit", label: "Visa debit •••• 5556", note: "Succeeds" },
|
|
587
|
+
{ id: "pm_card_mastercard", label: "Mastercard •••• 4444", note: "Succeeds" },
|
|
588
|
+
{ id: "pm_card_amex", label: "American Express •••• 8431", note: "Succeeds" },
|
|
589
|
+
{ id: "pm_card_chargeDeclined", label: "Visa •••• 0002", note: "Generic decline" },
|
|
590
|
+
{ id: "pm_card_chargeDeclinedInsufficientFunds", label: "Visa •••• 9995", note: "Insufficient funds" },
|
|
591
|
+
{ id: "pm_card_authenticationRequired", label: "Visa •••• 3155", note: "Requires 3D Secure" },
|
|
592
|
+
]);
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Payment-method chooser: the customer's attached cards (from `payment_methods.list`) followed by the test cards.
|
|
596
|
+
* Returns an element with `.value` (a `pm_…` id or "") and `.setCustomer(id)`.
|
|
597
|
+
*/
|
|
598
|
+
export function methodPicker({ customer, value, includeTestCards = true, allowNone = false, noneLabel = "No payment method" } = {}) {
|
|
599
|
+
const host = el("div", { class: "pm-list" });
|
|
600
|
+
host.value = value ?? "";
|
|
601
|
+
let attached = [];
|
|
602
|
+
let attachedComplete = true;
|
|
603
|
+
const name = nextId("pm");
|
|
604
|
+
function optionRow(id, labelNode, note, checked) {
|
|
605
|
+
const radio = el("input", { attrs: { type: "radio", name, value: id } });
|
|
606
|
+
radio.checked = checked;
|
|
607
|
+
radio.addEventListener("change", () => {
|
|
608
|
+
host.value = id;
|
|
609
|
+
});
|
|
610
|
+
const row = el("label", { class: "pm-option" }, [radio, labelNode, note ? el("span", { class: "pm-option-note", text: note }) : null]);
|
|
611
|
+
return row;
|
|
612
|
+
}
|
|
613
|
+
function draw() {
|
|
614
|
+
host.replaceChildren();
|
|
615
|
+
if (allowNone) host.append(optionRow("", el("span", { text: noneLabel }), undefined, host.value === ""));
|
|
616
|
+
if (customer) {
|
|
617
|
+
host.append(el("div", { class: "form-section-title", text: "Customer's saved cards" }));
|
|
618
|
+
if (attached.length === 0) host.append(el("div", { class: "muted", text: "No cards attached to this customer yet." }));
|
|
619
|
+
for (const method of attached) host.append(optionRow(method.id, cardChip(method, { expiry: true }), method.isDefault ? "Default" : undefined, host.value === method.id));
|
|
620
|
+
if (!attachedComplete) host.append(el("div", { class: "muted bound-note", attrs: { "data-bound": "true" }, text: `Showing the first ${attached.length.toLocaleString("en-US")} saved cards of this customer; more exist.` }));
|
|
621
|
+
}
|
|
622
|
+
if (includeTestCards) {
|
|
623
|
+
host.append(el("div", { class: "form-section-title", text: "Test cards" }));
|
|
624
|
+
for (const card of TEST_CARDS) host.append(optionRow(card.id, el("span", { text: card.label }), card.note, host.value === card.id));
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
host.setCustomer = async (id, defaultMethod) => {
|
|
628
|
+
customer = id;
|
|
629
|
+
attached = [];
|
|
630
|
+
attachedComplete = true;
|
|
631
|
+
if (id && canRead("payment_methods")) {
|
|
632
|
+
try {
|
|
633
|
+
const index = await indexAll("payment_methods.list", { customer: id });
|
|
634
|
+
attached = index.items.map((method) => ({ ...method, isDefault: method.id === defaultMethod }));
|
|
635
|
+
attachedComplete = index.complete;
|
|
636
|
+
if (!host.value && defaultMethod && attached.some((method) => method.id === defaultMethod)) host.value = defaultMethod;
|
|
637
|
+
} catch {
|
|
638
|
+
attached = [];
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
draw();
|
|
642
|
+
};
|
|
643
|
+
draw();
|
|
644
|
+
if (customer) void host.setCustomer(customer);
|
|
645
|
+
return host;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/** Amount input with a currency select ("18.90" + USD). Returns element with `.amount()` (minor units | undefined) and `.currency()`. */
|
|
649
|
+
export function amountInput({ amount, currency = "usd", currencies, disabled = false } = {}) {
|
|
650
|
+
const value = input({ placeholder: "0.00", inputmode: "decimal", value: amount ?? "" });
|
|
651
|
+
const currencySelect = select((currencies ?? ["usd", "eur", "gbp", "cad", "aud", "chf", "sek", "nok", "dkk", "jpy", "nzd", "sgd"]).map((code) => ({ value: code, label: code.toUpperCase() })), currency);
|
|
652
|
+
currencySelect.setAttribute("aria-label", "Currency");
|
|
653
|
+
currencySelect.name = "currency";
|
|
654
|
+
if (disabled) {
|
|
655
|
+
value.disabled = true;
|
|
656
|
+
currencySelect.disabled = true;
|
|
657
|
+
}
|
|
658
|
+
const group = el("div", { class: "amount-input" }, [value, currencySelect]);
|
|
659
|
+
group.input = value;
|
|
660
|
+
group.select = currencySelect;
|
|
661
|
+
group.currency = () => currencySelect.value;
|
|
662
|
+
group.amount = () => {
|
|
663
|
+
const cleaned = value.value.replace(/[,\s]/g, "");
|
|
664
|
+
if (!/^-?\d+(\.\d{0,2})?$/.test(cleaned)) return undefined;
|
|
665
|
+
const [whole, fraction = ""] = cleaned.split(".");
|
|
666
|
+
if (currencySelect.value === "jpy") return fraction && Number(fraction) !== 0 ? undefined : Number(whole);
|
|
667
|
+
const negative = whole.startsWith("-");
|
|
668
|
+
return (negative ? -1 : 1) * (Math.abs(Number(whole)) * 100 + Number(fraction.padEnd(2, "0")));
|
|
669
|
+
};
|
|
670
|
+
return group;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/** Field + control shortcut used by the create forms. */
|
|
674
|
+
export function formField(label, control, options) {
|
|
675
|
+
return field(label, control, options);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/** Run a mutation from a modal: shows the busy state, applies field/summary errors, closes on success. */
|
|
679
|
+
export async function submitModal(api, fields, task, onSuccess) {
|
|
680
|
+
api.setError(undefined);
|
|
681
|
+
for (const entry of Object.values(fields)) entry.setError?.(undefined);
|
|
682
|
+
api.setBusy(true);
|
|
683
|
+
try {
|
|
684
|
+
const value = await action(task, { onError: (error) => applyError(error, fields, api) });
|
|
685
|
+
if (value !== undefined) {
|
|
686
|
+
api.close("ok");
|
|
687
|
+
onSuccess?.(value);
|
|
688
|
+
}
|
|
689
|
+
} finally {
|
|
690
|
+
api.setBusy(false);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
export function applyError(error, fields, api) {
|
|
695
|
+
const message = describe(error);
|
|
696
|
+
const param = error instanceof ToolError ? error.param : undefined;
|
|
697
|
+
const base = param ? param.replace(/\[.*$/, "") : undefined;
|
|
698
|
+
if (base && fields[base]?.setError) {
|
|
699
|
+
fields[base].setError(message);
|
|
700
|
+
fields[base].querySelector?.("input, select, textarea")?.focus();
|
|
701
|
+
} else api.setError(message);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
export function moneyText(amount, currency) {
|
|
705
|
+
return money(amount, currency);
|
|
706
|
+
}
|
|
707
|
+
|