@redeemid/emdash 0.0.30 → 0.0.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/docs/access.md +7 -5
- package/docs/content.md +1 -1
- package/docs/developer-api.md +2 -2
- package/docs/getting-started.md +4 -4
- package/docs/production.md +3 -3
- package/docs/reference.md +3 -3
- package/docs/shopify-flow.md +2 -2
- package/docs/shopify-setup.md +4 -4
- package/docs/staff.md +21 -9
- package/package.json +11 -2
- package/src/access-admin.ts +211 -76
- package/src/access-map.ts +143 -0
- package/src/admin.ts +505 -0
- package/src/descriptor.ts +16 -9
- package/src/monitor.ts +20 -33
- package/src/plugin.ts +327 -81
- package/src/setup.ts +33 -38
- package/src/staff.ts +148 -66
- package/src/version.ts +1 -1
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,26 @@ 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
|
|
14
|
-
*
|
|
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, access and what happened), then Connection,
|
|
16
|
+
* with Status and one tab per setup step.
|
|
15
17
|
*/
|
|
16
18
|
const GROUP = "RedeemID";
|
|
17
19
|
export const PAGES = [
|
|
18
|
-
{ path: "/", label: "
|
|
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: "/
|
|
23
|
-
{ path: "/
|
|
24
|
-
{ path: "/
|
|
25
|
-
{ path: "/status", label: "Status", icon: "pulse", group: GROUP },
|
|
26
|
-
{ path: "/setup", label: "Setup", icon: "settings", group: GROUP },
|
|
24
|
+
{ path: "/events", label: "Events", icon: "pulse", group: GROUP },
|
|
25
|
+
{ path: "/privacy", label: "Privacy", icon: "shield-check", group: GROUP },
|
|
26
|
+
{ path: "/connection", label: "Connection", icon: "plugs-connected", group: GROUP },
|
|
27
27
|
];
|
|
28
|
+
/** Connection's tabs by address (/connection/sign-in), so a link can open one: Status, then a tab per setup step. */
|
|
29
|
+
export const CONNECTION_TABS = ["status", "store", "sign-in", "app", "webhooks", "level", "try", "storefront"] as const;
|
|
30
|
+
/** Events' tabs by address (/events/app). */
|
|
31
|
+
export const EVENTS_TABS = ["webhook", "app", "health"] as const;
|
|
32
|
+
export const TAB_PATHS = [...CONNECTION_TABS.map((t) => `/connection/${t}`), ...EVENTS_TABS.map((t) => `/events/${t}`)];
|
|
28
33
|
/** The Access panel on every saved entry, in every collection (new ones too). */
|
|
29
34
|
export const EDITOR_PANELS = [{ id: "access", title: "Access", route: "access-panel", order: 10 }];
|
|
30
35
|
export const WIDGETS = [{ id: "status", title: "RedeemID", size: "half" as const }];
|
|
@@ -36,6 +41,8 @@ export function redeemidPlugin(config: RedeemIDConfig): PluginDescriptor<RedeemI
|
|
|
36
41
|
version: VERSION,
|
|
37
42
|
entrypoint: "@redeemid/emdash/plugin",
|
|
38
43
|
componentsEntry: "@redeemid/emdash/blocks",
|
|
44
|
+
// Renders the Block Kit pages in the browser, with copy fields and dialogs (admin.ts).
|
|
45
|
+
adminEntry: "@redeemid/emdash/admin",
|
|
39
46
|
options: config,
|
|
40
47
|
adminPages: PAGES,
|
|
41
48
|
adminWidgets: WIDGETS,
|