@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/plugin.ts CHANGED
@@ -7,9 +7,10 @@
7
7
  * })
8
8
  *
9
9
  * It adds Portable Text blocks to the editor, an Access panel on every entry,
10
- * admin pages (Members, Access, Webhooks, App events, Reports, Status, Access
11
- * test, Privacy), a daily webhook health check and a dashboard widget. Sign-in, webhooks and checkout stay on the
12
- * auth provider's routes.
10
+ * admin pages (Overview, Members, Access, Reports, Privacy, Webhooks, App
11
+ * events, Status, Setup), a daily webhook health check and a dashboard
12
+ * widget. The pages are Block Kit built here; admin.ts renders them in the
13
+ * browser. Sign-in, webhooks and checkout stay on the auth provider's routes.
13
14
  *
14
15
  * - "Paywall break" (`redeemid-paywall`): marks where the entry's rule starts.
15
16
  * Everything before it is for everyone; everything after it follows the
@@ -33,17 +34,17 @@ import { deleteAccess, entryKey, loadAccess, onLevels, withSaved } from "./setti
33
34
  import { env, ENV, shopOrigin, storeDomain, unknownEnvNames, type LevelConfig, type RedeemIDConfig } from "./config.js";
34
35
  import { discover } from "./shopify.js";
35
36
  import { stores, type MemberRecord, type Stores } from "./storage.js";
36
- import { EDITOR_PANELS, PAGES, WIDGETS } from "./descriptor.js";
37
+ import { CONNECTION_TABS, EDITOR_PANELS, PAGES, WIDGETS } from "./descriptor.js";
37
38
  import { VERSION } from "./version.js";
38
39
  import { AdminError, adminConfigured } from "./admin-api.js";
39
- import { cancelShopifyErasure, closeRequest, eraseMember, exportMember, exportRequest, requestShopifyErasure } from "./privacy.js";
40
- import { accessTest, bulkResync, BULK_BATCH, exportBlocks, memberDetail, privacyPage, registerWebhooks, resyncOne, staffLink, staffUnlink, tagEdit } from "./staff.js";
40
+ import { cancelShopifyErasure, closeRequest, eraseMember, exportMember, exportRequest, recentRequests, requestShopifyErasure } from "./privacy.js";
41
+ import { bulkResync, BULK_BATCH, memberDetail, privacyPage, registerWebhooks, resyncOne, staffLink, staffUnlink, tagEdit } from "./staff.js";
41
42
  import { textProblems } from "./text.js";
42
43
  import { cacheFindings, gatedFindings, templateFindings } from "./template-scan.js";
43
- import { setupFacts, setupPage, WEBHOOK } from "./setup.js";
44
+ import { setupFacts, setupPage, setupSteps, WEBHOOK } from "./setup.js";
44
45
  import { record } from "./log.js";
45
46
  import { resend } from "./events.js";
46
- import { eventsPage, healthCheck, HEALTH_TASK, lastCheck, parseFilter, reportsPage, saveMonitor, WEBHOOK_PATH, WEBHOOK_TABS, webhooksPage, type MonitorEnv, type WebhookTab } from "./monitor.js";
47
+ import { eventRow, eventsPage, healthCheck, HEALTH_TASK, lastCheck, parseFilter, reportsPage, saveMonitor, WEBHOOK_PATH, WEBHOOK_TABS, webhooksPage, type MonitorEnv, type WebhookTab } from "./monitor.js";
47
48
  import { z } from "astro/zod";
48
49
  import { AgentError, explainAccess, findProducts, getCollection, getMember, getProduct, getRules, levelChanges, listLevels, siteHealth, type ExplainInput } from "./agents.js";
49
50
 
