@firedrill-tools/trolley 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 +219 -0
- package/firedrill/agent.target.json +16 -0
- package/firedrill/baseline.scenario.json +2109 -0
- package/firedrill/conformance.suite.json +19 -0
- package/firedrill/partner-outage.scenario.json +11 -0
- package/firedrill/processing-response-lost.scenario.json +11 -0
- package/firedrill/rate-limited.scenario.json +11 -0
- package/firedrill/tight-limits.scenario.json +16 -0
- package/firedrill/tools/trolley/app/assets/ATTRIBUTION.md +35 -0
- package/firedrill/tools/trolley/app/assets/cropped-Site-Icon-512x512-px-1-192x192.png +0 -0
- package/firedrill/tools/trolley/app/assets/fonts/OFL.txt +93 -0
- package/firedrill/tools/trolley/app/assets/full-logo.svg +1 -0
- package/firedrill/tools/trolley/app/assets/logo-black.svg +18 -0
- package/firedrill/tools/trolley/app/site/app.js +139 -0
- package/firedrill/tools/trolley/app/site/assets/fonts/inter-latin.woff2 +0 -0
- package/firedrill/tools/trolley/app/site/assets/trolley-icon.png +0 -0
- package/firedrill/tools/trolley/app/site/assets/trolley-logo.svg +1 -0
- package/firedrill/tools/trolley/app/site/base.css +99 -0
- package/firedrill/tools/trolley/app/site/batch-actions.js +139 -0
- package/firedrill/tools/trolley/app/site/components.css +149 -0
- package/firedrill/tools/trolley/app/site/icons.js +50 -0
- package/firedrill/tools/trolley/app/site/index.html +53 -0
- package/firedrill/tools/trolley/app/site/list.js +87 -0
- package/firedrill/tools/trolley/app/site/ui.js +181 -0
- package/firedrill/tools/trolley/app/site/view-balances.js +30 -0
- package/firedrill/tools/trolley/app/site/view-batch.js +86 -0
- package/firedrill/tools/trolley/app/site/view-batches.js +78 -0
- package/firedrill/tools/trolley/app/site/view-dashboard.js +79 -0
- package/firedrill/tools/trolley/app/site/view-methods.js +80 -0
- package/firedrill/tools/trolley/app/site/view-recipient.js +124 -0
- package/firedrill/tools/trolley/app/site/view-recipients.js +115 -0
- package/firedrill/tools/trolley/behavior.mjs +78 -0
- package/firedrill/tools/trolley/lib/access.mjs +21 -0
- package/firedrill/tools/trolley/lib/ids.mjs +28 -0
- package/firedrill/tools/trolley/lib/money.mjs +55 -0
- package/firedrill/tools/trolley/lib/paging.mjs +65 -0
- package/firedrill/tools/trolley/lib/pricing.mjs +88 -0
- package/firedrill/tools/trolley/lib/records.mjs +87 -0
- package/firedrill/tools/trolley/lib/serialize.mjs +152 -0
- package/firedrill/tools/trolley/lib/store.mjs +61 -0
- package/firedrill/tools/trolley/lib/time.mjs +60 -0
- package/firedrill/tools/trolley/lib/validate.mjs +111 -0
- package/firedrill/tools/trolley/lib/wire.mjs +145 -0
- package/firedrill/tools/trolley/ops/accounts-write.mjs +117 -0
- package/firedrill/tools/trolley/ops/accounts.mjs +129 -0
- package/firedrill/tools/trolley/ops/balances.mjs +23 -0
- package/firedrill/tools/trolley/ops/batches.mjs +139 -0
- package/firedrill/tools/trolley/ops/payment-build.mjs +79 -0
- package/firedrill/tools/trolley/ops/payments.mjs +112 -0
- package/firedrill/tools/trolley/ops/processing.mjs +104 -0
- package/firedrill/tools/trolley/ops/recipients-list.mjs +58 -0
- package/firedrill/tools/trolley/ops/recipients.mjs +127 -0
- package/firedrill/tools/trolley/trolley.tool.json +10095 -0
- package/firedrill/trolley-accounts.drill.json +205 -0
- package/firedrill/trolley-batch-lifecycle.drill.json +522 -0
- package/firedrill/trolley-denied.drill.json +74 -0
- package/firedrill/trolley-fresh-install.drill.json +68 -0
- package/firedrill/trolley-invalid-key.drill.json +743 -0
- package/firedrill/trolley-partner-outage.drill.json +97 -0
- package/firedrill/trolley-processing-rules.drill.json +138 -0
- package/firedrill/trolley-rate-limited.drill.json +118 -0
- package/firedrill/trolley-read-only.drill.json +468 -0
- package/firedrill/trolley-recipients.drill.json +183 -0
- package/firedrill/trolley-response-lost.drill.json +127 -0
- package/firedrill/trolley-tight-limits.drill.json +348 -0
- package/firedrill/world.json +2539 -0
- package/firedrill.json +5 -0
- package/package.json +64 -0
- package/starter.json +2108 -0
- package/test/conformance.mjs +12 -0
- package/test/flow-access.mjs +54 -0
- package/test/flow-accounts.mjs +63 -0
- package/test/flow-batches.mjs +107 -0
- package/test/flow-faults.mjs +58 -0
- package/test/flow-processing.mjs +41 -0
- package/test/flow-recipients.mjs +93 -0
- package/test/harness.mjs +87 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// Batch mutations: add/edit/remove payment (recipient search picker), generate quote, review summary, process, delete.
|
|
2
|
+
import { app, go, refreshNow } from "./app.js";
|
|
3
|
+
import { btn, call, confirmDialog, describe, el, field, human, input, money, newKey, openModal, select, showFieldError, toast } from "./ui.js";
|
|
4
|
+
|
|
5
|
+
const CURRENCIES = ["USD", "CAD", "EUR", "GBP", "AUD", "MXN"].map((c) => [c, c]);
|
|
6
|
+
|
|
7
|
+
async function run(operation, args, key, success, reload) {
|
|
8
|
+
try {
|
|
9
|
+
await call(operation, args, key);
|
|
10
|
+
toast(success);
|
|
11
|
+
} catch (error) {
|
|
12
|
+
toast(describe(error), { error: true, timeout: 8000 });
|
|
13
|
+
}
|
|
14
|
+
reload();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function openPaymentForm(batch, payment, reload) {
|
|
18
|
+
let recipient = payment ? payment.recipient : undefined;
|
|
19
|
+
const chosen = el("div", { class: "method-sub", text: recipient ? `Selected: ${recipient.name} (${recipient.email ?? recipient.id})` : "No recipient selected" });
|
|
20
|
+
const results = el("div", { class: "picker-list", attrs: { role: "listbox", "aria-label": "Matching recipients" } });
|
|
21
|
+
const query = input("recipientSearch", "", { type: "search", autocomplete: "off", placeholder: "Type a name, email or reference ID" });
|
|
22
|
+
let timer;
|
|
23
|
+
let seq = 0;
|
|
24
|
+
const search = async () => {
|
|
25
|
+
const mine = (seq += 1);
|
|
26
|
+
const text = query.value.trim();
|
|
27
|
+
try {
|
|
28
|
+
const value = await call("recipients.list", { pageSize: 8, status: "active", ...(text ? { search: text } : {}) });
|
|
29
|
+
if (mine !== seq) return;
|
|
30
|
+
results.replaceChildren(...(value.recipients.length ? value.recipients.map((r) => el("button", { attrs: { type: "button", role: "option", "aria-selected": String(recipient?.id === r.id) }, on: { click: () => { recipient = r; chosen.textContent = `Selected: ${r.name} (${r.email ?? r.id})`; void search(); } } }, [el("strong", { text: r.name }), el("span", { class: "method-sub", text: `${r.email ?? ""} · ${human(r.payoutMethod ?? "")} ${r.primaryCurrency ?? ""}` })]))
|
|
31
|
+
: [el("div", { class: "state", text: "No active recipients match." })]));
|
|
32
|
+
if (value.meta.records > value.recipients.length) results.append(el("div", { class: "menu-note", text: `Showing ${value.recipients.length} of ${value.meta.records}; refine the search.` }));
|
|
33
|
+
} catch (error) {
|
|
34
|
+
if (mine === seq) results.replaceChildren(el("div", { class: "form-error", text: describe(error) }));
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
query.addEventListener("input", () => { clearTimeout(timer); timer = setTimeout(search, 250); });
|
|
38
|
+
const form = el("form", { class: "form-grid", attrs: { novalidate: true } }, [
|
|
39
|
+
payment ? null : el("div", { class: "field-full", attrs: { "data-field": "recipient" } }, [field("Recipient", query, { full: true }), results, chosen, el("div", { class: "field-error" })]),
|
|
40
|
+
field("Amount", input("amount", payment?.sourceAmount ?? "", { inputmode: "decimal", placeholder: "0.00" })),
|
|
41
|
+
field("Currency", select("currency", CURRENCIES, payment?.sourceCurrency ?? batch.currency)),
|
|
42
|
+
field("Memo", input("memo", payment?.memo ?? ""), { full: true }),
|
|
43
|
+
field("External ID", input("externalId", payment?.externalId ?? "")),
|
|
44
|
+
field("Category", select("category", [["", "None"], ...["services", "rent", "royalties", "royalties_film", "prizes", "education", "refunds"].map((c) => [c, human(c)])], payment?.category ?? "")),
|
|
45
|
+
el("label", { class: "field-full method-sub" }, [el("input", { attrs: { type: "checkbox", name: "coverFees", checked: payment?.coverFees ?? false } }), el("span", { text: " Merchant covers the fees" })]),
|
|
46
|
+
]);
|
|
47
|
+
const formError = el("div", { class: "form-error", attrs: { role: "alert" } });
|
|
48
|
+
const save = btn(payment ? "Save payment" : "Add payment", "primary");
|
|
49
|
+
const key = newKey();
|
|
50
|
+
app.editing += 1;
|
|
51
|
+
const modal = openModal({ title: payment ? "Edit payment" : "Add payment", body: [form, formError], wide: true, actions: [btn("Cancel", "secondary", { onClick: () => modal.close() }), save], onClose: () => { app.editing -= 1; } });
|
|
52
|
+
if (!payment) void search();
|
|
53
|
+
save.addEventListener("click", async () => {
|
|
54
|
+
const d = new FormData(form);
|
|
55
|
+
const args = { batchId: batch.id, amount: String(d.get("amount")).trim(), currency: d.get("currency"), coverFees: d.get("coverFees") === "on" };
|
|
56
|
+
for (const name of ["memo", "externalId"]) if (String(d.get(name)).trim()) args[name] = String(d.get(name)).trim();
|
|
57
|
+
if (d.get("category")) args.category = d.get("category");
|
|
58
|
+
if (payment) args.paymentId = payment.id;
|
|
59
|
+
else if (recipient) args.recipient = { id: recipient.id };
|
|
60
|
+
save.disabled = true;
|
|
61
|
+
formError.textContent = "";
|
|
62
|
+
try {
|
|
63
|
+
await call(payment ? "payments.update" : "payments.create", args, key);
|
|
64
|
+
modal.close();
|
|
65
|
+
toast(payment ? "Payment updated." : "Payment added to the batch.");
|
|
66
|
+
reload();
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if (!showFieldError(form, error)) formError.textContent = describe(error);
|
|
69
|
+
save.disabled = false;
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function removePayment(batch, payment, reload) {
|
|
75
|
+
if (await confirmDialog("Remove payment?", `The ${money(payment.sourceAmount, payment.sourceCurrency)} payment to ${payment.recipient?.name ?? "this recipient"} will be removed from the batch.`, "Remove", { danger: true })) {
|
|
76
|
+
await run("payments.delete", { batchId: batch.id, paymentId: payment.id }, newKey(), "Payment removed.", reload);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function generateQuote(batch, reload) {
|
|
81
|
+
await run("batches.generate_quote", { batchId: batch.id }, newKey(), "Quote generated. Process the batch before it expires.", reload);
|
|
82
|
+
await refreshNow();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function reviewSummary(batch) {
|
|
86
|
+
const body = el("div", {}, el("div", { class: "skeleton" }));
|
|
87
|
+
openModal({ title: "Batch review", body, wide: true });
|
|
88
|
+
try {
|
|
89
|
+
const { batchSummary } = await call("batches.summary", { batchId: batch.id });
|
|
90
|
+
const rows = Object.entries(batchSummary.detail);
|
|
91
|
+
body.replaceChildren(rows.length === 0 ? el("p", { text: "This batch has no payments." }) : el("div", { class: "table-wrap" }, el("table", { class: "table" }, [
|
|
92
|
+
el("thead", {}, el("tr", {}, ["Payout method", "Payments", "Sending", "Fees", "Merchant fees", "Debit"].map((h, i) => el("th", { class: i ? "num" : "", text: h, attrs: { scope: "col" } })))),
|
|
93
|
+
el("tbody", {}, rows.map(([method, s]) => el("tr", {}, [el("td", { text: human(method) }), el("td", { class: "num", text: String(s.count) }), el("td", { class: "num", text: money(s.sendingAmount, batch.currency) }), el("td", { class: "num", text: money(s.totalFees, batch.currency) }), el("td", { class: "num", text: money(s.merchantFees, batch.currency) }), el("td", { class: "num", text: money(s.debitAmount, batch.currency) })]))),
|
|
94
|
+
])));
|
|
95
|
+
} catch (error) {
|
|
96
|
+
body.replaceChildren(el("p", { class: "form-error", text: describe(error) }));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function processBatch(batch, reload) {
|
|
101
|
+
let debit = "—";
|
|
102
|
+
let balance = "—";
|
|
103
|
+
try {
|
|
104
|
+
const [{ batchSummary }, balances] = await Promise.all([call("batches.summary", { batchId: batch.id }), call("balances.list", { kind: "paymentrails" })]);
|
|
105
|
+
const cents = Object.values(batchSummary.detail).reduce((sum, s) => sum + Math.round(Number(s.debitAmount) * 100), 0);
|
|
106
|
+
debit = money((cents / 100).toFixed(2), batch.currency);
|
|
107
|
+
const funding = balances.balances.find((b) => b.currency === batch.currency);
|
|
108
|
+
balance = funding ? money(funding.amount, funding.currency) : `no ${batch.currency} balance`;
|
|
109
|
+
} catch (error) {
|
|
110
|
+
toast(describe(error), { error: true });
|
|
111
|
+
}
|
|
112
|
+
const message = el("div", {}, [
|
|
113
|
+
el("p", { text: `You are about to send ${batch.totalPayments} payments from "${batch.description || batch.id}".` }),
|
|
114
|
+
el("dl", { class: "dl" }, [el("dt", { text: "Total debit" }), el("dd", { text: debit }), el("dt", { text: "Available balance" }), el("dd", { text: balance })]),
|
|
115
|
+
el("p", { class: "method-sub", text: "Synthetic processing: balances and payment statuses change in this world only; no money moves." }),
|
|
116
|
+
]);
|
|
117
|
+
if (!(await confirmDialog("Process batch?", message, "Process batch"))) return;
|
|
118
|
+
const key = newKey();
|
|
119
|
+
try {
|
|
120
|
+
await call("batches.start_processing", { batchId: batch.id }, key);
|
|
121
|
+
toast("Batch sent for processing.");
|
|
122
|
+
} catch (error) {
|
|
123
|
+
const unknown = error.is?.("INTERNAL_SERVER_ERROR") || error.is?.("PARTNER_INTEGRATION_ERROR");
|
|
124
|
+
toast(unknown ? "The result is unknown. Refresh before retrying." : describe(error), { error: true, timeout: 10000 });
|
|
125
|
+
}
|
|
126
|
+
reload();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function deleteBatch(batch) {
|
|
130
|
+
if (!(await confirmDialog("Delete batch?", `"${batch.description || batch.id}" and its ${batch.totalPayments} payments will be deleted.`, "Delete", { danger: true }))) return;
|
|
131
|
+
try {
|
|
132
|
+
await call("batches.delete", { batchId: batch.id }, newKey());
|
|
133
|
+
toast("Batch deleted.");
|
|
134
|
+
go("payments");
|
|
135
|
+
} catch (error) {
|
|
136
|
+
toast(describe(error), { error: true });
|
|
137
|
+
go("payments", batch.id);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
.icon-slot { display: inline-flex; }
|
|
2
|
+
.icon-slot svg, .btn svg, .icon-btn svg { width: 18px; height: 18px; }
|
|
3
|
+
.card { background: var(--card); border-radius: var(--radius); box-shadow: var(--shadow); border: 1px solid #ececec; }
|
|
4
|
+
.card-head { display: flex; align-items: center; gap: 12px; padding: 18px 22px; border-bottom: 1px solid var(--line); }
|
|
5
|
+
.card-head h2 { margin: 0; font-size: 18px; font-weight: 500; }
|
|
6
|
+
.card-head .spacer { flex: 1; }
|
|
7
|
+
.card-body { padding: 18px 22px; }
|
|
8
|
+
.grid-3 { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 22px; }
|
|
9
|
+
.grid-2 { display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr); gap: 22px; align-items: start; }
|
|
10
|
+
@media (max-width: 1100px) { .grid-3, .grid-2 { grid-template-columns: 1fr; } }
|
|
11
|
+
.stack { display: flex; flex-direction: column; gap: 22px; }
|
|
12
|
+
|
|
13
|
+
.btn { display: inline-flex; align-items: center; gap: 8px; height: 38px; padding: 0 16px; border-radius: 8px; border: 1px solid transparent; cursor: pointer; font-weight: 500; white-space: nowrap; }
|
|
14
|
+
.btn-sm { height: 30px; padding: 0 10px; font-size: 13px; }
|
|
15
|
+
.btn-primary { background: var(--blue-strong); color: #fff; }
|
|
16
|
+
.btn-primary:hover { background: #0052d6; }
|
|
17
|
+
.btn-dark { background: #181918; color: #fff; text-transform: uppercase; font-size: 12px; letter-spacing: 0.6px; }
|
|
18
|
+
.btn-dark:hover { background: #333; }
|
|
19
|
+
.btn-secondary { background: #fff; border-color: var(--line-2); color: var(--ink); }
|
|
20
|
+
.btn-secondary:hover { background: var(--grey-soft); }
|
|
21
|
+
.btn-danger { background: var(--red); color: #fff; }
|
|
22
|
+
.btn-danger:hover { background: #b93030; }
|
|
23
|
+
.btn-link { background: none; color: var(--blue-strong); padding: 0 6px; }
|
|
24
|
+
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
|
|
25
|
+
.icon-btn { border: 0; background: none; border-radius: 8px; padding: 6px; cursor: pointer; color: var(--muted); display: inline-grid; place-items: center; }
|
|
26
|
+
.icon-btn:hover { background: var(--grey-soft); color: var(--ink); }
|
|
27
|
+
|
|
28
|
+
.pill { display: inline-block; font-size: 12px; font-weight: 500; border-radius: 6px; padding: 3px 8px; line-height: 1.3; white-space: nowrap; }
|
|
29
|
+
.pill-green { background: var(--green-soft); color: var(--green); }
|
|
30
|
+
.pill-blue { background: var(--blue-soft); color: var(--blue-strong); }
|
|
31
|
+
.pill-amber { background: var(--amber-soft); color: var(--amber); }
|
|
32
|
+
.pill-red { background: var(--red-soft); color: var(--red); }
|
|
33
|
+
.pill-grey { background: var(--grey-soft); color: var(--muted); }
|
|
34
|
+
|
|
35
|
+
.toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-bottom: 16px; }
|
|
36
|
+
.toolbar .spacer { flex: 1; }
|
|
37
|
+
.search { position: relative; }
|
|
38
|
+
.search svg { position: absolute; left: 11px; top: 50%; transform: translateY(-50%); width: 16px; height: 16px; color: var(--muted); }
|
|
39
|
+
.search input { padding-left: 34px; width: 280px; }
|
|
40
|
+
.input { height: 38px; border: 1px solid var(--line-2); border-radius: 8px; padding: 0 11px; background: #fff; min-width: 0; }
|
|
41
|
+
textarea.input { height: auto; padding: 9px 11px; }
|
|
42
|
+
.input:focus { border-color: var(--blue); outline: none; box-shadow: 0 0 0 3px rgba(0, 146, 255, 0.18); }
|
|
43
|
+
.tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--line); margin-bottom: 16px; overflow-x: auto; }
|
|
44
|
+
.tab { border: 0; background: none; padding: 10px 14px; cursor: pointer; color: var(--muted); font-weight: 500; border-bottom: 2px solid transparent; margin-bottom: -1px; }
|
|
45
|
+
.tab:hover { color: var(--ink); }
|
|
46
|
+
.tab[aria-selected="true"] { color: var(--ink); border-bottom-color: var(--blue); }
|
|
47
|
+
|
|
48
|
+
.table-wrap { overflow-x: auto; }
|
|
49
|
+
table.table { width: 100%; border-collapse: collapse; }
|
|
50
|
+
.table th { text-align: left; font-weight: 500; font-size: 15px; color: var(--ink-2); padding: 16px 20px; border-bottom: 1px solid var(--line); white-space: nowrap; }
|
|
51
|
+
.table th button { border: 0; background: none; padding: 0; cursor: pointer; font-weight: 500; display: inline-flex; gap: 4px; align-items: center; }
|
|
52
|
+
.table td { padding: 14px 20px; border-bottom: 1px solid var(--line); vertical-align: middle; }
|
|
53
|
+
.table tbody tr:last-child td { border-bottom: 0; }
|
|
54
|
+
.table tbody tr.clickable { cursor: pointer; }
|
|
55
|
+
.table tbody tr.clickable:hover { background: #fafafa; }
|
|
56
|
+
.table .num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
57
|
+
.table .sub { color: var(--muted); font-size: 12px; margin-top: 2px; }
|
|
58
|
+
.who { display: flex; align-items: center; gap: 12px; min-width: 200px; }
|
|
59
|
+
.face { width: 34px; height: 34px; border-radius: 50%; background: #dfe7f1; color: #37506e; display: grid; place-items: center; font-size: 12px; font-weight: 600; flex: none; }
|
|
60
|
+
.face.biz { border-radius: 8px; background: #e7e3f5; color: #4a3d7a; }
|
|
61
|
+
.flag { font-size: 12px; font-weight: 600; color: var(--muted); border: 1px solid var(--line); border-radius: 4px; padding: 1px 5px; }
|
|
62
|
+
.mono { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 12.5px; }
|
|
63
|
+
.pager { display: flex; align-items: center; gap: 12px; padding: 12px 20px; border-top: 1px solid var(--line); color: var(--muted); font-size: 13px; flex-wrap: wrap; }
|
|
64
|
+
.pager .spacer { flex: 1; }
|
|
65
|
+
.pager select { height: 30px; }
|
|
66
|
+
|
|
67
|
+
.state { padding: 48px 24px; text-align: center; color: var(--muted); }
|
|
68
|
+
.state h3 { color: var(--ink); font-weight: 500; margin: 12px 0 6px; font-size: 16px; }
|
|
69
|
+
.state svg { width: 36px; height: 36px; color: var(--line-2); }
|
|
70
|
+
.skeleton { height: 14px; border-radius: 6px; background: linear-gradient(90deg, #eee, #f7f7f7, #eee); background-size: 200% 100%; animation: shimmer 1.2s infinite; }
|
|
71
|
+
@keyframes shimmer { to { background-position: -200% 0; } }
|
|
72
|
+
.banner { display: flex; gap: 10px; align-items: flex-start; padding: 12px 16px; border-radius: 10px; margin-bottom: 16px; }
|
|
73
|
+
.banner svg { width: 18px; height: 18px; flex: none; margin-top: 1px; }
|
|
74
|
+
.banner-info { background: var(--blue-soft); color: #0a3f78; }
|
|
75
|
+
.banner-warn { background: var(--amber-soft); color: #6b4000; }
|
|
76
|
+
.banner-error { background: var(--red-soft); color: #8a1f1f; }
|
|
77
|
+
|
|
78
|
+
.kpi { padding: 22px; }
|
|
79
|
+
.kpi-label { color: var(--ink-2); font-size: 17px; font-weight: 500; }
|
|
80
|
+
.kpi-value { font-size: 34px; font-weight: 600; margin-top: 16px; letter-spacing: -0.5px; font-variant-numeric: tabular-nums; }
|
|
81
|
+
.kpi-sub { color: var(--muted); font-size: 16px; margin-top: 4px; }
|
|
82
|
+
.dl { display: grid; grid-template-columns: 160px 1fr; gap: 10px 16px; margin: 0; }
|
|
83
|
+
.dl dt { color: var(--muted); }
|
|
84
|
+
.dl dd { margin: 0; overflow-wrap: anywhere; }
|
|
85
|
+
.crumbs { display: flex; gap: 6px; align-items: center; color: var(--muted); margin-bottom: 14px; font-size: 13px; }
|
|
86
|
+
.crumbs a { color: var(--muted); text-decoration: none; }
|
|
87
|
+
.crumbs a:hover { color: var(--blue-strong); }
|
|
88
|
+
.profile-head { display: flex; align-items: center; gap: 16px; margin-bottom: 20px; flex-wrap: wrap; }
|
|
89
|
+
.profile-head .face { width: 56px; height: 56px; font-size: 18px; }
|
|
90
|
+
.profile-head h2 { margin: 0; font-size: 22px; font-weight: 600; }
|
|
91
|
+
.profile-head .spacer { flex: 1; }
|
|
92
|
+
.method { display: flex; gap: 14px; align-items: center; padding: 14px 0; border-bottom: 1px solid var(--line); }
|
|
93
|
+
.method:last-child { border-bottom: 0; }
|
|
94
|
+
.method-icon { width: 40px; height: 40px; border-radius: 10px; border: 1px solid var(--line); display: grid; place-items: center; color: #17a38a; }
|
|
95
|
+
.method-main { flex: 1; min-width: 0; }
|
|
96
|
+
.method-title { font-weight: 500; }
|
|
97
|
+
.method-sub { color: var(--muted); font-size: 13px; }
|
|
98
|
+
.choice-row { display: flex; gap: 12px; flex-wrap: wrap; }
|
|
99
|
+
.choice { width: 96px; height: 76px; border: 1px solid var(--line); border-radius: 10px; background: #fff; cursor: pointer; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px; font-size: 12px; font-weight: 500; }
|
|
100
|
+
.choice svg { width: 22px; height: 22px; color: var(--blue-strong); }
|
|
101
|
+
.choice:hover { border-color: var(--line-2); }
|
|
102
|
+
.choice[aria-pressed="true"] { border: 2px solid #17a38a; background: #f2fbf8; }
|
|
103
|
+
.summary-strip { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
|
104
|
+
.summary-strip > div { padding: 18px 22px; border-right: 1px solid var(--line); }
|
|
105
|
+
.summary-strip > div:last-child { border-right: 0; }
|
|
106
|
+
.summary-strip .label { color: var(--muted); font-size: 13px; }
|
|
107
|
+
.summary-strip .value { font-size: 22px; font-weight: 600; margin-top: 6px; font-variant-numeric: tabular-nums; }
|
|
108
|
+
@media (max-width: 760px) { .summary-strip { grid-template-columns: 1fr 1fr; } .search input { width: 100%; } .dl { grid-template-columns: 1fr; } }
|
|
109
|
+
|
|
110
|
+
.overlay { position: fixed; inset: 0; background: rgba(12, 14, 18, 0.45); display: grid; place-items: center; z-index: 60; padding: 16px; }
|
|
111
|
+
.modal { background: #fff; border-radius: 14px; width: min(520px, 100%); max-height: calc(100vh - 32px); display: flex; flex-direction: column; box-shadow: 0 20px 50px rgba(0, 0, 0, 0.25); outline: none; }
|
|
112
|
+
.modal-wide { width: min(720px, 100%); }
|
|
113
|
+
.modal-head { display: flex; align-items: center; padding: 18px 22px; border-bottom: 1px solid var(--line); }
|
|
114
|
+
.modal-head h2 { margin: 0; font-size: 18px; font-weight: 600; flex: 1; }
|
|
115
|
+
.modal-body { padding: 20px 22px; overflow: auto; }
|
|
116
|
+
.modal-body p { margin: 0 0 10px; line-height: 1.5; color: var(--ink-2); }
|
|
117
|
+
.modal-foot { display: flex; justify-content: flex-end; gap: 10px; padding: 14px 22px; border-top: 1px solid var(--line); }
|
|
118
|
+
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px 16px; }
|
|
119
|
+
.field { display: flex; flex-direction: column; gap: 6px; }
|
|
120
|
+
.field-full { grid-column: 1 / -1; }
|
|
121
|
+
.field label { font-size: 13px; font-weight: 500; color: var(--ink-2); }
|
|
122
|
+
.field-hint { font-size: 12px; color: var(--muted); }
|
|
123
|
+
.field-error { font-size: 12px; color: var(--red); min-height: 0; }
|
|
124
|
+
.form-error { color: var(--red); margin-top: 12px; }
|
|
125
|
+
@media (max-width: 600px) { .form-grid { grid-template-columns: 1fr; } }
|
|
126
|
+
|
|
127
|
+
.toast-host { position: fixed; top: 20px; left: 50%; transform: translateX(-50%); display: flex; flex-direction: column; gap: 10px; z-index: 80; }
|
|
128
|
+
.toast { display: flex; align-items: center; gap: 12px; background: #fff; border-radius: 16px; padding: 14px 16px; box-shadow: 0 10px 30px rgba(0, 0, 0, 0.14); min-width: 320px; max-width: 560px; }
|
|
129
|
+
.toast-icon { width: 32px; height: 32px; border-radius: 50%; background: #2fa36b; color: #fff; display: grid; place-items: center; flex: none; }
|
|
130
|
+
.toast-icon svg { width: 18px; height: 18px; }
|
|
131
|
+
.toast-error .toast-icon { background: var(--red); }
|
|
132
|
+
.toast-text { flex: 1; line-height: 1.4; }
|
|
133
|
+
.toast-close { border: 0; background: none; cursor: pointer; color: var(--muted); }
|
|
134
|
+
.toast-close svg { width: 16px; height: 16px; }
|
|
135
|
+
.menu { position: absolute; background: #fff; border: 1px solid var(--line); border-radius: 10px; box-shadow: 0 10px 30px rgba(0, 0, 0, 0.12); padding: 6px; z-index: 70; min-width: 220px; }
|
|
136
|
+
.menu-item { display: block; width: 100%; text-align: left; border: 0; background: none; padding: 9px 10px; border-radius: 6px; cursor: pointer; }
|
|
137
|
+
.menu-item:hover { background: var(--grey-soft); }
|
|
138
|
+
.menu-note { padding: 8px 10px; color: var(--muted); font-size: 12px; border-bottom: 1px solid var(--line); margin-bottom: 4px; }
|
|
139
|
+
.picker-list { border: 1px solid var(--line); border-radius: 8px; max-height: 200px; overflow: auto; margin-top: 6px; }
|
|
140
|
+
.picker-list button { display: flex; width: 100%; gap: 10px; align-items: center; border: 0; background: #fff; padding: 8px 10px; cursor: pointer; text-align: left; }
|
|
141
|
+
.picker-list button:hover, .picker-list button[aria-selected="true"] { background: var(--blue-soft); }
|
|
142
|
+
.spacer-16 { height: 16px; }
|
|
143
|
+
.rail-item .icon-slot svg { width: 24px; height: 24px; }
|
|
144
|
+
.hamburger .icon-slot svg { width: 26px; height: 26px; }
|
|
145
|
+
.bell .icon-slot svg { width: 19px; height: 19px; }
|
|
146
|
+
.row-actions { display: flex; gap: 4px; justify-content: flex-end; }
|
|
147
|
+
.method-icon svg { width: 22px; height: 22px; }
|
|
148
|
+
.method-icon.done { color: #fff; background: #2fa36b; border-color: #2fa36b; border-radius: 50%; }
|
|
149
|
+
.modal-body .dl { margin: 12px 0 14px; }
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Monochrome interface glyphs drawn for this package (24px grid, solid/rounded like the dashboard's rail icons).
|
|
2
|
+
const NS = "http://www.w3.org/2000/svg";
|
|
3
|
+
const PATHS = {
|
|
4
|
+
gauge: "M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Zm3.9 5.2-2.6 5.9a1.6 1.6 0 1 1-2.2-1.4l4.8-4.5Z",
|
|
5
|
+
user: "M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Zm0 4a3.4 3.4 0 1 1 0 6.8A3.4 3.4 0 0 1 12 6Zm0 14a8 8 0 0 1-6-2.7c.9-2 3.2-3.1 6-3.1s5.1 1.1 6 3.1A8 8 0 0 1 12 20Z",
|
|
6
|
+
money: "M3 6h18a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1Zm1.5 2v8h15V8h-15ZM12 9.2a2.8 2.8 0 1 1 0 5.6 2.8 2.8 0 0 1 0-5.6Z",
|
|
7
|
+
receipt: "M5 2h14v20l-2.3-1.5L14.3 22 12 20.5 9.7 22l-2.4-1.5L5 22V2Zm3 5v1.6h8V7H8Zm0 4v1.6h8V11H8Zm0 4v1.6h5V15H8Z",
|
|
8
|
+
coins: "M12 3c4.4 0 8 1.3 8 3s-3.6 3-8 3-8-1.3-8-3 3.6-3 8-3Zm-8 5.4C5.6 9.6 8.6 10.4 12 10.4s6.4-.8 8-2V11c0 1.7-3.6 3-8 3s-8-1.3-8-3V8.4Zm0 5C5.6 14.6 8.6 15.4 12 15.4s6.4-.8 8-2V16c0 1.7-3.6 3-8 3s-8-1.3-8-3v-2.6Zm0 5c1.6 1.2 4.6 2 8 2s6.4-.8 8-2V19c0 1.7-3.6 3-8 3s-8-1.3-8-3v-.6Z",
|
|
9
|
+
calculator: "M6 2h12a2 2 0 0 1 2 2v16a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2Zm1 2.5V8h10V4.5H7ZM7 10v2h2v-2H7Zm4 0v2h2v-2h-2Zm4 0v2h2v-2h-2ZM7 14v2h2v-2H7Zm4 0v2h2v-2h-2Zm4 0v5h2v-5h-2ZM7 18v2h2v-2H7Zm4 0v2h2v-2h-2Z",
|
|
10
|
+
chart: "M3 3h2v16h16v2H3V3Zm15.3 3.3 1.4 1.4-5.2 5.2-3-3-3.8 3.8-1.4-1.4 5.2-5.2 3 3 3.8-3.8Z",
|
|
11
|
+
checkbox: "M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2Zm0 2v14h14V5H5Zm11.3 3.3 1.4 1.4-7.2 7.2-4.2-4.2 1.4-1.4 2.8 2.8 5.8-5.8Z",
|
|
12
|
+
code: "M8.7 6.3 10.1 7.7 5.8 12l4.3 4.3-1.4 1.4L3 12l5.7-5.7Zm6.6 0L21 12l-5.7 5.7-1.4-1.4 4.3-4.3-4.3-4.3 1.4-1.4Z",
|
|
13
|
+
gear: "M13.8 2l.5 2.6c.6.2 1.2.5 1.7.9l2.5-.9 1.8 3.1-2 1.7a7 7 0 0 1 0 1.9l2 1.7-1.8 3.1-2.5-.9c-.5.4-1.1.7-1.7.9l-.5 2.6h-3.6l-.5-2.6a6.5 6.5 0 0 1-1.7-.9l-2.5.9-1.8-3.1 2-1.7a7 7 0 0 1 0-1.9l-2-1.7 1.8-3.1 2.5.9c.5-.4 1.1-.7 1.7-.9l.5-2.6h3.6ZM12 8.6a3.4 3.4 0 1 0 0 6.8 3.4 3.4 0 0 0 0-6.8Z",
|
|
14
|
+
menu: "M3 5h18v2.2H3V5Zm0 5.9h18v2.2H3v-2.2Zm0 5.9h18V19H3v-2.2Z",
|
|
15
|
+
bell: "M12 2a1.5 1.5 0 0 1 1.5 1.5v.7A6 6 0 0 1 18 10v4.6l2 2.4v1H4v-1l2-2.4V10a6 6 0 0 1 4.5-5.8v-.7A1.5 1.5 0 0 1 12 2Zm-2.3 17h4.6a2.3 2.3 0 0 1-4.6 0Z",
|
|
16
|
+
search: "M10.5 3a7.5 7.5 0 0 1 6 12l4.3 4.3-1.5 1.5-4.3-4.3A7.5 7.5 0 1 1 10.5 3Zm0 2a5.5 5.5 0 1 0 0 11 5.5 5.5 0 0 0 0-11Z",
|
|
17
|
+
plus: "M11 4h2v7h7v2h-7v7h-2v-7H4v-2h7V4Z",
|
|
18
|
+
close: "M6.4 5 12 10.6 17.6 5 19 6.4 13.4 12l5.6 5.6-1.4 1.4-5.6-5.6L6.4 19 5 17.6 10.6 12 5 6.4 6.4 5Z",
|
|
19
|
+
chevronLeft: "M15.4 5.4 14 4l-8 8 8 8 1.4-1.4L8.8 12l6.6-6.6Z",
|
|
20
|
+
chevronRight: "M8.6 5.4 10 4l8 8-8 8-1.4-1.4 6.6-6.6-6.6-6.6Z",
|
|
21
|
+
chevronDown: "M5.4 8.6 4 10l8 8 8-8-1.4-1.4L12 15.2 5.4 8.6Z",
|
|
22
|
+
bank: "M12 2 22 7v2H2V7l10-5Zm-7 9h2.5v7H5v-7Zm5.7 0h2.6v7h-2.6v-7Zm5.8 0H19v7h-2.5v-7ZM2 20h20v2H2v-2Z",
|
|
23
|
+
paypal: "M7 3h6.5c3.2 0 5 1.6 4.6 4.4-.5 3.4-2.8 5-6 5H9.8L8.9 18H5.2L7 3Zm2.9 3-.6 3.8h1.6c1.4 0 2.3-.7 2.5-2 .2-1.2-.4-1.8-1.7-1.8H9.9ZM18.9 8.6c.9.8 1.2 2 1 3.4-.5 3.3-2.8 4.9-6 4.9h-1.2l-.8 5.1H8.3l.3-2h2l.8-5.1H13c2.9 0 5.3-1.9 5.9-6.3Z",
|
|
24
|
+
check: "M9.5 16.2 5.3 12l-1.4 1.4 5.6 5.6L20.1 8.4 18.7 7 9.5 16.2Z",
|
|
25
|
+
mail: "M3 5h18a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1Zm1 2.3V17h16V7.3l-8 5.3-8-5.3ZM5.8 7 12 11.1 18.2 7H5.8Z",
|
|
26
|
+
phone: "M8 2h8a2 2 0 0 1 2 2v16a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2Zm0 3v13h8V5H8Zm3 14.2v1.3h2v-1.3h-2Z",
|
|
27
|
+
info: "M12 2a10 10 0 1 1 0 20 10 10 0 0 1 0-20Zm-1 8.5V17h2v-6.5h-2ZM12 6.6a1.3 1.3 0 1 0 0 2.6 1.3 1.3 0 0 0 0-2.6Z",
|
|
28
|
+
lock: "M12 2a5 5 0 0 1 5 5v3h1a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h1V7a5 5 0 0 1 5-5Zm0 2a3 3 0 0 0-3 3v3h6V7a3 3 0 0 0-3-3Z",
|
|
29
|
+
more: "M12 4.5a1.8 1.8 0 1 1 0 3.6 1.8 1.8 0 0 1 0-3.6Zm0 5.7a1.8 1.8 0 1 1 0 3.6 1.8 1.8 0 0 1 0-3.6Zm0 5.7a1.8 1.8 0 1 1 0 3.6 1.8 1.8 0 0 1 0-3.6Z",
|
|
30
|
+
refresh: "M12 4a8 8 0 0 1 7.4 5H22l-3.5 4.5L15 9h2.2A6 6 0 1 0 18 14h2.1A8 8 0 1 1 12 4Z",
|
|
31
|
+
};
|
|
32
|
+
export function icon(name, cls = "") {
|
|
33
|
+
const svg = document.createElementNS(NS, "svg");
|
|
34
|
+
svg.setAttribute("viewBox", "0 0 24 24");
|
|
35
|
+
svg.setAttribute("aria-hidden", "true");
|
|
36
|
+
svg.setAttribute("focusable", "false");
|
|
37
|
+
if (cls) svg.setAttribute("class", cls);
|
|
38
|
+
const path = document.createElementNS(NS, "path");
|
|
39
|
+
path.setAttribute("d", PATHS[name] ?? PATHS.info);
|
|
40
|
+
path.setAttribute("fill", "currentColor");
|
|
41
|
+
svg.append(path);
|
|
42
|
+
return svg;
|
|
43
|
+
}
|
|
44
|
+
export function hydrateIcons(root = document) {
|
|
45
|
+
for (const slot of root.querySelectorAll("[data-icon]")) {
|
|
46
|
+
if (slot.firstChild) continue;
|
|
47
|
+
slot.append(icon(slot.getAttribute("data-icon")));
|
|
48
|
+
slot.classList.add("icon-slot");
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>Trolley Dashboard (synthetic)</title>
|
|
7
|
+
<link rel="icon" type="image/png" href="./assets/trolley-icon.png" />
|
|
8
|
+
<link rel="stylesheet" href="./base.css" />
|
|
9
|
+
<link rel="stylesheet" href="./components.css" />
|
|
10
|
+
<script type="module" src="./app.js"></script>
|
|
11
|
+
</head>
|
|
12
|
+
<body>
|
|
13
|
+
<a class="skip-link" href="#page">Skip to content</a>
|
|
14
|
+
<div class="shell" id="shell">
|
|
15
|
+
<aside class="rail" id="rail" aria-label="Main navigation">
|
|
16
|
+
<a class="rail-brand" href="#/" aria-label="Trolley dashboard home">
|
|
17
|
+
<img class="rail-tile" src="./assets/trolley-icon.png" alt="Trolley" width="40" height="40" />
|
|
18
|
+
<img class="rail-wordmark" src="./assets/trolley-logo.svg" alt="" width="90" height="28" />
|
|
19
|
+
</a>
|
|
20
|
+
<nav class="rail-nav">
|
|
21
|
+
<a class="rail-item" href="#/" data-route="dashboard" data-tip="Overview"><span data-icon="gauge"></span><span class="rail-label">Overview</span></a>
|
|
22
|
+
<a class="rail-item" href="#/recipients" data-route="recipients" data-tip="Recipients"><span data-icon="user"></span><span class="rail-label">Recipients</span></a>
|
|
23
|
+
<a class="rail-item" href="#/payments" data-route="payments" data-tip="Payments"><span data-icon="money"></span><span class="rail-label">Payments</span></a>
|
|
24
|
+
<button type="button" class="rail-item" data-not-simulated="Invoices" data-tip="Invoices"><span data-icon="receipt"></span><span class="rail-label">Invoices</span></button>
|
|
25
|
+
<a class="rail-item" href="#/balances" data-route="balances" data-tip="Balances"><span data-icon="coins"></span><span class="rail-label">Balances</span></a>
|
|
26
|
+
<button type="button" class="rail-item" data-not-simulated="Tax Center" data-tip="Tax Center"><span data-icon="calculator"></span><span class="rail-label">Tax Center</span></button>
|
|
27
|
+
<button type="button" class="rail-item" data-not-simulated="Reports" data-tip="Reports"><span data-icon="chart"></span><span class="rail-label">Reports</span></button>
|
|
28
|
+
<button type="button" class="rail-item" data-not-simulated="Approvals" data-tip="Approvals"><span data-icon="checkbox"></span><span class="rail-label">Approvals</span></button>
|
|
29
|
+
</nav>
|
|
30
|
+
<div class="rail-bottom">
|
|
31
|
+
<button type="button" class="rail-item" data-not-simulated="Developer tools" data-tip="Developers"><span data-icon="code"></span><span class="rail-label">Developers</span></button>
|
|
32
|
+
<button type="button" class="rail-item" data-not-simulated="Settings" data-tip="Settings"><span data-icon="gear"></span><span class="rail-label">Settings</span></button>
|
|
33
|
+
</div>
|
|
34
|
+
</aside>
|
|
35
|
+
<div class="main">
|
|
36
|
+
<header class="topbar">
|
|
37
|
+
<button type="button" class="hamburger" id="menu-toggle" aria-label="Toggle navigation" aria-expanded="false"><span data-icon="menu"></span></button>
|
|
38
|
+
<h1 class="page-title" id="page-title">Overview</h1>
|
|
39
|
+
<div class="topbar-right">
|
|
40
|
+
<button type="button" class="bell" id="bell" aria-label="Notifications (not simulated)" data-not-simulated="Notification feed"><span data-icon="bell"></span></button>
|
|
41
|
+
<div class="whoami">
|
|
42
|
+
<span class="whoami-name" id="whoami-name">…</span>
|
|
43
|
+
<span class="mode-pill" id="mode-pill" title="Synthetic sandbox merchant: no money moves">SANDBOX</span>
|
|
44
|
+
</div>
|
|
45
|
+
<button type="button" class="avatar-btn" id="avatar" aria-haspopup="menu" aria-expanded="false" aria-label="Account menu">·</button>
|
|
46
|
+
</div>
|
|
47
|
+
</header>
|
|
48
|
+
<main class="page" id="page" tabindex="-1"></main>
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
<div class="toast-host" id="toasts" aria-live="polite"></div>
|
|
52
|
+
</body>
|
|
53
|
+
</html>
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Shared list rendering: data table, loading/empty/error/denied states and Trolley's page/pageSize pager.
|
|
2
|
+
import { icon } from "./icons.js";
|
|
3
|
+
import { ToolError, btn, describe, el } from "./ui.js";
|
|
4
|
+
|
|
5
|
+
export function loadingRows(columns, rows = 5) {
|
|
6
|
+
return el("tbody", {}, Array.from({ length: rows }, () => el("tr", {}, columns.map(() => el("td", {}, el("div", { class: "skeleton" }))))));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function stateBlock(glyph, title, message, action) {
|
|
10
|
+
return el("div", { class: "state" }, [icon(glyph), el("h3", { text: title }), message ? el("div", { text: message }) : null, action ? el("div", { class: "state-action" }, action) : null]);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function errorBlock(error, retry) {
|
|
14
|
+
if (error instanceof ToolError && (error.denied || error.is("INVALID_API_KEY"))) {
|
|
15
|
+
return stateBlock("lock", error.is("INVALID_API_KEY") ? "API key is invalid" : "Access denied", describe(error));
|
|
16
|
+
}
|
|
17
|
+
return stateBlock("info", "We couldn't load this", describe(error), retry ? btn("Try again", "secondary", { icon: "refresh", onClick: retry }) : null);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* columns: [{ label, key?, sortable?, num?, cell(row) }]
|
|
22
|
+
* Returns { node, setLoading, setRows, setError }.
|
|
23
|
+
*/
|
|
24
|
+
export function dataTable(columns, { onRowClick, sort, onSort, caption } = {}) {
|
|
25
|
+
const head = el("thead", {}, el("tr", {}, columns.map((column) => {
|
|
26
|
+
if (!column.key || !onSort) return el("th", { class: column.num ? "num" : "", text: column.label, attrs: { scope: "col" } });
|
|
27
|
+
const active = sort?.orderBy === column.key;
|
|
28
|
+
const arrow = active ? (sort.sortBy === "asc" ? " ↑" : " ↓") : "";
|
|
29
|
+
return el("th", { class: column.num ? "num" : "", attrs: { scope: "col", "aria-sort": active ? (sort.sortBy === "asc" ? "ascending" : "descending") : undefined } },
|
|
30
|
+
el("button", { text: `${column.label}${arrow}`, attrs: { type: "button" }, on: { click: () => onSort(column.key) } }));
|
|
31
|
+
})));
|
|
32
|
+
const table = el("table", { class: "table" }, [caption ? el("caption", { class: "skip-link", text: caption }) : null, head, loadingRows(columns)]);
|
|
33
|
+
const wrap = el("div", { class: "table-wrap" }, table);
|
|
34
|
+
const extra = el("div");
|
|
35
|
+
const node = el("div", {}, [wrap, extra]);
|
|
36
|
+
const replaceBody = (body) => { table.tBodies[0]?.remove(); table.append(body); };
|
|
37
|
+
return {
|
|
38
|
+
node,
|
|
39
|
+
setLoading() { extra.replaceChildren(); replaceBody(loadingRows(columns)); },
|
|
40
|
+
setRows(rows, empty) {
|
|
41
|
+
extra.replaceChildren();
|
|
42
|
+
replaceBody(el("tbody", {}, rows.map((row) => {
|
|
43
|
+
const tr = el("tr", { class: onRowClick ? "clickable" : "" }, columns.map((column) => {
|
|
44
|
+
const content = column.cell(row);
|
|
45
|
+
return el("td", { class: column.num ? "num" : "" }, content);
|
|
46
|
+
}));
|
|
47
|
+
if (onRowClick) {
|
|
48
|
+
tr.tabIndex = 0;
|
|
49
|
+
tr.addEventListener("click", (event) => { if (!event.target.closest("button, a")) onRowClick(row); });
|
|
50
|
+
tr.addEventListener("keydown", (event) => { if (event.key === "Enter") onRowClick(row); });
|
|
51
|
+
}
|
|
52
|
+
return tr;
|
|
53
|
+
})));
|
|
54
|
+
if (rows.length === 0 && empty) extra.append(empty);
|
|
55
|
+
},
|
|
56
|
+
setError(error, retry) { replaceBody(el("tbody")); extra.replaceChildren(errorBlock(error, retry)); },
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Trolley meta: { page, pages, records }. */
|
|
61
|
+
export function pager(meta, pageSize, onChange) {
|
|
62
|
+
const { page = 1, pages = 0, records = 0 } = meta ?? {};
|
|
63
|
+
const first = records === 0 ? 0 : (page - 1) * pageSize + 1;
|
|
64
|
+
const last = Math.min(records, page * pageSize);
|
|
65
|
+
const sizeSelect = el("select", { class: "input", attrs: { "aria-label": "Rows per page" }, on: { change: (event) => onChange({ page: 1, pageSize: Number(event.target.value) }) } },
|
|
66
|
+
[10, 25, 50, 100].map((size) => el("option", { text: `${size} per page`, attrs: { value: size, selected: size === pageSize } })));
|
|
67
|
+
return el("div", { class: "pager" }, [
|
|
68
|
+
el("span", { text: records === 0 ? "No results" : `Showing ${first}–${last} of ${records}` }),
|
|
69
|
+
el("span", { class: "spacer" }),
|
|
70
|
+
sizeSelect,
|
|
71
|
+
el("span", { text: `Page ${pages === 0 ? 0 : page} of ${pages}` }),
|
|
72
|
+
el("button", { class: "icon-btn", attrs: { type: "button", "aria-label": "Previous page", disabled: page <= 1 }, on: { click: () => onChange({ page: page - 1, pageSize }) } }, icon("chevronLeft")),
|
|
73
|
+
el("button", { class: "icon-btn", attrs: { type: "button", "aria-label": "Next page", disabled: page >= pages }, on: { click: () => onChange({ page: page + 1, pageSize }) } }, icon("chevronRight")),
|
|
74
|
+
]);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function searchBox(placeholder, value, onInput) {
|
|
78
|
+
let timer;
|
|
79
|
+
const inputNode = el("input", { class: "input", attrs: { type: "search", placeholder, value: value ?? "", "aria-label": placeholder } });
|
|
80
|
+
inputNode.addEventListener("input", () => { clearTimeout(timer); timer = setTimeout(() => onInput(inputNode.value.trim()), 300); });
|
|
81
|
+
return el("div", { class: "search" }, [icon("search"), inputNode]);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function tabs(items, active, onSelect) {
|
|
85
|
+
return el("div", { class: "tabs", attrs: { role: "tablist" } }, items.map(([value, label]) =>
|
|
86
|
+
el("button", { class: "tab", text: label, attrs: { type: "button", role: "tab", "aria-selected": value === active ? "true" : "false" }, on: { click: () => onSelect(value) } })));
|
|
87
|
+
}
|