@redeemid/emdash 0.0.30 → 0.0.31

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/src/admin.ts ADDED
@@ -0,0 +1,505 @@
1
+ /**
2
+ * RedeemID's admin pages in the browser (EmDash loads this file as the
3
+ * plugin's `adminEntry`).
4
+ *
5
+ * The pages are still built on the server as Block Kit (see plugin.ts): this
6
+ * only renders them, the way EmDash renders any plugin page, plus a few
7
+ * things Block Kit has no block for:
8
+ *
9
+ * - `redeemid_copy`: read-only fields, each with a copy button.
10
+ * - `redeemid_modal`: a button that opens a dialog holding blocks (a form).
11
+ * The dialog closes when its action comes back without an error.
12
+ * - `redeemid_cards`: a grid of linked cards (title, a value, a line of text).
13
+ *
14
+ * `fields`, `stats`, `columns`, `tab` and `accordion` blocks are rendered here
15
+ * too, so values wrap instead of being cut off, stat cards and columns wrap
16
+ * on a phone, and the blocks above can sit inside tabs.
17
+ *
18
+ * Written without JSX so the site's bundler needs no JSX settings for it.
19
+ */
20
+
21
+ import { ClipboardText, Collapsible, Dialog, DialogRoot, Button, Input, Loader, Tabs } from "@cloudflare/kumo";
22
+ import { API_BASE, apiFetch, useNavigate } from "@emdash-cms/admin";
23
+ import { BlockRenderer } from "@emdash-cms/blocks";
24
+ import { createElement as h, Fragment, useCallback, useEffect, useRef, useState, type ReactNode } from "react";
25
+
26
+ import { PAGES, TAB_PATHS } from "./descriptor.js";
27
+
28
+ const PLUGIN = "redeemid";
29
+ const ADMIN = "/_emdash/admin";
30
+
31
+ type Block = { type: string; block_id?: string; [key: string]: unknown };
32
+ type Toast = { message: string; type: "success" | "error" | "info" };
33
+ type Response = { blocks: Block[]; toast?: Toast };
34
+ type Interaction = { type: string; action_id?: string; block_id?: string; value?: unknown; values?: Record<string, unknown>; page?: string };
35
+ type Send = (interaction: Interaction) => Promise<Response | null>;
36
+ type LinkTarget = { kind: "plugin-page"; path: string } | { kind: "external"; url: string } | { kind: "content"; collection: string; id: string; locale?: string } | { kind: "plugin-settings" };
37
+
38
+ /** Where a Block Kit link goes, as EmDash resolves it for plugin pages. */
39
+ export function linkHref(target: LinkTarget): string | null {
40
+ switch (target.kind) {
41
+ case "plugin-page": {
42
+ const path = `/${String(target.path).replace(/^\/+/, "")}`;
43
+ return /^\/[a-z0-9/_-]*$/i.test(path) && !path.includes("//") ? `${ADMIN}/plugins/${PLUGIN}${path === "/" ? "/" : path}` : null;
44
+ }
45
+ case "content":
46
+ return `${ADMIN}/content/${encodeURIComponent(target.collection)}/${encodeURIComponent(target.id)}`;
47
+ case "plugin-settings":
48
+ return `${ADMIN}/plugins-manager/${PLUGIN}/settings`;
49
+ case "external":
50
+ try {
51
+ const url = new URL(target.url);
52
+ return url.protocol === "https:" || url.protocol === "http:" || url.protocol === "mailto:" ? url.href : null;
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+ return null;
58
+ }
59
+
60
+ const CUSTOM = new Set(["redeemid_map", "redeemid_copy", "redeemid_modal", "redeemid_cards", "fields", "stats", "columns", "tab", "accordion"]);
61
+
62
+ interface Ctx {
63
+ send: Send;
64
+ }
65
+
66
+ /** A list of blocks: ours here, the rest through EmDash's own renderer. */
67
+ function Blocks({ blocks, ctx }: { blocks: Block[]; ctx: Ctx }): ReactNode {
68
+ const onAction = (i: Interaction) => void ctx.send(i);
69
+ return h(
70
+ "div",
71
+ { className: "flex flex-col gap-4" },
72
+ blocks.map((b, i) =>
73
+ h("div", { key: b.block_id ?? `${b.type}:${i}`, className: "min-w-0" }, CUSTOM.has(b.type) ? h(One, { block: b, ctx }) : h(BlockRenderer as never, { blocks: [b], onAction, resolveLinkTarget: linkHref })),
74
+ ),
75
+ );
76
+ }
77
+
78
+ function One({ block, ctx }: { block: Block; ctx: Ctx }): ReactNode {
79
+ switch (block.type) {
80
+ case "fields":
81
+ return h(Facts, { fields: block.fields as Array<{ label: string; value: string }> });
82
+ case "stats":
83
+ return h(Stats, { items: block.items as Array<{ label: string; value: string | number; description?: string }> });
84
+ case "redeemid_copy":
85
+ return h(CopyFields, { fields: block.fields as CopyField[] });
86
+ case "redeemid_cards":
87
+ return h(Cards, { items: block.items as Card[] });
88
+ case "redeemid_modal":
89
+ return h(Modal, { block, ctx });
90
+ case "redeemid_map":
91
+ return h(AccessMap, { block, ctx });
92
+ case "columns": {
93
+ const cols = block.columns as Block[][];
94
+ return h(
95
+ "div",
96
+ { className: `grid grid-cols-1 gap-4 ${cols.length >= 3 ? "lg:grid-cols-3" : "md:grid-cols-2"}` },
97
+ cols.map((c, i) => h("div", { key: i, className: "min-w-0" }, h(Blocks, { blocks: c, ctx }))),
98
+ );
99
+ }
100
+ case "tab":
101
+ return h(TabBlock, { block, ctx });
102
+ case "accordion":
103
+ return h(Accordion, { block, ctx });
104
+ }
105
+ return null;
106
+ }
107
+
108
+ /** Label and value pairs that wrap (Block Kit's cut long values off with "..."). */
109
+ function Facts({ fields }: { fields: Array<{ label: string; value: string }> }): ReactNode {
110
+ return h(
111
+ "dl",
112
+ { className: "grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3" },
113
+ fields.map((f, i) => h("div", { key: i, className: "min-w-0" }, h("dt", { className: "text-sm text-kumo-subtle" }, f.label), f.value ? h("dd", { className: "text-kumo-default break-words" }, f.value) : h("dd", { className: "text-kumo-subtle" }, "Not set"))),
114
+ );
115
+ }
116
+
117
+ /** Stat cards that wrap onto more rows instead of running off a phone screen. */
118
+ function Stats({ items }: { items: Array<{ label: string; value: string | number; description?: string }> }): ReactNode {
119
+ return h(
120
+ "div",
121
+ { className: "grid gap-3", style: { gridTemplateColumns: "repeat(auto-fit, minmax(8.5rem, 1fr))" } },
122
+ items.map((it, i) =>
123
+ h(
124
+ "div",
125
+ { key: i, className: "min-w-0 rounded-lg border border-kumo-line p-4" },
126
+ h("div", { className: "text-sm text-kumo-subtle" }, it.label),
127
+ h("div", { className: "mt-1 text-2xl font-bold text-kumo-default break-words" }, String(it.value)),
128
+ it.description ? h("div", { className: "mt-1 text-sm text-kumo-subtle" }, it.description) : null,
129
+ ),
130
+ ),
131
+ );
132
+ }
133
+
134
+ interface CopyField {
135
+ label: string;
136
+ value: string;
137
+ /** Small print under the label, such as the setting's environment variable. */
138
+ hint?: string;
139
+ /** false: a state such as Set or Missing, shown without a copy button. */
140
+ copy?: boolean;
141
+ }
142
+
143
+ /** Read-only fields with copy buttons. */
144
+ function CopyFields({ fields }: { fields: CopyField[] }): ReactNode {
145
+ return h(
146
+ "div",
147
+ { className: "grid grid-cols-1 md:grid-cols-2 gap-x-6", style: { rowGap: "1.25rem" } },
148
+ fields.map((f, i) =>
149
+ h(
150
+ "div",
151
+ { key: i, className: "min-w-0 flex flex-col gap-1" },
152
+ h("div", { className: "text-sm font-medium text-kumo-default" }, f.label),
153
+ f.copy === false
154
+ ? h(Input as never, { value: f.value, disabled: true, readOnly: true, "aria-label": f.label, className: "w-full" })
155
+ : h(ClipboardText as never, { text: f.value, size: "base", className: "w-full", labels: { copyAction: `Copy ${f.label}` } }),
156
+ f.hint ? h("div", { className: "text-xs text-kumo-subtle font-mono break-all" }, f.hint) : null,
157
+ ),
158
+ ),
159
+ );
160
+ }
161
+
162
+ interface Card {
163
+ title: string;
164
+ text?: string;
165
+ /** A big number or word, such as a count or Healthy. */
166
+ value?: string;
167
+ link?: LinkTarget;
168
+ label?: string;
169
+ tone?: "ok" | "warn" | "error";
170
+ }
171
+
172
+ const TONE: Record<string, string> = { ok: "#16a34a", warn: "#d97706", error: "#dc2626" };
173
+
174
+ function Cards({ items }: { items: Card[] }): ReactNode {
175
+ return h(
176
+ "div",
177
+ { className: "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4" },
178
+ items.map((c, i) => {
179
+ const href = c.link ? linkHref(c.link) : null;
180
+ const external = c.link?.kind === "external";
181
+ return h(
182
+ "div",
183
+ { key: i, className: "min-w-0 flex flex-col gap-1 rounded-lg border border-kumo-line p-4", style: c.tone ? { borderLeft: `3px solid ${TONE[c.tone]}` } : undefined },
184
+ h("div", { className: c.value ? "text-sm text-kumo-subtle" : "font-semibold text-kumo-default" }, c.title),
185
+ c.value ? h("div", { className: "text-2xl font-bold text-kumo-default break-words" }, c.value) : null,
186
+ c.text ? h("div", { className: "text-sm text-kumo-default" }, c.text) : null,
187
+ href
188
+ ? h(
189
+ "a",
190
+ { href, className: "mt-1 text-sm font-medium text-kumo-link hover:underline", ...(external ? { target: "_blank", rel: "noopener noreferrer" } : {}) },
191
+ `${c.label ?? "Open"}${external ? " ↗" : " →"}`,
192
+ )
193
+ : null,
194
+ );
195
+ }),
196
+ );
197
+ }
198
+
199
+ type MapGo = { tab: string } | { level: string } | { feature: string } | { entry: { collection: string; id: string } };
200
+ type MapItem = { kind: "feature" | "content" | "term" | "entry" | "folder"; label: string; note?: string; via?: string; go: MapGo };
201
+ type MapRow = { id: string; name: string; who: string; members?: number; includes: string[]; chain: string[]; items: MapItem[] };
202
+
203
+ const KINDS: Array<{ kind: MapItem["kind"]; label: string }> = [
204
+ { kind: "feature", label: "Features" },
205
+ { kind: "content", label: "Content types" },
206
+ { kind: "term", label: "Terms" },
207
+ { kind: "entry", label: "Entries" },
208
+ { kind: "folder", label: "Folders" },
209
+ ];
210
+
211
+ /**
212
+ * The access map: one row per reader (everyone, signed in, each level), a
213
+ * count per kind of thing it opens, and the picked row's full list with
214
+ * "via" for what comes from an included level and a way to change each one.
215
+ */
216
+ function AccessMap({ block, ctx }: { block: Block; ctx: Ctx }): ReactNode {
217
+ const rows = block.rows as MapRow[];
218
+ const [picked, setPicked] = useState(rows[2]?.id ?? rows[0]?.id ?? "");
219
+ const [inherited, setInherited] = useState(true);
220
+ const row = rows.find((r) => r.id === picked) ?? rows[0];
221
+ const go = (target: MapGo) => void ctx.send({ type: "block_action", action_id: String(block.action_id), value: JSON.stringify(target) });
222
+ const cell = { padding: "0.6rem 0.75rem", borderBottom: "1px solid var(--color-kumo-line)", verticalAlign: "top" as const, textAlign: "left" as const };
223
+ const count = (r: MapRow, kind: MapItem["kind"]) => {
224
+ const of = r.items.filter((i) => i.kind === kind);
225
+ const own = of.filter((i) => !i.via).length;
226
+ if (!of.length) return "";
227
+ const via = of.length - own;
228
+ return h("span", { style: { whiteSpace: "nowrap" } }, own ? h("span", { className: "font-semibold" }, String(own)) : null, via ? h("span", { className: "text-kumo-subtle text-xs", style: own ? { marginLeft: "0.35rem" } : undefined }, `+${via} via`) : null);
229
+ };
230
+ const shown = (row?.items ?? []).filter((i) => inherited || !i.via);
231
+ return h(
232
+ "div",
233
+ { className: "flex flex-col gap-4" },
234
+ h(
235
+ "div",
236
+ { style: { overflowX: "auto" } },
237
+ h(
238
+ "table",
239
+ { className: "w-full text-sm", style: { borderCollapse: "collapse", minWidth: "44rem" } },
240
+ h(
241
+ "thead",
242
+ null,
243
+ h(
244
+ "tr",
245
+ { className: "text-kumo-subtle" },
246
+ ["Reader", "Who", "Members", "Includes", ...KINDS.map((k) => k.label)].map((label) => h("th", { key: label, scope: "col", style: { ...cell, fontWeight: 500 } }, label)),
247
+ ),
248
+ ),
249
+ h(
250
+ "tbody",
251
+ null,
252
+ rows.map((r) => {
253
+ const on = r.id === row?.id;
254
+ const inChain = !on && Boolean(row?.chain.includes(r.id));
255
+ return h(
256
+ "tr",
257
+ {
258
+ key: r.id,
259
+ onClick: () => setPicked(r.id),
260
+ style: { cursor: "pointer", background: on ? "var(--color-kumo-info-tint)" : inChain ? "var(--color-kumo-tint)" : undefined, boxShadow: on ? "inset 3px 0 0 var(--color-kumo-brand)" : inChain ? "inset 3px 0 0 var(--color-kumo-line)" : undefined },
261
+ },
262
+ h(
263
+ "th",
264
+ { scope: "row", style: { ...cell, fontWeight: 600 } },
265
+ h("button", { type: "button", "aria-pressed": on, className: "text-left font-semibold text-kumo-default hover:underline", onClick: () => setPicked(r.id) }, r.name),
266
+ inChain ? h("div", { className: "text-xs text-kumo-subtle font-normal" }, `${row?.name} gets this too`) : null,
267
+ ),
268
+ h("td", { style: cell }, r.who),
269
+ h("td", { style: cell }, r.members === undefined ? "" : String(r.members)),
270
+ h("td", { style: cell }, r.includes.join(", ")),
271
+ ...KINDS.map((k) => h("td", { key: k.kind, style: cell }, count(r, k.kind))),
272
+ );
273
+ }),
274
+ ),
275
+ ),
276
+ ),
277
+ row
278
+ ? h(
279
+ "section",
280
+ { "aria-live": "polite", className: "rounded-lg border border-kumo-line p-4 flex flex-col gap-4" },
281
+ h(
282
+ "div",
283
+ { className: "flex flex-wrap items-start justify-between gap-3" },
284
+ h(
285
+ "div",
286
+ { className: "min-w-0" },
287
+ h("h3", { className: "text-lg font-semibold text-kumo-default" }, `What ${row.name} opens`),
288
+ h("p", { className: "text-sm text-kumo-subtle" }, [row.who, row.includes.length ? `includes ${row.includes.join(", ")}` : ""].filter(Boolean).join(" · ")),
289
+ ),
290
+ h(
291
+ "div",
292
+ { className: "flex flex-wrap gap-2" },
293
+ row.id !== "everyone" && row.id !== "signed-in" ? h(Button as never, { variant: "secondary", size: "sm", onClick: () => go({ level: row.id }) }, "Edit level") : null,
294
+ h("label", { className: "flex items-center gap-2 text-sm text-kumo-default" }, h("input", { type: "checkbox", checked: inherited, onChange: (e: { target: { checked: boolean } }) => setInherited(e.target.checked) }), "Show what comes via others"),
295
+ ),
296
+ ),
297
+ shown.length
298
+ ? h(
299
+ "div",
300
+ { className: "grid grid-cols-1 md:grid-cols-2 gap-4" },
301
+ KINDS.map((k) => {
302
+ const list = shown.filter((i) => i.kind === k.kind);
303
+ if (!list.length) return null;
304
+ return h(
305
+ "div",
306
+ { key: k.kind, className: "min-w-0" },
307
+ h("h4", { className: "text-sm font-semibold text-kumo-subtle mb-1" }, `${k.label} (${list.length})`),
308
+ h(
309
+ "ul",
310
+ { className: "flex flex-col" },
311
+ list.map((i, n) =>
312
+ h(
313
+ "li",
314
+ { key: n, className: "flex items-start justify-between gap-3 py-1.5", style: { borderBottom: "1px solid var(--color-kumo-hairline)" } },
315
+ h(
316
+ "div",
317
+ { className: "min-w-0" },
318
+ h("div", { className: "text-sm text-kumo-default break-words" }, i.label, i.via ? h("span", { className: "text-xs text-kumo-subtle", style: { marginLeft: "0.5rem", whiteSpace: "nowrap" } }, `via ${i.via}`) : null),
319
+ i.note ? h("div", { className: "text-xs text-kumo-subtle break-words" }, i.note) : null,
320
+ ),
321
+ "entry" in i.go
322
+ ? h("a", { href: linkHref({ kind: "content", collection: i.go.entry.collection, id: i.go.entry.id }) ?? "#", className: "shrink-0 text-sm font-medium text-kumo-link hover:underline" }, "Open →")
323
+ : h("button", { type: "button", className: "shrink-0 text-sm font-medium text-kumo-link hover:underline", onClick: () => go(i.go) }, "Change →"),
324
+ ),
325
+ ),
326
+ ),
327
+ );
328
+ }),
329
+ )
330
+ : h("p", { className: "text-sm text-kumo-subtle" }, inherited ? "Nothing on this site names it yet." : "Nothing of its own. Tick \"Show what comes via others\" to see what it gets from other rows."),
331
+ )
332
+ : null,
333
+ );
334
+ }
335
+
336
+ function TabBlock({ block, ctx }: { block: Block; ctx: Ctx }): ReactNode {
337
+ const panels = block.panels as Array<{ label: string; blocks: Block[] }>;
338
+ const start = Number(block.default_tab ?? 0);
339
+ const [active, setActive] = useState(start);
340
+ // A response that opens another tab (after a save there) moves to it.
341
+ // Every response names its tab, so any new response (a new block) moves there.
342
+ useEffect(() => setActive(start), [start, block]);
343
+ return h(
344
+ "div",
345
+ null,
346
+ h("div", { style: { overflowX: "auto" } }, h(Tabs as never, { variant: "underline", value: String(active), onValueChange: (v: string) => setActive(Number(v)), tabs: panels.map((p, i) => ({ value: String(i), label: p.label })) })),
347
+ h("div", { className: "pt-4" }, h(Blocks, { blocks: panels[active]?.blocks ?? [], ctx })),
348
+ );
349
+ }
350
+
351
+ function Accordion({ block, ctx }: { block: Block; ctx: Ctx }): ReactNode {
352
+ const [open, setOpen] = useState(Boolean(block.default_open));
353
+ const C = Collapsible as unknown as { Root: never; DefaultTrigger: never; DefaultPanel: never };
354
+ return h(C.Root, { open, onOpenChange: setOpen } as never, h(C.DefaultTrigger, null, String(block.label)), h(C.DefaultPanel, null, h(Blocks, { blocks: block.blocks as Block[], ctx })));
355
+ }
356
+
357
+ /** A button that opens a dialog with blocks in it; an action that succeeds closes it. */
358
+ function Modal({ block, ctx }: { block: Block; ctx: Ctx }): ReactNode {
359
+ const trigger = block.trigger as { label: string; style?: "primary" | "secondary" } | undefined;
360
+ const [open, setOpen] = useState(Boolean(block.open));
361
+ // Each opening starts a fresh form; an error keeps the dialog and what was typed.
362
+ const [round, setRound] = useState(0);
363
+ const [problem, setProblem] = useState<string | null>(null);
364
+ const show = (v: boolean) => {
365
+ if (v) {
366
+ setRound((r) => r + 1);
367
+ setProblem(null);
368
+ }
369
+ setOpen(v);
370
+ };
371
+ useEffect(() => {
372
+ if (block.open) setOpen(true);
373
+ }, [block.open]);
374
+ const inner: Ctx = {
375
+ send: async (i) => {
376
+ setProblem(null);
377
+ const out = await ctx.send(i);
378
+ if (!out) setProblem("RedeemID didn't answer. Try again.");
379
+ else if (out.toast?.type === "error") setProblem(out.toast.message);
380
+ else setOpen(false);
381
+ return out;
382
+ },
383
+ };
384
+ return h(
385
+ Fragment,
386
+ null,
387
+ trigger ? h(Button as never, { variant: trigger.style === "primary" ? "primary" : "secondary", onClick: () => show(true) }, trigger.label) : null,
388
+ h(
389
+ DialogRoot as never,
390
+ { open, onOpenChange: show },
391
+ h(
392
+ Dialog as never,
393
+ { className: "p-6", style: { width: "min(40rem, calc(100vw - 2rem))", maxHeight: "calc(100vh - 2rem)", overflowY: "auto" } },
394
+ h(
395
+ "div",
396
+ { className: "flex items-start justify-between gap-3 mb-2" },
397
+ h("h3", { className: "text-lg font-semibold text-kumo-default" }, String(block.title ?? "")),
398
+ h(Button as never, { variant: "secondary", size: "sm", onClick: () => show(false) }, "Close"),
399
+ ),
400
+ block.description ? h("p", { className: "mb-2 text-sm text-kumo-subtle" }, String(block.description)) : null,
401
+ problem ? h("div", { role: "alert", className: "mb-2 rounded-lg border border-kumo-danger/50 bg-kumo-danger/10 px-4 py-3 text-sm text-kumo-danger" }, problem) : null,
402
+ h(Blocks, { key: round, blocks: (block.blocks as Block[]) ?? [], ctx: inner }),
403
+ ),
404
+ ),
405
+ );
406
+ }
407
+
408
+ /** One admin page: loads its blocks, sends actions, shows the answer. */
409
+ function Page({ page }: { page: string }): ReactNode {
410
+ const [blocks, setBlocks] = useState<Block[]>([]);
411
+ const [loading, setLoading] = useState(true);
412
+ const [pending, setPending] = useState(false);
413
+ const [error, setError] = useState<string | null>(null);
414
+ const [toast, setToast] = useState<Toast | null>(null);
415
+ const generation = useRef(0);
416
+ const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
417
+ const navigate = useNavigate();
418
+
419
+ const send: Send = useCallback(
420
+ async (interaction) => {
421
+ const g = ++generation.current;
422
+ if (timer.current) clearTimeout(timer.current);
423
+ setToast(null);
424
+ const first = interaction.type === "page_load";
425
+ if (first) setLoading(true);
426
+ else setPending(true);
427
+ try {
428
+ const res = await apiFetch(`${API_BASE}/plugins/${PLUGIN}/admin`, {
429
+ method: "POST",
430
+ headers: { "Content-Type": "application/json" },
431
+ body: JSON.stringify(first ? interaction : { ...interaction, page }),
432
+ });
433
+ if (g !== generation.current) return null;
434
+ if (!res.ok) {
435
+ setError(`RedeemID answered ${res.status}: ${await res.text()}`);
436
+ return null;
437
+ }
438
+ const data = ((await res.json()) as { data: Response }).data;
439
+ if (g !== generation.current) return data;
440
+ setBlocks(data.blocks ?? []);
441
+ setError(null);
442
+ if (data.toast) {
443
+ setToast(data.toast);
444
+ timer.current = setTimeout(() => setToast(null), data.toast.type === "error" ? 8000 : 4000);
445
+ }
446
+ return data;
447
+ } catch (e) {
448
+ if (g === generation.current) setError(e instanceof Error ? e.message : "RedeemID didn't answer.");
449
+ return null;
450
+ } finally {
451
+ if (g === generation.current) {
452
+ setLoading(false);
453
+ setPending(false);
454
+ }
455
+ }
456
+ },
457
+ [page],
458
+ );
459
+
460
+ useEffect(() => {
461
+ void send({ type: "page_load", page });
462
+ return () => {
463
+ generation.current++;
464
+ if (timer.current) clearTimeout(timer.current);
465
+ };
466
+ }, [send, page]);
467
+
468
+ // Links to other admin pages move inside the admin instead of reloading it.
469
+ const onClickCapture = (e: { target: unknown; button: number; metaKey: boolean; ctrlKey: boolean; shiftKey: boolean; altKey: boolean; preventDefault(): void }) => {
470
+ const a = (e.target as Element | null)?.closest?.("a[href]") as HTMLAnchorElement | null;
471
+ if (!a || a.target || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
472
+ const url = new URL(a.href, window.location.href);
473
+ if (url.origin !== window.location.origin || !url.pathname.startsWith(`${ADMIN}/`)) return;
474
+ e.preventDefault();
475
+ void (navigate as unknown as (o: { to: string }) => Promise<void>)({ to: url.pathname.slice(ADMIN.length) + url.search });
476
+ };
477
+
478
+ if (loading) return h("div", { className: "flex items-center justify-center py-16" }, h(Loader as never, { "aria-label": "Loading" }));
479
+ if (error) return h("div", { role: "alert", className: "rounded-lg border border-kumo-line p-6 text-kumo-default" }, h("div", { className: "font-semibold" }, "RedeemID couldn't load this page"), h("p", { className: "mt-1 text-sm text-kumo-subtle" }, error));
480
+ return h(
481
+ "div",
482
+ { className: "relative", onClickCapture },
483
+ toast
484
+ ? h(
485
+ "div",
486
+ {
487
+ role: toast.type === "error" ? "alert" : "status",
488
+ className: `fixed end-4 top-4 z-50 rounded-lg border px-4 py-3 text-sm shadow-lg ${toast.type === "success" ? "border-kumo-success/50 bg-kumo-success-tint text-kumo-success" : toast.type === "error" ? "border-kumo-danger/50 bg-kumo-danger/10 text-kumo-danger" : "border-kumo-info/50 bg-kumo-info-tint text-kumo-info"}`,
489
+ },
490
+ toast.message,
491
+ )
492
+ : null,
493
+ h("div", { "aria-busy": pending || undefined, className: pending ? "opacity-60 transition-opacity" : "transition-opacity" }, h(Blocks, { blocks, ctx: { send } })),
494
+ h("span", { role: "status", className: "sr-only" }, pending ? "Updating" : ""),
495
+ );
496
+ }
497
+
498
+ /** One component per page, so moving between them starts each one fresh. */
499
+ export const pages: Record<string, () => ReactNode> = Object.fromEntries(
500
+ [...PAGES.map((p) => p.path), ...TAB_PATHS].map((path) => {
501
+ const C = () => h(Page, { key: path, page: path });
502
+ C.displayName = `RedeemID${path}`;
503
+ return [path, C];
504
+ }),
505
+ );
package/src/descriptor.ts CHANGED
@@ -10,21 +10,23 @@ import { VERSION } from "./version.js";
10
10
 
11
11
  /**
12
12
  * Every page shares one sidebar folder, so RedeemID shows as a single
13
- * RedeemID entry under Plugins (opening to these pages) instead of nine
14
- * loose items mixed in with other plugins' pages.
13
+ * RedeemID entry under Plugins (opening to these pages) instead of loose
14
+ * items mixed in with other plugins' pages. Overview comes first; then the
15
+ * day-to-day pages (people and access), then Connection, whose tabs hold
16
+ * Status, Setup, Webhooks, Health and App events.
15
17
  */
16
18
  const GROUP = "RedeemID";
17
19
  export const PAGES = [
18
- { path: "/", label: "Members", icon: "users", group: GROUP },
20
+ { path: "/", label: "Overview", icon: "identification-card", group: GROUP },
21
+ { path: "/members", label: "Members", icon: "users", group: GROUP },
19
22
  { path: "/access", label: "Access", icon: "lock", group: GROUP },
20
- { path: "/test", label: "Access test", icon: "search", group: GROUP },
21
23
  { path: "/reports", label: "Reports", icon: "chart", group: GROUP },
22
- { path: "/webhooks", label: "Webhooks", icon: "pulse", group: GROUP },
23
- { path: "/events", label: "App events", icon: "list", group: GROUP },
24
- { path: "/privacy", label: "Privacy", icon: "lock", group: GROUP },
25
- { path: "/status", label: "Status", icon: "pulse", group: GROUP },
26
- { path: "/setup", label: "Setup", icon: "settings", group: GROUP },
24
+ { path: "/privacy", label: "Privacy", icon: "shield-check", group: GROUP },
25
+ { path: "/connection", label: "Connection", icon: "plugs-connected", group: GROUP },
27
26
  ];
27
+ /** Connection's tabs by address (/connection/webhooks), so a link can open one. */
28
+ export const CONNECTION_TABS = ["status", "setup", "webhooks", "health", "events"] as const;
29
+ export const TAB_PATHS = CONNECTION_TABS.map((t) => `/connection/${t}`);
28
30
  /** The Access panel on every saved entry, in every collection (new ones too). */
29
31
  export const EDITOR_PANELS = [{ id: "access", title: "Access", route: "access-panel", order: 10 }];
30
32
  export const WIDGETS = [{ id: "status", title: "RedeemID", size: "half" as const }];
@@ -36,6 +38,8 @@ export function redeemidPlugin(config: RedeemIDConfig): PluginDescriptor<RedeemI
36
38
  version: VERSION,
37
39
  entrypoint: "@redeemid/emdash/plugin",
38
40
  componentsEntry: "@redeemid/emdash/blocks",
41
+ // Renders the Block Kit pages in the browser, with copy fields and dialogs (admin.ts).
42
+ adminEntry: "@redeemid/emdash/admin",
39
43
  options: config,
40
44
  adminPages: PAGES,
41
45
  adminWidgets: WIDGETS,
package/src/monitor.ts CHANGED
@@ -129,7 +129,7 @@ export async function healthCheck(env_: MonitorEnv): Promise<HealthResult> {
129
129
 
130
130
  const last = await lastDelivery(s);
131
131
  if (settings.quietDays > 0) {
132
- if (!last) problems.push({ level: "warn", text: "No webhooks received yet. Check the subscriptions under Setup." });
132
+ if (!last) problems.push({ level: "warn", text: "No webhooks received yet. Check the webhook subscriptions under Connection → Status." });
133
133
  else if (Date.parse(last.receivedAt) < now - settings.quietDays * DAY) {
134
134
  const days = Math.floor((now - Date.parse(last.receivedAt)) / DAY);
135
135
  problems.push({ level: "warn", text: `No webhooks for ${days} days. Quiet stores are normal; if customers changed in Shopify since, check Setup.` });
@@ -483,7 +483,7 @@ function customerOfBody(body: string): string {
483
483
  }
484
484
  }
485
485
 
486
- function eventRow(e: LogEntry) {
486
+ export function eventRow(e: LogEntry) {
487
487
  const status = e.status === "ok" ? "OK" : cap(e.status);
488
488
  return {
489
489
  event: LOG_EVENTS[e.event] ?? e.event,