@@ -212,6 +213,9 @@ export function createPlugin(code: RedeemIDConfig = {}): ResolvedPlugin {
212
213
  // rule follows the entry's slug and goes with the entry.
213
214
  // email:send: the daily webhook check can warn staff by email.
214
215
  capabilities: ["content:read", "email:send"],
216
+ // No admin.entry here: EmDash turns off Block Kit editor panels for a
217
+ // plugin with one. The descriptor's adminEntry alone loads admin.ts,
218
+ // which renders the pages; the Access panel stays Block Kit.
215
219
  admin: {
216
220
  pages: PAGES,
217
221
  widgets: WIDGETS,
@@ -281,17 +285,16 @@ export function createPlugin(code: RedeemIDConfig = {}): ResolvedPlugin {
281
285
  if ((i.type === "block_action" || i.type === "form_submit") && isSetupAction(i.action_id)) return await setupAction(config, live, ctx, i);
282
286
  if (i.type === "block_action" || i.type === "form_submit") return await action(config, ctx, i);
283
287
  if (i.type !== "page_load") return { blocks: [] };
284
- if (i.page === "/") return await membersPage(config);
288
+ if (i.page === "/") return await overviewPage(config, ctx);
289
+ if (i.page === "/members") return await membersPage(config);
285
290
  if (i.page === "/access") return await accessPage(await accessEnv(config));
286
- if (i.page === "/webhooks") {
291
+ if (i.page === "/reports") return await reportsPage(config, await memberStores());
292
+ // Connection, or one of its tabs by address (/connection/webhooks).
293
+ if (i.page === "/connection" || i.page?.startsWith("/connection/")) {
287
294
  await scheduleHealth(ctx);
288
- return await webhooksPage(monitorEnv(config, await memberStores(), ctx));
295
+ const tab = i.page.split("/")[2] as ConnectionTab | undefined;
296
+ return await connectionPage(config, ctx, tab && CONNECTION_TABS.includes(tab) ? tab : undefined);
289
297
  }
290
- if (i.page === "/events") return await eventsPage(await memberStores());
291
- if (i.page === "/reports") return await reportsPage(config, await memberStores());
292
- if (i.page === "/setup") return setupPage(await setupFacts(config, await memberStores(), siteOrigin(ctx)));
293
- if (i.page === "/status") return await statusPage(config, ctx.request);
294
- if (i.page === "/test") return await accessTest(config);
295
298
  if (i.page === "/privacy") return await privacyPage(await memberStores());
296
299
  if (i.page === "widget:status") return await statusWidget(config, ctx);
297
300
  } catch (e) {
@@ -340,6 +343,12 @@ async function accessEnv(config: RedeemIDConfig): Promise<AccessEnv> {
340
343
  saved: await loadAccess(store),
341
344
  collections: collections.map((c) => ({ slug: c.slug, label: c.label || c.slug })),
342
345
  taxonomies: taxonomies.map((t) => ({ name: t.name, label: t.label || t.name, collections: t.collections ?? [] })),
346
+ holders: async () => {
347
+ const levels = config.levels ?? [];
348
+ const out: Record<string, number> = Object.fromEntries(levels.map((l) => [l.slug, 0]));
349
+ for (const m of await allMembers()) if (m.status === "enabled") for (const slug of levelsFor(m.tags, levels)) out[slug] = (out[slug] ?? 0) + 1;
350
+ return out;
351
+ },
343
352
  terms: async (taxonomy) => {
344
353
  type Node = { slug: string; label?: string; name?: string; children?: Node[] };
345
354
  const out: Array<{ slug: string; label: string }> = [];
@@ -474,12 +483,15 @@ function siteOrigin(ctx: PluginContext): string {
474
483
  async function setupAction(
475
484
  config: RedeemIDConfig,
476
485
  live: () => Promise<RedeemIDConfig>,
477
- ctx: PluginContext,
486
+ ctx: RouteContext,
478
487
  i: { action_id?: string; values?: Record<string, unknown> },
479
488
  ) {
480
489
  const s = await memberStores();
481
490
  const origin = siteOrigin(ctx);
482
- const page = async (toast?: { type: "success" | "error" | "info"; message: string }) => setupPage(await setupFacts(await live(), s, origin), toast);
491
+ const page = async (toast?: { type: "success" | "error" | "info"; message: string }) => {
492
+ const now = await live();
493
+ return connectionPage(now, ctx, "setup", { setup: setupPage(await setupFacts(now, s, origin), toast) });
494
+ };
483
495
  if (i.action_id === "setup_register") {
484
496
  try {
485
497
  const { added, failed } = await registerWebhooks(config, origin + WEBHOOK);
@@ -539,10 +551,10 @@ async function action(
539
551
  const { createKyselyAdapter } = await import("@emdash-cms/auth/adapters/kysely");
540
552
  const adapter = createKyselyAdapter((await getDb()) as never);
541
553
  const m = i.action_id === "member_link" ? await staffLink(adapter, s, id, i.values?.email) : await staffUnlink(adapter, s, id);
542
- return memberDetail(config, m, { type: "success", message: m.userId ? "Linked. Their next sign-in uses that site user." : "Unlinked." });
554
+ return memberDetail(config, m, { type: "success", message: m.userId ? "Linked. Their next sign-in uses that site user." : "Unlinked." }, "account");
543
555
  } catch (e) {
544
556
  const m = await s.members.get(id);
545
- return m ? memberDetail(config, m, fail(e)) : membersPage(config);
557
+ return m ? memberDetail(config, m, fail(e), "account") : membersPage(config);
546
558
  }
547
559
  }
548
560
  case "member_tag_add":
@@ -553,10 +565,10 @@ async function action(
553
565
  try {
554
566
  const m = await tagEdit(config, s, id, add ? "add" : "remove", add ? i.values?.tags : [v.tag]);
555
567
  const now = m ?? (await s.members.get(id));
556
- return now ? memberDetail(config, now, { type: "success", message: add ? "Tags added in Shopify." : "Tag removed in Shopify." }) : membersPage(config);
568
+ return now ? memberDetail(config, now, { type: "success", message: add ? "Tags added in Shopify." : "Tag removed in Shopify." }, "tags") : membersPage(config);
557
569
  } catch (e) {
558
570
  const m = await s.members.get(id);
559
- return m ? memberDetail(config, m, fail(e)) : membersPage(config);
571
+ return m ? memberDetail(config, m, fail(e), "tags") : membersPage(config);
560
572
  }
561
573
  }
562
574
  case "members_resync": {
@@ -576,49 +588,47 @@ async function action(
576
588
  const done = added.length ? `Registered ${added.length} webhooks.` : failed.length ? "" : "Nothing was missing.";
577
589
  const refused = failed.map((f) => `Shopify refused ${f.topic}: ${f.message}`).join(" ");
578
590
  await record({ event: "staff.webhooks", label: "register", status: failed.length ? `${failed.length} refused` : `${added.length} registered`, level: failed.length ? "warn" : "ok", origin: "staff" });
579
- return webhooksPage(monitor(), "setup", { type: failed.length ? "error" : "success", message: [done, refused].filter(Boolean).join(" ") });
591
+ return connectionPage(config, ctx, "status", { hooks: await webhooksPage(monitor(), "setup", { type: failed.length ? "error" : "success", message: [done, refused].filter(Boolean).join(" ") }) });
580
592
  } catch (e) {
581
- return webhooksPage(monitor(), "setup", fail(e));
593
+ return connectionPage(config, ctx, "status", { hooks: await webhooksPage(monitor(), "setup", fail(e)) });
582
594
  }
583
595
  }
584
596
  case "webhooks_tab": {
585
597
  const tab = WEBHOOK_TABS.includes(i.value as WebhookTab) ? (i.value as WebhookTab) : "received";
586
- return webhooksPage(monitor(), tab);
598
+ return connectionPage(config, ctx, tab === "setup" ? "status" : tab === "health" ? "health" : "webhooks");
587
599
  }
588
600
  case "deliveries_page":
589
- return webhooksPage(monitor(), "received", undefined, cursorOf(i.value));
601
+ return connectionPage(config, ctx, "webhooks", { hooks: await webhooksPage(monitor(), "received", undefined, cursorOf(i.value)) });
590
602
  case "health_check": {
591
603
  const r = await healthCheck({ ...monitor(), by: "staff" });
592
604
  const message = r.problems.length ? `Checked: ${r.problems.length === 1 ? "1 problem" : `${r.problems.length} problems`}.` : "Checked: subscriptions are in place and deliveries look healthy.";
593
- return webhooksPage(monitor(), "health", { type: r.problems.length ? "info" : "success", message });
605
+ return connectionPage(config, ctx, "health", { hooks: await webhooksPage(monitor(), "health", { type: r.problems.length ? "info" : "success", message }) });
594
606
  }
595
607
  case "monitor_save":
596
- return webhooksPage(monitor(), "health", await saveMonitor(ctx.kv, i.values));
608
+ return connectionPage(config, ctx, "health", { hooks: await webhooksPage(monitor(), "health", await saveMonitor(ctx.kv, i.values)) });
597
609
  case "events_filter":
598
- return eventsPage(s, { event: String(i.values?.event ?? "") || undefined, problems: i.values?.problems === true });
610
+ return connectionPage(config, ctx, "events", { events: await eventsPage(s, { event: String(i.values?.event ?? "") || undefined, problems: i.values?.problems === true }) });
599
611
  case "events_show":
600
- return eventsPage(s, { event: String(i.value ?? "") || undefined });
612
+ return connectionPage(config, ctx, "events", { events: await eventsPage(s, { event: String(i.value ?? "") || undefined }) });
601
613
  case "events_page":
602
- return eventsPage(s, parseFilter(i.block_id), cursorOf(i.value));
614
+ return connectionPage(config, ctx, "events", { events: await eventsPage(s, parseFilter(i.block_id), cursorOf(i.value)) });
603
615
  case "failed_resend": {
604
616
  const sent = await resend(config, s, String(i.value ?? "")).catch(() => false);
605
- return eventsPage(s, {}, undefined, sent ? { type: "success", message: "Sent. It's off the failed list." } : { type: "error", message: "The receiver still didn't take it. It stays on the list." });
617
+ return connectionPage(config, ctx, "events", { events: await eventsPage(s, {}, undefined, sent ? { type: "success", message: "Sent. It's off the failed list." } : { type: "error", message: "The receiver still didn't take it. It stays on the list." }) });
606
618
  }
607
619
  case "failed_clear":
608
620
  await s.failedEvents.delete(String(i.value ?? ""));
609
- return eventsPage(s, {}, undefined, { type: "success", message: "Cleared." });
621
+ return connectionPage(config, ctx, "events", { events: await eventsPage(s, {}, undefined, { type: "success", message: "Cleared." }) });
610
622
  case "failed_clear_all": {
611
623
  const { items } = await s.failedEvents.query({ limit: 100 });
612
624
  await s.failedEvents.deleteMany(items.map((r) => r.id));
613
- return eventsPage(s, {}, undefined, { type: "success", message: "Failed events cleared." });
625
+ return connectionPage(config, ctx, "events", { events: await eventsPage(s, {}, undefined, { type: "success", message: "Failed events cleared." }) });
614
626
  }
615
- case "access_test":
616
- return accessTest(config, i.values);
617
627
  case "member_export": {
618
628
  const id = String(i.value ?? "");
619
629
  const m = await s.members.get(id);
620
630
  if (!m) return membersPage(config);
621
- return exportBlocks(memberDetail(config, m, { type: "success", message: "Export below." }), await exportRequest(s, id, "staff"));
631
+ return memberDetail(config, m, { type: "success", message: "Export below." }, "privacy", await exportRequest(s, id, "staff"));
622
632
  }
623
633
  case "member_erase": {
624
634
  const id = String(i.value ?? "");
@@ -633,9 +643,9 @@ async function action(
633
643
  if (i.action_id === "member_shopify_erase") await requestShopifyErasure(config, s, id);
634
644
  else await cancelShopifyErasure(config, s, id);
635
645
  const message = i.action_id === "member_shopify_erase" ? "Shopify will erase the customer after its waiting period." : "The Shopify erasure is cancelled.";
636
- return m ? memberDetail(config, m, { type: "success", message }) : privacyPage(s, { type: "success", message });
646
+ return m ? memberDetail(config, m, { type: "success", message }, "privacy") : privacyPage(s, { type: "success", message });
637
647
  } catch (e) {
638
- return m ? memberDetail(config, m, fail(e)) : privacyPage(s, fail(e));
648
+ return m ? memberDetail(config, m, fail(e), "privacy") : privacyPage(s, fail(e));
639
649
  }
640
650
  }
641
651
  case "privacy_done": {
@@ -779,30 +789,94 @@ function textSummary(config: RedeemIDConfig): string {
779
789
  return ignored ? `${all - ignored} in use, ${ignored} ignored` : `${all} in use`;
780
790
  }
781
791
 
792
+ type ConnectionTab = (typeof CONNECTION_TABS)[number];
793
+
794
+ type Built = { blocks: unknown[]; toast?: unknown };
795
+ const withoutHeader = (b: Built) => b.blocks.filter((x, n) => !(n === 0 && (x as { type?: string }).type === "header"));
796
+ const panelsOf = (b: Built) => ((b.blocks.find((x) => (x as { type?: string }).type === "tab") as { panels?: Array<{ blocks: unknown[] }> } | undefined)?.panels ?? []).map((p) => p.blocks);
797
+
798
+ /**
799
+ * Everything about the store connection on one page: the values Shopify
800
+ * needs (Status), the setup steps, webhook deliveries and their health, and
801
+ * the app's event log. An action passes the part it rebuilt (with its
802
+ * toast); the other tabs load as usual. Opens on Setup until it's done.
803
+ */
804
+ async function connectionPage(config: RedeemIDConfig, ctx: RouteContext, tab?: ConnectionTab, given: { hooks?: Built; events?: Built; setup?: Built } = {}) {
805
+ const s = await memberStores();
806
+ const [hooks, events, facts, status] = await Promise.all([
807
+ given.hooks ?? webhooksPage(monitorEnv(config, s, ctx)),
808
+ given.events ?? eventsPage(s),
809
+ setupFacts(config, s, siteOrigin(ctx)),
810
+ statusPage(config, ctx.request),
811
+ ]);
812
+ const setup = given.setup ?? setupPage(facts);
813
+ const steps = setupSteps(facts).filter((x) => x.state !== "optional" && x.key !== "storefront");
814
+ const ready = steps.every((x) => x.state === "done");
815
+ const [received = [], health = [], subscriptions = []] = panelsOf(hooks);
816
+ const attention = JSON.stringify(hooks.blocks).includes('"label":"Health (!)"');
817
+ const open = tab ?? (ready ? "status" : "setup");
818
+ const toast = (given.hooks ?? given.events ?? given.setup)?.toast;
819
+ return {
820
+ blocks: [
821
+ { type: "header", text: "Connection" },
822
+ { type: "context", text: "How this site talks to Shopify: the values to copy into Shopify, the setup steps, webhook deliveries and what RedeemID has done." },
823
+ {
824
+ type: "tab",
825
+ default_tab: CONNECTION_TABS.indexOf(open),
826
+ panels: [
827
+ { label: "Status", blocks: [...withoutHeader(status), { type: "divider" }, { type: "header", text: "Webhook subscriptions" }, ...subscriptions] },
828
+ { label: ready ? "Setup" : `Setup (${steps.filter((x) => x.state === "done").length}/${steps.length})`, blocks: withoutHeader(setup) },
829
+ { label: "Webhooks", blocks: received },
830
+ { label: attention ? "Health (!)" : "Health", blocks: health },
831
+ { label: "App events", blocks: withoutHeader(events) },
832
+ ],
833
+ },
834
+ ],
835
+ ...(toast ? { toast } : {}),
836
+ };
837
+ }
838
+
782
839
  async function statusPage(config: RedeemIDConfig, request: Request) {
783
840
  const c = await checks(config);
784
841
  const origin = new URL(request.url).origin;
785
- const yes = (v: boolean) => (v ? "Set" : "Missing");
842
+ const set = (v: boolean) => (v ? "Set" : "Missing");
786
843
  const delivery = await lastDelivery().catch(() => null);
844
+ const unknown = unknownEnvNames();
845
+ const ready = c.discovery.startsWith("OK") && c.clientId;
787
846
  return {
788
847
  blocks: [
789
848
  { type: "header", text: "Status" },
790
- c.discovery.startsWith("OK") && c.clientId
791
- ? { type: "banner", variant: "default", title: "Sign-in is ready", description: "Members can sign in with their Shopify customer account." }
792
- : { type: "banner", variant: "alert", title: "Sign-in isn't ready", description: "Set the missing values below as environment secrets, then reload." },
849
+ ready
850
+ ? { type: "banner", variant: "default", title: "Sign-in is ready", description: "Members can sign in with their store customer account." }
851
+ : { type: "banner", variant: "alert", title: "Sign-in isn't ready", description: "Set the missing values below as environment secrets, then reload. Setup walks through each one." },
852
+ ...customDomainBlocks(config),
853
+ { type: "header", text: "Shopify settings" },
854
+ { type: "context", text: "Copy these into the store: the first three into the Customer Account API settings of the Headless storefront, the webhook URL into the app's webhook subscriptions." },
793
855
  {
794
- type: "fields",
856
+ type: "redeemid_copy",
857
+ fields: [
858
+ { label: "Callback URI", value: `${origin}/_emdash/api/auth/redeemid/callback` },
859
+ { label: "JavaScript origin", value: origin },
860
+ { label: "Logout URI", value: `${origin}/` },
861
+ { label: "Webhook URL", value: `${origin}/_emdash/api/auth/redeemid/webhook` },
862
+ ],
863
+ },
864
+ { type: "divider" },
865
+ { type: "header", text: "Environment" },
866
+ { type: "context", text: "What the site reads from its environment. Secrets show only whether they're set." },
867
+ {
868
+ type: "redeemid_copy",
795
869
  fields: [
796
- { label: "Store domain (REDEEMID_SHOPIFY_STORE_DOMAIN or shop)", value: c.domain || "Missing" },
797
- { label: "Store discovery", value: c.discovery },
798
- { label: "Customer Account API client ID", value: yes(c.clientId) },
799
- { label: "Webhook signing secret", value: yes(c.webhookSecret) },
800
- { label: "Admin API app client ID (staff tools)", value: yes(Boolean(env(ENV.adminClientId))) },
801
- { label: "Events", value: config.events?.url ? `${config.events.url}${env(ENV.eventsSecret) ? "" : " (REDEEMID_EVENTS_SECRET missing)"}` : "Off" },
802
- { label: "Storefront API access", value: c.storefront },
803
- { label: "Last webhook", value: delivery ?? "None received yet" },
804
- { label: "Replacement text", value: textSummary(config) },
805
- ...(unknownEnvNames().length ? [{ label: "Set but not used (check the name)", value: unknownEnvNames().join(", ") }] : []),
870
+ { label: "Store domain", value: c.domain || "Missing", hint: `${ENV.storeDomain} or shop`, copy: Boolean(c.domain) },
871
+ { label: "Store discovery", value: c.discovery, copy: false },
872
+ { label: "Customer Account API client ID", value: set(c.clientId), hint: ENV.clientId, copy: false },
873
+ { label: "Webhook signing secret", value: set(c.webhookSecret), hint: ENV.webhookSecret, copy: false },
874
+ { label: "Admin API app client ID (staff tools)", value: set(Boolean(env(ENV.adminClientId))), hint: ENV.adminClientId, copy: false },
875
+ { label: "Storefront API access", value: c.storefront, hint: `${ENV.storefrontPrivateToken} or ${ENV.storefrontPublicToken}`, copy: false },
876
+ { label: "Events", value: config.events?.url ? `${config.events.url}${env(ENV.eventsSecret) ? "" : " (secret missing)"}` : "Off", hint: config.events?.url ? ENV.eventsSecret : undefined, copy: Boolean(config.events?.url) },
877
+ { label: "Last webhook", value: delivery ?? "None received yet", copy: false },
878
+ { label: "Replacement text", value: textSummary(config), copy: false },
879
+ ...(unknown.length ? [{ label: "Set but not used (check the name)", value: unknown.join(", "), copy: false }] : []),
806
880
  ],
807
881
  },
808
882
  ...(textProblems(config).length
@@ -818,22 +892,9 @@ async function statusPage(config: RedeemIDConfig, request: Request) {
818
892
  },
819
893
  ]
820
894
  : []),
821
- ...customDomainBlocks(config),
822
- ...templateBlocks(config),
823
- { type: "context", text: "Setting up a store step by step is under Setup. Deliveries, their health and the webhook subscriptions are under Webhooks; everything RedeemID did is under App events." },
824
- { type: "divider" },
825
- { type: "header", text: "Shopify settings" },
826
- {
827
- type: "fields",
828
- fields: [
829
- { label: "Callback URI", value: `${origin}/_emdash/api/auth/redeemid/callback` },
830
- { label: "JavaScript origin", value: origin },
831
- { label: "Logout URI", value: `${origin}/` },
832
- { label: "Webhook URL", value: `${origin}/_emdash/api/auth/redeemid/webhook` },
833
- ],
834
- },
835
895
  { type: "divider" },
836
- { type: "context", text: "Levels, rules and the meter are under Access." },
896
+ { type: "header", text: "Templates" },
897
+ ...templateBlocks(config),
837
898
  ],
838
899
  };
839
900
  }
@@ -903,19 +964,184 @@ function templateBlocks(config: RedeemIDConfig): unknown[] {
903
964
  ];
904
965
  }
905
966
 
967
+ /** Counts the Overview and the dashboard card share. */
968
+ async function summary(config: RedeemIDConfig, ctx: PluginContext) {
969
+ const [c, members, check, delivery, requests] = await Promise.all([
970
+ checks(config),
971
+ allMembers().catch(() => [] as MemberRecord[]),
972
+ lastCheck(ctx.kv).catch(() => null),
973
+ memberStores()
974
+ .then((s) => s.deliveries.query({ orderBy: { receivedAt: "desc" }, limit: 1 }))
975
+ .then((r) => r.items[0]?.data ?? null)
976
+ .catch(() => null),
977
+ memberStores()
978
+ .then((s) => recentRequests(s))
979
+ .catch(() => []),
980
+ ]);
981
+ const levels = config.levels ?? [];
982
+ return {
983
+ signIn: c.discovery.startsWith("OK") && c.clientId,
984
+ members,
985
+ active: members.filter((m) => m.status === "enabled" && levelsFor(m.tags, levels).length > 0).length,
986
+ signedIn: members.filter((m) => m.userId).length,
987
+ check,
988
+ delivery,
989
+ openRequests: requests.filter((r) => r.data.status === "open").length,
990
+ levels,
991
+ };
992
+ }
993
+
994
+ type Summary = Awaited<ReturnType<typeof summary>>;
995
+
996
+ function webhookWord(s: Summary): { value: string; tone: "ok" | "warn" | "error" } {
997
+ if (!s.check) return { value: "Unchecked", tone: "warn" };
998
+ return s.check.problems.length ? { value: `${s.check.problems.length} to fix`, tone: "error" } : { value: "Healthy", tone: "ok" };
999
+ }
1000
+
1001
+ /** "3 h ago" for a recent time. */
1002
+ function ago(iso: string | null | undefined, now = Date.now()): string {
1003
+ if (!iso) return "";
1004
+ const ms = now - Date.parse(iso);
1005
+ if (!Number.isFinite(ms)) return "";
1006
+ if (ms < 60_000) return "just now";
1007
+ if (ms < 3_600_000) return `${Math.floor(ms / 60_000)} min ago`;
1008
+ if (ms < 86_400_000) return `${Math.floor(ms / 3_600_000)} h ago`;
1009
+ const d = Math.floor(ms / 86_400_000);
1010
+ return d === 1 ? "yesterday" : `${d} days ago`;
1011
+ }
1012
+
1013
+ const page = (path: string) => ({ kind: "plugin-page" as const, path });
1014
+ const count = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`;
1015
+
1016
+ /** The dashboard card: what needs a look, the counts, and the way in. */
906
1017
  async function statusWidget(config: RedeemIDConfig, ctx: PluginContext) {
907
- const c = await checks(config);
908
- const members = await allMembers().catch(() => []);
909
- const check = await lastCheck(ctx.kv).catch(() => null);
1018
+ const s = await summary(config, ctx);
1019
+ const hooks = webhookWord(s);
1020
+ const alerts: unknown[] = [];
1021
+ if (!s.signIn) alerts.push({ type: "banner", variant: "error", title: "Sign-in isn't ready", description: "Members can't sign in until the store settings are complete. See Setup." });
1022
+ if (s.check?.problems.length) alerts.push({ type: "banner", variant: "alert", title: "Webhooks need attention", description: s.check.problems[0]!.text });
1023
+ if (s.openRequests) alerts.push({ type: "banner", variant: "alert", title: `${s.openRequests} privacy ${s.openRequests === 1 ? "request is" : "requests are"} open`, description: "Staff need to finish them under Privacy." });
910
1024
  return {
911
1025
  blocks: [
912
- ...(check?.problems.length ? [{ type: "banner", variant: "alert", title: "Webhooks need attention", description: check.problems[0]!.text }] : []),
1026
+ ...alerts,
1027
+ {
1028
+ type: "stats",
1029
+ items: [
1030
+ { label: "Members", value: String(s.members.length), description: `${s.active} with a level` },
1031
+ { label: "Sign-in", value: s.signIn ? "Ready" : "Not ready" },
1032
+ { label: "Webhooks", value: hooks.value, description: s.delivery ? `Last ${ago(s.delivery.receivedAt)}` : "None yet" },
1033
+ ],
1034
+ },
913
1035
  {
914
1036
  type: "fields",
915
1037
  fields: [
916
- { label: "Sign-in", value: c.discovery.startsWith("OK") && c.clientId ? "Ready" : "Not ready" },
917
- { label: "Members", value: String(members.length) },
918
- { label: "Webhooks", value: check ? (check.problems.length ? `${check.problems.length} to look at` : "Healthy") : "Not checked yet" },
1038
+ { label: "Signed in here", value: String(s.signedIn) },
1039
+ { label: "Levels", value: s.levels.map((l) => l.name).join(", ") || "None yet" },
1040
+ ],
1041
+ },
1042
+ {
1043
+ type: "actions",
1044
+ elements: [
1045
+ { type: "link", label: "Overview", target: page("/"), appearance: "primary" },
1046
+ { type: "link", label: "Members", target: page("/members"), appearance: "secondary" },
1047
+ { type: "link", label: "Access", target: page("/access"), appearance: "secondary" },
1048
+ { type: "link", label: s.signIn && !s.check?.problems.length ? "Webhooks" : "Setup", target: page(s.signIn && !s.check?.problems.length ? "/connection/webhooks" : "/connection/setup"), appearance: "secondary" },
1049
+ ],
1050
+ },
1051
+ ],
1052
+ };
1053
+ }
1054
+
1055
+ /** The first page: what needs doing, the numbers, recent activity and where to go. */
1056
+ async function overviewPage(config: RedeemIDConfig, ctx: PluginContext) {
1057
+ const st = await memberStores();
1058
+ const [s, facts, log] = await Promise.all([
1059
+ summary(config, ctx),
1060
+ setupFacts(config, st, siteOrigin(ctx)).catch(() => null),
1061
+ st.log.query({ orderBy: { at: "desc" }, limit: 8 }).catch(() => ({ items: [] as Array<{ data: Parameters<typeof eventRow>[0] }> })),
1062
+ ]);
1063
+ const steps = facts ? setupSteps(facts) : [];
1064
+ const required = steps.filter((x) => x.state !== "optional");
1065
+ const done = required.filter((x) => x.state === "done").length;
1066
+ const hooks = webhookWord(s);
1067
+ const rules = config.rules ?? [];
1068
+ const counts = s.levels.map((l) => ({
1069
+ level: l.name,
1070
+ members: s.members.filter((m) => m.status === "enabled" && levelsFor(m.tags, s.levels).includes(l.slug)).length,
1071
+ includes: (l.includes ?? []).map((x) => s.levels.find((o) => o.slug === x)?.name ?? x).join(", "),
1072
+ features: (config.features ?? []).filter((f) => f.levels?.includes(l.slug)).map((f) => f.name).join(", "),
1073
+ rules: rules.filter((r) => r.levels.includes(l.slug)).length,
1074
+ folders: (config.files ?? []).filter((f) => f.levels?.includes(l.slug)).length,
1075
+ }));
1076
+ const domain = storeDomain(config);
1077
+ const shop = domain && !domain.startsWith("http") ? `https://${domain.replace(/^https?:\/\//, "")}` : null;
1078
+ const alerts: unknown[] = [];
1079
+ if (required.length && done < required.length)
1080
+ alerts.push({ type: "banner", variant: s.signIn ? "default" : "alert", title: `Setup: ${done} of ${required.length} steps done`, description: `Next: ${required.find((x) => x.state !== "done")?.title ?? ""}.` });
1081
+ if (s.check?.problems.length) alerts.push({ type: "banner", variant: "alert", title: "Webhooks need attention", description: s.check.problems.map((p) => p.text).join(" ") });
1082
+ if (s.openRequests) alerts.push({ type: "banner", variant: "alert", title: `${s.openRequests} privacy ${s.openRequests === 1 ? "request is" : "requests are"} open`, description: "Finish them under Privacy." });
1083
+ return {
1084
+ blocks: [
1085
+ { type: "header", text: "RedeemID" },
1086
+ { type: "context", text: `Members sign in with their store customer account, and their Shopify customer tags decide what they read. Version ${VERSION}.` },
1087
+ ...alerts,
1088
+ {
1089
+ type: "redeemid_cards",
1090
+ items: [
1091
+ { title: "Sign-in", value: s.signIn ? "Ready" : "Not ready", tone: s.signIn ? "ok" : "error", text: domain ? `Store: ${domain.replace(/^https?:\/\//, "")}` : "No store set", link: page("/connection/status"), label: "Status" },
1092
+ { title: "Members", value: String(s.members.length), text: `${s.active} with a level, ${s.signedIn} signed in here`, link: page("/members"), label: "Members" },
1093
+ { title: "Webhooks", value: hooks.value, tone: hooks.tone, text: s.delivery ? `Last delivery ${ago(s.delivery.receivedAt)} (${s.delivery.topic})` : "None received yet", link: page("/connection/webhooks"), label: "Webhooks" },
1094
+ { title: "Access", value: count(s.levels.length, "level"), text: [count(rules.length, "rule"), count((config.features ?? []).length, "feature"), count((config.files ?? []).length, "protected folder")].join(", "), link: page("/access"), label: "Access" },
1095
+ { title: "Privacy", value: s.openRequests ? `${s.openRequests} open` : "Nothing open", tone: s.openRequests ? "warn" : undefined, text: "Exports and erasures from members, staff and Shopify", link: page("/privacy"), label: "Privacy" },
1096
+ { title: "Setup", value: required.length ? `${done} of ${required.length}` : "Unknown", tone: required.length && done === required.length ? "ok" : "warn", text: done === required.length ? "Every step is done" : `Next: ${required.find((x) => x.state !== "done")?.title ?? ""}`, link: page("/connection/setup"), label: "Setup" },
1097
+ ],
1098
+ },
1099
+ { type: "header", text: "Levels" },
1100
+ counts.length
1101
+ ? {
1102
+ type: "table",
1103
+ page_action_id: "overview_levels",
1104
+ columns: [
1105
+ { key: "level", label: "Level" },
1106
+ { key: "members", label: "Members", format: "number" },
1107
+ { key: "includes", label: "Includes" },
1108
+ { key: "features", label: "Features" },
1109
+ { key: "rules", label: "Rules", format: "number" },
1110
+ { key: "folders", label: "Folders", format: "number" },
1111
+ ],
1112
+ rows: counts,
1113
+ }
1114
+ : { type: "empty", title: "No levels yet", description: "Levels name the readers who hold some Shopify tags.", size: "sm", actions: [{ type: "link", label: "Add a level", target: page("/access"), appearance: "primary" }] },
1115
+ ...(counts.length ? [{ type: "actions", elements: [{ type: "link", label: "Explore the access map", target: page("/access"), appearance: "secondary" }] }] : []),
1116
+ { type: "header", text: "Recent activity" },
1117
+ {
1118
+ type: "table",
1119
+ page_action_id: "overview_events",
1120
+ empty_text: "Nothing recorded yet.",
1121
+ columns: [
1122
+ { key: "event", label: "Event" },
1123
+ { key: "about", label: "About" },
1124
+ { key: "result", label: "Result", format: "badge" },
1125
+ { key: "at", label: "When", format: "relative_time" },
1126
+ ],
1127
+ rows: log.items.map((r) => {
1128
+ const { event, about, result, at } = eventRow(r.data);
1129
+ return { event, about, result, at };
1130
+ }),
1131
+ },
1132
+ { type: "actions", elements: [{ type: "link", label: "All app events", target: page("/connection/events"), appearance: "secondary" }, { type: "link", label: "Reports", target: page("/reports"), appearance: "secondary" }] },
1133
+ { type: "header", text: "Resources" },
1134
+ {
1135
+ type: "redeemid_cards",
1136
+ items: [
1137
+ { title: "Documentation", text: "Setting up the store, levels, rules, blocks and templates.", link: { kind: "external", url: "https://www.redeemid.com/docs/emdash/" }, label: "Read the docs" },
1138
+ ...(shop
1139
+ ? [
1140
+ { title: "Customers in Shopify", text: "Tags, subscriptions and orders live on the Shopify customer.", link: { kind: "external", url: `${shop}/admin/customers` }, label: "Open Shopify" },
1141
+ { title: "Shopify Flow", text: "Add and remove customer tags when orders, subscriptions or dates change.", link: { kind: "external", url: `${shop}/admin/apps/flow` }, label: "Open Flow" },
1142
+ ]
1143
+ : []),
1144
+ { title: "Report a problem", text: "Bugs and questions about RedeemID for EmDash.", link: { kind: "external", url: "https://github.com/webmultipliers/redeemid-emdash/issues" }, label: "Open an issue" },
919
1145
  ],
920
1146
  },
921
1147
  ],