@redeemid/emdash 0.0.29 → 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/staff.ts CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { adminConfigured, adminScopes, editTags, registerWebhooks, resync, webhookSubscriptions, WEBHOOK_TOPICS } from "./admin-api.js";
8
8
  import { applyEntryRule, describe, entryRuleFor, levelsFor, membership, passes, requirement, termTree, type Terms } from "./access.js";
9
- import { PROVIDER_ID, type RedeemIDConfig } from "./config.js";
9
+ import { PROVIDER_ID, shopOrigin, type RedeemIDConfig } from "./config.js";
10
10
  import { levelChange } from "./events.js";
11
11
  import { featuresFor } from "./features.js";
12
12
  import { record } from "./log.js";
@@ -32,76 +32,157 @@ function consentLabel(state: string | null | undefined): string {
32
32
  return t.charAt(0).toUpperCase() + t.slice(1);
33
33
  }
34
34
 
35
- export function memberDetail(config: RedeemIDConfig, m: MemberRecord, toast?: Blocks["toast"]): Blocks {
35
+ /** The member page's tabs, in order. */
36
+ export const MEMBER_TABS = ["membership", "tags", "account", "privacy"] as const;
37
+ export type MemberTab = (typeof MEMBER_TABS)[number];
38
+
39
+ /** When, to the minute, in UTC: "2026-11-13 18:59 UTC". */
40
+ const stamp = (iso: string | null | undefined) => (iso ? `${iso.slice(0, 10)} ${iso.slice(11, 16)} UTC`.trim() : "");
41
+
42
+ /**
43
+ * One member: who they are and where they stand at the top, then tabs for
44
+ * their membership, their Shopify tags, their account and privacy.
45
+ */
46
+ export function memberDetail(config: RedeemIDConfig, m: MemberRecord, toast?: Blocks["toast"], tab: MemberTab = "membership", exported?: unknown): Blocks {
36
47
  const levels = config.levels ?? [];
37
48
  const { current, past } = membership(m, levels);
38
49
  const features = featuresFor(m, config.features, levels);
39
50
  const featureNames = (config.features ?? []).filter((f) => features.includes(f.slug)).map((f) => f.name);
40
51
  const name = [m.firstName, m.lastName].filter(Boolean).join(" ") || m.email || `Customer ${m.customerId}`;
52
+ let shop: string | null = null;
53
+ try {
54
+ const origin = new URL(shopOrigin(config));
55
+ if (origin.hostname.endsWith(".myshopify.com")) shop = origin.origin;
56
+ } catch {
57
+ shop = null;
58
+ }
59
+ const admin = adminConfigured();
60
+
61
+ const membershipTab = [
62
+ {
63
+ type: "fields",
64
+ fields: [
65
+ { label: "Levels now", value: current.map((l) => `${l.name} (since ${date(l.since)})`).join(", ") || "None" },
66
+ { label: "Features", value: featureNames.join(", ") || "None" },
67
+ { label: "Member since (Shopify)", value: m.dates?.since ?? "" },
68
+ { label: m.dates?.ends ? "Ends on (Shopify)" : "Renews on (Shopify)", value: m.dates?.ends ?? m.dates?.renews ?? "" },
69
+ { label: "Gift until (Shopify)", value: m.dates?.gift ?? "" },
70
+ ],
71
+ },
72
+ { type: "header", text: "Level history" },
73
+ past.length
74
+ ? {
75
+ type: "table",
76
+ page_action_id: "member_levels",
77
+ columns: [
78
+ { key: "level", label: "Level" },
79
+ { key: "from", label: "From" },
80
+ { key: "to", label: "To" },
81
+ ],
82
+ rows: past.map((l) => ({ level: l.name, from: date(l.from), to: date(l.to) })),
83
+ }
84
+ : { type: "context", text: "No earlier levels." },
85
+ ];
86
+
87
+ const tagsTab = [
88
+ { type: "context", text: "Levels come only from these tags. Edits are written to Shopify, then read back, so Shopify stays the only source of access." },
89
+ ...(admin
90
+ ? [
91
+ {
92
+ type: "redeemid_modal",
93
+ block_id: `modal:tags:${m.customerId}`,
94
+ title: "Add tags in Shopify",
95
+ description: "Adds the tags to the customer in Shopify, then reads the customer back. Access follows.",
96
+ trigger: { label: "Add tags", style: "primary" },
97
+ blocks: [
98
+ {
99
+ type: "form",
100
+ block_id: `tags:${m.customerId}`,
101
+ fields: [{ type: "text_input", action_id: "tags", label: "Tags (comma separated)", placeholder: "gold-member" }],
102
+ submit: { label: "Add in Shopify", action_id: "member_tag_add" },
103
+ },
104
+ ],
105
+ },
106
+ ]
107
+ : [{ type: "context", text: "Set the Admin API app's client ID to edit tags and resync from here." }]),
108
+ m.tags.length
109
+ ? {
110
+ type: "table",
111
+ page_action_id: "member_tags",
112
+ columns: [
113
+ { key: "tag", label: "Tag", format: "code" },
114
+ { key: "since", label: "Since" },
115
+ { key: "remove", label: "", format: "element" },
116
+ ],
117
+ rows: m.tags.map((t) => ({
118
+ tag: t,
119
+ since: date(m.tagsSince?.[t.toLowerCase()]),
120
+ remove: admin
121
+ ? {
122
+ type: "button",
123
+ action_id: "member_tag_remove",
124
+ label: "Remove",
125
+ style: "secondary",
126
+ value: { customerId: m.customerId, tag: t },
127
+ confirm: { title: "Remove this tag in Shopify?", text: `Removes "${t}" from the customer in Shopify. Access follows.`, confirm: "Remove", deny: "Cancel", style: "danger" },
128
+ }
129
+ : null,
130
+ })),
131
+ }
132
+ : { type: "empty", title: "No tags", description: "This customer has no tags in Shopify, so no level.", size: "sm" },
133
+ ];
134
+
135
+ const accountTab = [
136
+ {
137
+ type: "fields",
138
+ fields: [
139
+ { label: "Email", value: m.email ?? "" },
140
+ { label: "Shopify customer ID", value: m.customerId },
141
+ { label: "Account", value: m.status === "enabled" ? "On" : "Turned off in Shopify" },
142
+ { label: "Email verified", value: m.emailVerified === undefined ? "" : m.emailVerified ? "Yes" : "No" },
143
+ { label: "Email marketing", value: consentLabel(m.emailMarketing) },
144
+ { label: "SMS marketing", value: consentLabel(m.smsMarketing) },
145
+ { label: "Last synced", value: stamp(m.syncedAt) },
146
+ { label: "Synced by", value: m.source || "unknown" },
147
+ ],
148
+ },
149
+ { type: "divider" },
150
+ ...linkSection(m),
151
+ ];
152
+
153
+ const privacyTab = [...privacySection(m.customerId), ...(exported === undefined ? [] : [{ type: "header", text: "Export" }, { type: "code", language: "jsonc", code: JSON.stringify(exported, null, 2) }])];
154
+
41
155
  return {
42
156
  toast,
43
157
  blocks: [
44
- { type: "actions", elements: [{ type: "button", action_id: "members_back", label: "← All members", style: "secondary" }] },
158
+ {
159
+ type: "actions",
160
+ elements: [
161
+ { type: "button", action_id: "members_back", label: "\u2190 All members", style: "secondary" },
162
+ ...(admin ? [{ type: "button", action_id: "member_resync", label: "Resync from Shopify", style: "primary", value: m.customerId }] : []),
163
+ ...(shop ? [{ type: "link", label: "Open in Shopify", target: { kind: "external", url: `${shop}/admin/customers/${m.customerId}` }, appearance: "secondary" }] : []),
164
+ ],
165
+ },
45
166
  { type: "header", text: name },
167
+ { type: "context", text: [m.email, `Shopify customer ${m.customerId}`, m.userId ? `signed in here since ${date(m.linkedAt)}` : "hasn't signed in here yet"].filter(Boolean).join(" \u00b7 ") },
46
168
  {
47
- type: "fields",
48
- fields: [
49
- { label: "Email", value: m.email ?? "—" },
50
- { label: "Shopify customer", value: m.customerId },
51
- { label: "Account", value: m.status === "enabled" ? "On" : "Turned off in Shopify" },
52
- { label: "Signed in here", value: m.userId ? `Yes, first on ${date(m.linkedAt)}` : "Not yet" },
53
- { label: "Levels", value: current.map((l) => `${l.name} (since ${date(l.since)})`).join(", ") || "—" },
54
- { label: "Levels before", value: past.map((l) => `${l.name} (${date(l.from)} to ${date(l.to)})`).join(", ") || "—" },
55
- { label: "Features", value: featureNames.join(", ") || "—" },
56
- { label: "Member since (Shopify)", value: m.dates?.since ?? "—" },
57
- { label: m.dates?.ends ? "Ends on (Shopify)" : "Renews on (Shopify)", value: m.dates?.ends ?? m.dates?.renews ?? "—" },
58
- { label: "Gift until (Shopify)", value: m.dates?.gift ?? "—" },
59
- { label: "Email verified", value: m.emailVerified === undefined ? "—" : m.emailVerified ? "Yes" : "No" },
60
- { label: "Email marketing", value: consentLabel(m.emailMarketing) },
61
- { label: "SMS marketing", value: consentLabel(m.smsMarketing) },
62
- { label: "Last synced", value: `${m.syncedAt} (${m.source || "unknown"})` },
169
+ type: "stats",
170
+ items: [
171
+ { label: "Account", value: m.status === "enabled" ? "On" : "Off" },
172
+ { label: "Levels", value: current.map((l) => l.name).join(", ") || "None" },
173
+ { label: "Tags", value: String(m.tags.length) },
174
+ ],
175
+ },
176
+ {
177
+ type: "tab",
178
+ default_tab: MEMBER_TABS.indexOf(tab),
179
+ panels: [
180
+ { label: "Membership", blocks: membershipTab },
181
+ { label: "Tags", blocks: tagsTab },
182
+ { label: "Account", blocks: accountTab },
183
+ { label: "Privacy", blocks: privacyTab },
63
184
  ],
64
185
  },
65
- { type: "header", text: "Shopify tags" },
66
- m.tags.length
67
- ? {
68
- type: "table",
69
- page_action_id: "member_tags",
70
- columns: [
71
- { key: "tag", label: "Tag" },
72
- { key: "since", label: "Since" },
73
- { key: "remove", label: "", format: "element" },
74
- ],
75
- rows: m.tags.map((t) => ({
76
- tag: t,
77
- since: date(m.tagsSince?.[t.toLowerCase()]),
78
- remove: adminConfigured()
79
- ? {
80
- type: "button",
81
- action_id: "member_tag_remove",
82
- label: "Remove in Shopify",
83
- style: "danger",
84
- value: { customerId: m.customerId, tag: t },
85
- confirm: { title: "Remove this tag in Shopify?", text: `Removes "${t}" from the customer in Shopify. Access follows.`, confirm: "Remove", deny: "Cancel", style: "danger" },
86
- }
87
- : null,
88
- })),
89
- }
90
- : { type: "context", text: "No tags." },
91
- adminConfigured()
92
- ? {
93
- type: "form",
94
- block_id: `tags:${m.customerId}`,
95
- fields: [{ type: "text_input", action_id: "tags", label: "Add tags in Shopify (comma separated)", placeholder: "gold-member" }],
96
- submit: { label: "Add in Shopify", action_id: "member_tag_add" },
97
- }
98
- : { type: "context", text: "Set the Admin API app's client ID to edit tags and resync from here." },
99
- adminConfigured()
100
- ? { type: "actions", elements: [{ type: "button", action_id: "member_resync", label: "Resync from Shopify", style: "primary", value: m.customerId }] }
101
- : { type: "divider" },
102
- { type: "context", text: "Tags are written to Shopify, then read back. Shopify stays the only source of access." },
103
- ...linkSection(m),
104
- ...privacySection(m.customerId),
105
186
  ],
106
187
  };
107
188
  }
@@ -228,17 +309,18 @@ function privacySection(customerId: string): unknown[] {
228
309
  ]
229
310
  : [];
230
311
  return [
231
- { type: "header", text: "Privacy" },
232
- { type: "actions", elements: [{ type: "button", action_id: "member_export", label: "Export data", style: "secondary", value: customerId }, erase, ...shopify] },
233
312
  { type: "context", text: "Export shows everything RedeemID keeps about this member. Erasing removes it from the site; Shopify stays the record of the customer." },
313
+ { type: "actions", elements: [{ type: "button", action_id: "member_export", label: "Export data", style: "secondary", value: customerId }, erase] },
314
+ ...(shopify.length
315
+ ? [
316
+ { type: "header", text: "In Shopify" },
317
+ { type: "context", text: "Shopify erases the customer's personal data across the store, orders included, after its waiting period. Cancel stops a request that hasn't run yet." },
318
+ { type: "actions", elements: shopify },
319
+ ]
320
+ : []),
234
321
  ];
235
322
  }
236
323
 
237
- /** The member's export, as JSON, under their detail page. */
238
- export function exportBlocks(detail: Blocks, data: unknown): Blocks {
239
- return { ...detail, blocks: [...detail.blocks, { type: "header", text: "Export" }, { type: "code", language: "jsonc", code: JSON.stringify(data, null, 2) }] };
240
- }
241
-
242
324
  /** Every privacy request: what came in, from where, and what's still open. */
243
325
  export async function privacyPage(s: Stores, toast?: Blocks["toast"], extra: unknown[] = []): Promise<Blocks> {
244
326
  const items = await recentRequests(s);
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.0.29";
1
+ export const VERSION = "0.0.31";