@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/README.md +2 -2
- package/docs/access.md +7 -5
- package/docs/content.md +1 -1
- package/docs/developer-api.md +1 -1
- 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 +2 -2
- package/docs/staff.md +18 -8
- 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 +19 -9
- package/src/monitor.ts +2 -2
- package/src/plugin.ts +302 -76
- package/src/staff.ts +148 -66
- package/src/version.ts +1 -1
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
|
-
|
|
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
|
-
{
|
|
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: "
|
|
48
|
-
|
|
49
|
-
{ label: "
|
|
50
|
-
{ label: "
|
|
51
|
-
{ label: "
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
{ label: "
|
|
59
|
-
{ label: "
|
|
60
|
-
{ label: "
|
|
61
|
-
{ label: "
|
|
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.
|
|
1
|
+
export const VERSION = "0.0.31";
|