@redeemid/emdash 0.0.16 → 0.0.18

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.
Files changed (73) hide show
  1. package/README.md +447 -232
  2. package/bin/redeemid-emdash.mjs +13 -1
  3. package/docs/developer-api.md +126 -0
  4. package/docs/shopify-setup.md +1 -1
  5. package/package.json +3 -1
  6. package/src/access-admin.ts +754 -0
  7. package/src/access.ts +84 -18
  8. package/src/admin-api.ts +9 -2
  9. package/src/agents.ts +62 -3
  10. package/src/api-version.ts +6 -0
  11. package/src/blocks/CollectionBlock.astro +4 -1
  12. package/src/blocks/SearchBlock.astro +14 -0
  13. package/src/blocks/index.ts +2 -0
  14. package/src/blocks-names.ts +1 -0
  15. package/src/cart-ops.ts +164 -0
  16. package/src/catalog.ts +287 -0
  17. package/src/checkout.ts +73 -0
  18. package/src/client/redeemid.ts +563 -0
  19. package/src/components/AccessBadge.astro +7 -5
  20. package/src/components/Account.astro +1 -1
  21. package/src/components/BrowseControls.astro +87 -0
  22. package/src/components/Cart.astro +12 -6
  23. package/src/components/CartDrawer.astro +59 -0
  24. package/src/components/CartView.astro +2 -1
  25. package/src/components/Collection.astro +23 -18
  26. package/src/components/Content.astro +10 -5
  27. package/src/components/Contribution.astro +1 -1
  28. package/src/components/Gate.astro +4 -2
  29. package/src/components/LockedPanel.astro +5 -3
  30. package/src/components/MemberCard.astro +1 -1
  31. package/src/components/Notices.astro +3 -28
  32. package/src/components/Plan.astro +1 -1
  33. package/src/components/Preferences.astro +1 -1
  34. package/src/components/Product.astro +116 -25
  35. package/src/components/ProductGrid.astro +52 -0
  36. package/src/components/ProductSearch.astro +83 -0
  37. package/src/components/Recommendations.astro +45 -0
  38. package/src/components/RedeemIDClient.astro +45 -0
  39. package/src/components/YourData.astro +1 -1
  40. package/src/config.ts +31 -5
  41. package/src/contract.ts +50 -0
  42. package/src/descriptor.ts +7 -0
  43. package/src/events.ts +3 -0
  44. package/src/features.ts +1 -1
  45. package/src/files.ts +1 -1
  46. package/src/guard.ts +5 -3
  47. package/src/index.ts +3 -0
  48. package/src/log.ts +96 -0
  49. package/src/monitor.ts +560 -0
  50. package/src/notices.ts +29 -0
  51. package/src/plugin.ts +234 -88
  52. package/src/rate-limit.ts +7 -0
  53. package/src/routes/api/cart.ts +84 -0
  54. package/src/routes/api/search.ts +49 -0
  55. package/src/routes/api/shared.ts +4 -0
  56. package/src/routes/api/state.ts +69 -0
  57. package/src/routes/buy.ts +4 -3
  58. package/src/routes/callback.ts +23 -5
  59. package/src/routes/cart-edit.ts +15 -66
  60. package/src/routes/cart.ts +4 -2
  61. package/src/routes/preferences.ts +2 -2
  62. package/src/routes/privacy.ts +2 -2
  63. package/src/routes/shared.ts +11 -0
  64. package/src/routes/webhook.ts +24 -11
  65. package/src/settings.ts +195 -0
  66. package/src/shopify.ts +37 -1
  67. package/src/site-config.ts +38 -5
  68. package/src/staff.ts +12 -23
  69. package/src/storage.ts +12 -0
  70. package/src/storefront.ts +231 -59
  71. package/src/text.ts +43 -2
  72. package/src/version.ts +1 -1
  73. package/src/webhooks.ts +2 -1
@@ -0,0 +1,563 @@
1
+ /**
2
+ * window.redeemid: RedeemID's browser client, loaded by <RedeemIDClient />.
3
+ *
4
+ * It never sends anything anywhere by itself: analytics tools, CRMs and site
5
+ * code subscribe to its events and forward what they choose.
6
+ *
7
+ * redeemid.subscribe((name, props, event) => { ... });
8
+ *
9
+ * Every event is { name, version, time, props } with scalar props only, and
10
+ * never a user id, an email or a Shopify tag. A subscriber also gets the
11
+ * events that fired before it subscribed (pass { replay: false } to skip
12
+ * them), so it can wait for consent. Each event is also dispatched on
13
+ * document as a CustomEvent named "redeemid:<name>".
14
+ *
15
+ * It also enhances RedeemID's markup when present: add-to-cart forms update
16
+ * a cart drawer in place, product option pickers switch price and image, and
17
+ * search boxes suggest as you type. Without JavaScript every form still
18
+ * works on its own.
19
+ */
20
+
21
+ type Props = Record<string, string | number | boolean | null>;
22
+ interface ClientEvent {
23
+ name: string;
24
+ version: number;
25
+ time: number;
26
+ props: Readonly<Props>;
27
+ }
28
+ type Listener = (name: string, props: Readonly<Props>, event: ClientEvent) => void;
29
+
30
+ interface Boot {
31
+ version: string;
32
+ apiVersion: string;
33
+ base: string;
34
+ locale: string;
35
+ strings: Record<string, string>;
36
+ notices: Record<string, string>;
37
+ }
38
+
39
+ interface Money {
40
+ amount: string;
41
+ currencyCode: string;
42
+ }
43
+ interface Line {
44
+ id: string;
45
+ quantity: number;
46
+ variantTitle: string;
47
+ productTitle: string;
48
+ productHandle: string;
49
+ image: { url: string; altText: string | null } | null;
50
+ price: Money | null;
51
+ total: Money | null;
52
+ sellingPlan?: string | null;
53
+ }
54
+ interface CartData {
55
+ id: string;
56
+ totalQuantity: number;
57
+ lines: Line[];
58
+ subtotal: Money | null;
59
+ total: Money | null;
60
+ discountCodes: Array<{ code: string; applicable: boolean }>;
61
+ }
62
+
63
+ declare global {
64
+ interface Window {
65
+ redeemid?: unknown;
66
+ }
67
+ }
68
+
69
+ const EVENT_VERSION = 1;
70
+ const EVENTS = ["ready", "state", "wall_shown", "metered_view", "sign_in_click", "checkout_click", "cart_changed", "cart_opened", "search"] as const;
71
+ const SITE_EVENT = /^site_[a-z0-9_]{1,40}$/;
72
+ const KEEP = 100;
73
+
74
+ function boot(): Boot | null {
75
+ const el = document.getElementById("redeemid-client-config");
76
+ try {
77
+ return el ? (JSON.parse(el.textContent ?? "") as Boot) : null;
78
+ } catch {
79
+ return null;
80
+ }
81
+ }
82
+
83
+ (() => {
84
+ if (window.redeemid) return;
85
+ const booted = boot();
86
+ if (!booted) return;
87
+ const config: Boot = booted;
88
+ const S = (k: string, vars: Record<string, string | number> = {}) => (config.strings[k] ?? k).replace(/\{(\w+)\}/g, (w, n: string) => (n in vars ? String(vars[n]) : w));
89
+ const api = (path: string) => `${config.base}/api/${path}`;
90
+
91
+ // ------------------------------------------------------------ events
92
+
93
+ const listeners: Listener[] = [];
94
+ const history: ClientEvent[] = [];
95
+
96
+ const clean = (props: Record<string, unknown> = {}): Props => {
97
+ const out: Props = {};
98
+ for (const [k, v] of Object.entries(props)) out[k] = typeof v === "string" || typeof v === "number" || typeof v === "boolean" ? v : null;
99
+ return out;
100
+ };
101
+
102
+ const deliver = (fn: Listener, e: ClientEvent) => {
103
+ try {
104
+ fn(e.name, e.props, e);
105
+ } catch (error) {
106
+ // A broken subscriber must not break the page or the others.
107
+ console.error(error);
108
+ }
109
+ };
110
+
111
+ const send = (name: string, props: Record<string, unknown> = {}): ClientEvent => {
112
+ const event: ClientEvent = Object.freeze({ name, version: EVENT_VERSION, time: Date.now(), props: Object.freeze(clean(props)) });
113
+ history.push(event);
114
+ if (history.length > KEEP) history.shift();
115
+ listeners.slice().forEach((fn) => deliver(fn, event));
116
+ document.dispatchEvent(new CustomEvent(`redeemid:${name}`, { detail: event }));
117
+ return event;
118
+ };
119
+
120
+ const subscribe = (fn: Listener, options: { replay?: boolean } = {}) => {
121
+ if (typeof fn !== "function") return () => {};
122
+ listeners.push(fn);
123
+ if (options.replay !== false) history.slice().forEach((e) => deliver(fn, e));
124
+ return () => {
125
+ const at = listeners.indexOf(fn);
126
+ if (at > -1) listeners.splice(at, 1);
127
+ };
128
+ };
129
+
130
+ /** Sites may add their own events, named site_*. RedeemID's names are reserved. */
131
+ const emit = (name: string, props: Record<string, unknown> = {}) => (SITE_EVENT.test(String(name)) ? send(String(name), props) : null);
132
+
133
+ // ------------------------------------------------------------ state
134
+
135
+ let pending: Promise<Record<string, unknown>> | null = null;
136
+
137
+ const getJson = async (url: string) => {
138
+ const res = await fetch(url, { credentials: "same-origin", headers: { Accept: "application/json" } });
139
+ const body = (await res.json().catch(() => ({}))) as Record<string, unknown>;
140
+ if (!res.ok) throw Object.assign(new Error(`redeemid: ${String(body.error ?? res.status)}`), { code: body.error ?? null, status: res.status });
141
+ return body;
142
+ };
143
+
144
+ const postJson = async (url: string, data: unknown) => {
145
+ const res = await fetch(url, {
146
+ method: "POST",
147
+ credentials: "same-origin",
148
+ headers: { "Content-Type": "application/json", Accept: "application/json", "X-EmDash-Request": "1" },
149
+ body: JSON.stringify(data),
150
+ });
151
+ const body = (await res.json().catch(() => ({}))) as Record<string, unknown>;
152
+ if (!res.ok && res.status !== 422) throw Object.assign(new Error(`redeemid: ${String(body.error ?? res.status)}`), { code: body.error ?? null, status: res.status });
153
+ return body;
154
+ };
155
+
156
+ const refresh = () => {
157
+ pending = getJson(api("state")).then((state) => {
158
+ send("state", {
159
+ signedIn: Boolean(state.signedIn),
160
+ linked: Boolean(state.linked),
161
+ levels: ((state.levels as string[]) ?? []).join(","),
162
+ features: ((state.features as string[]) ?? []).join(","),
163
+ cartCount: Number((state.cart as { count?: number } | undefined)?.count ?? 0),
164
+ });
165
+ return state;
166
+ });
167
+ pending.catch(() => {
168
+ pending = null;
169
+ });
170
+ return pending;
171
+ };
172
+
173
+ const state = () => pending ?? refresh();
174
+
175
+ const here = () => window.location.pathname + window.location.search;
176
+
177
+ const signIn = (returnTo?: string) => {
178
+ window.location.assign(`${config.base}/login?return=${encodeURIComponent(returnTo ?? here())}`);
179
+ };
180
+
181
+ /** Sign out is a POST (so links elsewhere can't sign members out): submit a form for it. */
182
+ const signOut = (returnTo?: string) => {
183
+ const form = document.createElement("form");
184
+ form.method = "post";
185
+ form.action = `${config.base}/logout?return=${encodeURIComponent(returnTo ?? here())}`;
186
+ form.hidden = true;
187
+ document.body.append(form);
188
+ form.submit();
189
+ };
190
+
191
+ // ------------------------------------------------------------ cart
192
+
193
+ const counts = (n: number) => {
194
+ document.querySelectorAll<HTMLElement>("[data-redeemid-cart-count]").forEach((el) => {
195
+ el.dataset.redeemidCartCount = String(n);
196
+ const badge = el.querySelector<HTMLElement>(".redeemid-cart__count");
197
+ if (badge) {
198
+ badge.textContent = n > 0 ? ` (${n})` : "";
199
+ badge.hidden = n === 0;
200
+ }
201
+ });
202
+ };
203
+
204
+ let lastCart: CartData | null = null;
205
+
206
+ const changed = (action: string, body: Record<string, unknown>) => {
207
+ const cart = (body.cart as CartData | null) ?? null;
208
+ lastCart = cart;
209
+ const count = cart?.totalQuantity ?? 0;
210
+ counts(count);
211
+ const added = body.added as { product?: string; variant?: string; quantity?: number; sellingPlan?: boolean } | undefined;
212
+ send("cart_changed", { action, notice: String(body.notice ?? ""), count, product: added?.product ?? null, quantity: added?.quantity ?? null, subscription: added?.sellingPlan ?? null });
213
+ renderDrawer(cart, String(body.notice ?? ""));
214
+ return body;
215
+ };
216
+
217
+ const cart = Object.freeze({
218
+ get: async () => {
219
+ const body = await getJson(api("cart"));
220
+ lastCart = (body.cart as CartData | null) ?? null;
221
+ counts(lastCart?.totalQuantity ?? 0);
222
+ return lastCart;
223
+ },
224
+ /** Add a product: { product: handle, variant?, options?: { Size: "M" }, sellingPlan?, quantity? }. */
225
+ add: async (input: { product: string; variant?: string; options?: Record<string, string>; sellingPlan?: string | null; quantity?: number }) =>
226
+ changed("add", await postJson(api("cart"), { action: "add", page: here(), ...input })),
227
+ update: async (line: string, quantity: number) => changed("update", await postJson(api("cart"), { action: "update", line, quantity })),
228
+ remove: async (line: string) => changed("remove", await postJson(api("cart"), { action: "remove", line })),
229
+ discount: async (code: string) => changed("discount", await postJson(api("cart"), { action: "discount", code })),
230
+ /** Go to Shopify's checkout (signed in for members). */
231
+ checkout: async () => {
232
+ send("checkout_click", { source: "api" });
233
+ const body = await postJson(api("cart"), { action: "checkout" });
234
+ if (typeof body.checkoutUrl === "string") window.location.assign(body.checkoutUrl);
235
+ return body.checkoutUrl ?? null;
236
+ },
237
+ });
238
+
239
+ // ------------------------------------------------------------ catalog
240
+
241
+ const query = (params: Record<string, string | number | undefined | null>) => {
242
+ const q = new URLSearchParams();
243
+ for (const [k, v] of Object.entries(params)) if (v !== undefined && v !== null && v !== "") q.set(k, String(v));
244
+ return q.toString();
245
+ };
246
+
247
+ const search = (q: string, options: { first?: number; after?: string; sort?: string } = {}) => {
248
+ send("search", { query_length: q.trim().length });
249
+ return getJson(`${api("search")}?${query({ q, ...options })}`);
250
+ };
251
+ const suggest = (q: string, limit = 6) => getJson(`${api("search")}?${query({ q, suggest: 1, limit })}`);
252
+ const collection = (handle: string, options: { first?: number; after?: string; sort?: string } = {}) => getJson(`${api("search")}?${query({ collection: handle, ...options })}`);
253
+
254
+ // ------------------------------------------------------------ drawer
255
+
256
+ const money = (m: Money | null | undefined) => {
257
+ if (!m) return "";
258
+ const n = Number(m.amount);
259
+ return Number.isFinite(n) ? new Intl.NumberFormat(config.locale, { style: "currency", currency: m.currencyCode }).format(n) : "";
260
+ };
261
+
262
+ const el = <K extends keyof HTMLElementTagNameMap>(tag: K, attrs: Record<string, string> = {}, ...children: Array<Node | string | null | false>) => {
263
+ const node = document.createElement(tag);
264
+ for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, v);
265
+ for (const c of children) if (c !== null && c !== false) node.append(c);
266
+ return node;
267
+ };
268
+
269
+ const drawer = () => document.querySelector<HTMLDialogElement>("dialog[data-redeemid-cart-drawer]");
270
+ let opener: Element | null = null;
271
+
272
+ function renderDrawer(c: CartData | null, notice = "") {
273
+ const d = drawer();
274
+ const body = d?.querySelector<HTMLElement>("[data-redeemid-drawer-body]");
275
+ const status = d?.querySelector<HTMLElement>("[data-redeemid-drawer-status]");
276
+ if (!d || !body) return;
277
+ if (status) status.textContent = notice ? (config.notices[notice] ?? "") : "";
278
+ body.replaceChildren();
279
+ if (!c || c.lines.length === 0) {
280
+ body.append(el("p", { class: "redeemid-cartview__empty" }, S("Your cart is empty.")));
281
+ return;
282
+ }
283
+ const list = el("ul", { class: "redeemid-cartview__lines" });
284
+ for (const l of c.lines) {
285
+ const qtyId = `redeemid-drawer-q-${l.id.replace(/[^a-z0-9]/gi, "")}`;
286
+ const qty = el("input", { id: qtyId, type: "number", min: "0", max: "1000", inputmode: "numeric", value: String(l.quantity) }) as HTMLInputElement;
287
+ const update = el("button", { type: "button" }, S("Update"));
288
+ update.addEventListener("click", () => void cart.update(l.id, Number.parseInt(qty.value, 10) || 0).catch(failed));
289
+ const remove = el("button", { type: "button", class: "redeemid-cartview__remove" }, S("Remove"), el("span", { style: "position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap" }, ` ${l.productTitle}`));
290
+ remove.addEventListener("click", () => void cart.remove(l.id).catch(failed));
291
+ const option = [l.variantTitle && l.variantTitle !== "Default Title" ? l.variantTitle : "", l.sellingPlan ?? ""].filter(Boolean).join(" · ");
292
+ list.append(
293
+ el(
294
+ "li",
295
+ { class: "redeemid-cartview__line", "data-redeemid-line": l.id },
296
+ l.image ? el("img", { src: l.image.url, alt: l.image.altText ?? "", loading: "lazy" }) : null,
297
+ el(
298
+ "div",
299
+ { class: "redeemid-cartview__item" },
300
+ el("p", { class: "redeemid-cartview__name" }, l.productTitle, option ? el("span", {}, ` · ${option}`) : null),
301
+ el("p", { class: "redeemid-cartview__price" }, S("{price} each · {total}", { price: money(l.price), total: money(l.total) })),
302
+ el("p", { class: "redeemid-cartview__quantity" }, el("label", { for: qtyId }, S("Quantity")), qty, update),
303
+ remove,
304
+ ),
305
+ ),
306
+ );
307
+ }
308
+ const codeInput = el("input", { id: "redeemid-drawer-discount", type: "text", autocomplete: "off", value: c.discountCodes[0]?.code ?? "" }) as HTMLInputElement;
309
+ const apply = el("button", { type: "button" }, S("Apply"));
310
+ apply.addEventListener("click", () => void cart.discount(codeInput.value).catch(failed));
311
+ const checkout = el("button", { type: "button", class: "redeemid-cartview__checkout" }, S("Checkout"));
312
+ checkout.addEventListener("click", () => void cart.checkout().catch(failed));
313
+ const code = c.discountCodes[0];
314
+ const parts: Array<Node | null> = [
315
+ list,
316
+ el("p", { class: "redeemid-cartview__discount" }, el("label", { for: "redeemid-drawer-discount" }, S("Discount code")), codeInput, apply),
317
+ code && !code.applicable ? el("p", { class: "redeemid-cartview__code-note" }, S("{code} doesn't apply to this cart.", { code: code.code })) : null,
318
+ el("dl", { class: "redeemid-cartview__totals" }, el("dt", {}, S("Subtotal")), el("dd", {}, money(c.subtotal)), el("dt", {}, S("Total")), el("dd", {}, money(c.total))),
319
+ el("p", { class: "redeemid-cartview__note" }, S("Taxes and shipping are worked out at checkout.")),
320
+ checkout,
321
+ ];
322
+ for (const p of parts) if (p) body.append(p);
323
+ }
324
+
325
+ function failed(e: unknown) {
326
+ const status = drawer()?.querySelector<HTMLElement>("[data-redeemid-drawer-status]");
327
+ const code = (e as { code?: string })?.code;
328
+ if (status) status.textContent = config.notices[code === "too_many_requests" ? "too_many_requests" : "checkout_failed"] ?? "";
329
+ }
330
+
331
+ const open = async (source: string, notice = "") => {
332
+ const d = drawer();
333
+ if (!d) return false;
334
+ opener = document.activeElement;
335
+ if (!d.open) d.showModal();
336
+ send("cart_opened", { source });
337
+ const body = d.querySelector<HTMLElement>("[data-redeemid-drawer-body]");
338
+ if (body && !lastCart) body.textContent = S("Loading your cart…");
339
+ try {
340
+ renderDrawer(await cart.get(), notice);
341
+ } catch (e) {
342
+ failed(e);
343
+ }
344
+ return true;
345
+ };
346
+
347
+ const openCart = () => open("api");
348
+
349
+ const closeCart = () => {
350
+ const d = drawer();
351
+ if (d?.open) d.close();
352
+ };
353
+
354
+ // ------------------------------------------------------------ enhancements
355
+
356
+ const where = (node: Element) => (node.closest(".redeemid-locked") ? "wall" : node.closest("dialog[data-redeemid-cart-drawer]") ? "drawer" : "page");
357
+
358
+ document.addEventListener(
359
+ "click",
360
+ (e) => {
361
+ const target = e.target instanceof Element ? e.target : null;
362
+ const link = target?.closest<HTMLAnchorElement>("a[href]");
363
+ if (!link) return;
364
+ const url = new URL(link.href, window.location.href);
365
+ if (url.origin !== window.location.origin) return;
366
+ if (link.hasAttribute("data-redeemid-cart-link") && drawer()) {
367
+ e.preventDefault();
368
+ void open("link");
369
+ return;
370
+ }
371
+ if (url.pathname === `${config.base}/login`) send("sign_in_click", { source: where(link) });
372
+ if (url.pathname === `${config.base}/cart`) send("checkout_click", { source: where(link) });
373
+ },
374
+ true,
375
+ );
376
+
377
+ document.addEventListener("click", (e) => {
378
+ const target = e.target instanceof Element ? e.target : null;
379
+ if (target?.closest("[data-redeemid-drawer-close]")) closeCart();
380
+ });
381
+
382
+ document.addEventListener("submit", (e) => {
383
+ const form = e.target instanceof HTMLFormElement ? e.target : null;
384
+ if (!form || e.defaultPrevented) return;
385
+ // Not form.action: RedeemID's forms have a field named "action", which shadows it.
386
+ const path = new URL(form.getAttribute("action") ?? "", window.location.href).pathname;
387
+ const submitter = (e as SubmitEvent).submitter as HTMLButtonElement | null;
388
+ if (path === `${config.base}/buy`) {
389
+ send("checkout_click", { source: where(form), product: String(new FormData(form).get("plan") ?? "") });
390
+ return;
391
+ }
392
+ if (path !== `${config.base}/cart-edit`) return;
393
+ const data = new FormData(form);
394
+ const action = String(data.get("action") ?? "");
395
+ if (submitter?.name === "then" && submitter.value === "checkout") {
396
+ send("checkout_click", { source: where(form), product: String(data.get("product") ?? "") });
397
+ return; // the form takes the buyer straight to checkout
398
+ }
399
+ // Without a drawer the form reloads the page with a notice, as it does without JavaScript.
400
+ if (!drawer() || action !== "add") return;
401
+ e.preventDefault();
402
+ const options: Record<string, string> = {};
403
+ data.forEach((v, k) => {
404
+ if (k.startsWith("option.") && typeof v === "string") options[k.slice(7)] = v;
405
+ });
406
+ const busy = form.querySelectorAll<HTMLButtonElement>("button");
407
+ busy.forEach((b) => (b.disabled = true));
408
+ cart
409
+ .add({
410
+ product: String(data.get("product") ?? ""),
411
+ variant: String(data.get("variant") ?? "") || undefined,
412
+ options,
413
+ sellingPlan: data.has("selling_plan") ? String(data.get("selling_plan")) : undefined,
414
+ quantity: Number.parseInt(String(data.get("quantity") ?? "1"), 10) || 1,
415
+ })
416
+ .then((body) => {
417
+ if (body.notice === "cart_added") void open("add", "cart_added");
418
+ else {
419
+ const note = form.querySelector<HTMLElement>("[data-redeemid-form-status]");
420
+ if (note) note.textContent = config.notices[String(body.notice)] ?? "";
421
+ }
422
+ })
423
+ .catch(() => form.submit())
424
+ .finally(() => busy.forEach((b) => (b.disabled = false)));
425
+ });
426
+
427
+ // Product option pickers: keep the price, image, sale price and button in step with the choice.
428
+ const products = () =>
429
+ document.querySelectorAll<HTMLFormElement>("form[data-redeemid-product-form]").forEach((form) => {
430
+ const data = form.querySelector<HTMLScriptElement>("script[data-redeemid-variants]");
431
+ if (!data || form.dataset.redeemidReady) return;
432
+ form.dataset.redeemidReady = "1";
433
+ type V = { id: string; available: boolean; price: string; compareAt: string; image: string | null; imageAlt: string; options: Record<string, string> };
434
+ let variants: V[] = [];
435
+ try {
436
+ variants = JSON.parse(data.textContent ?? "[]") as V[];
437
+ } catch {
438
+ return;
439
+ }
440
+ const root = form.closest<HTMLElement>("[data-redeemid-product]") ?? form;
441
+ const update = () => {
442
+ const chosen: Record<string, string> = {};
443
+ form.querySelectorAll<HTMLSelectElement | HTMLInputElement>("[name^='option.']").forEach((f) => {
444
+ if (f instanceof HTMLInputElement && (f.type === "radio" || f.type === "checkbox") && !f.checked) return;
445
+ chosen[f.name.slice(7)] = f.value;
446
+ });
447
+ const v = variants.find((x) => Object.entries(x.options).every(([k, val]) => chosen[k] === val));
448
+ const price = root.querySelector<HTMLElement>("[data-redeemid-price]");
449
+ const compare = root.querySelector<HTMLElement>("[data-redeemid-compare-at]");
450
+ const status = root.querySelector<HTMLElement>("[data-redeemid-availability]");
451
+ if (price) price.textContent = v?.price ?? "";
452
+ if (compare) {
453
+ compare.textContent = v?.compareAt ?? "";
454
+ compare.hidden = !v?.compareAt;
455
+ }
456
+ if (status) status.textContent = !v ? S("Not available in this combination.") : v.available ? "" : S("Sold out.");
457
+ form.querySelectorAll<HTMLButtonElement>("button[type='submit']").forEach((b) => (b.disabled = !v?.available));
458
+ const img = root.querySelector<HTMLImageElement>("[data-redeemid-product-image]");
459
+ if (img && v?.image) {
460
+ img.src = v.image;
461
+ img.alt = v.imageAlt;
462
+ }
463
+ };
464
+ form.addEventListener("change", (e) => {
465
+ if (e.target instanceof Element && e.target.matches("[name^='option.']")) update();
466
+ });
467
+ });
468
+
469
+ // Gallery thumbnails swap the main image.
470
+ document.addEventListener("click", (e) => {
471
+ const thumb = e.target instanceof Element ? e.target.closest<HTMLButtonElement>("[data-redeemid-thumb]") : null;
472
+ if (!thumb) return;
473
+ const root = thumb.closest<HTMLElement>("[data-redeemid-product]");
474
+ const img = root?.querySelector<HTMLImageElement>("[data-redeemid-product-image]");
475
+ if (!img) return;
476
+ img.src = thumb.dataset.redeemidThumb ?? img.src;
477
+ img.alt = thumb.dataset.redeemidAlt ?? "";
478
+ root?.querySelectorAll("[data-redeemid-thumb]").forEach((t) => t.setAttribute("aria-pressed", String(t === thumb)));
479
+ });
480
+
481
+ // Search suggestions while typing.
482
+ const suggestions = () =>
483
+ document.querySelectorAll<HTMLInputElement>("input[data-redeemid-suggest]").forEach((input) => {
484
+ if (input.dataset.redeemidReady) return;
485
+ input.dataset.redeemidReady = "1";
486
+ const list = document.getElementById(input.getAttribute("aria-controls") ?? "");
487
+ const live = input.form?.querySelector<HTMLElement>("[data-redeemid-suggest-status]");
488
+ if (!list) return;
489
+ const href = input.dataset.redeemidProductHref ?? "";
490
+ let timer: ReturnType<typeof setTimeout> | undefined;
491
+ let seq = 0;
492
+ const clear = () => {
493
+ list.replaceChildren();
494
+ list.hidden = true;
495
+ input.setAttribute("aria-expanded", "false");
496
+ };
497
+ input.addEventListener("keydown", (e) => {
498
+ if (e.key === "Escape") clear();
499
+ });
500
+ input.addEventListener("input", () => {
501
+ clearTimeout(timer);
502
+ const q = input.value.trim();
503
+ if (q.length < 2) return clear();
504
+ timer = setTimeout(async () => {
505
+ const mine = ++seq;
506
+ try {
507
+ const s = (await suggest(q)) as { products?: Array<{ handle: string; title: string }>; queries?: string[] };
508
+ if (mine !== seq) return;
509
+ list.replaceChildren();
510
+ const items = [
511
+ ...(s.queries ?? []).slice(0, 3).map((t) => el("li", {}, el("a", { href: `${input.form?.getAttribute("action") ?? window.location.pathname}?q=${encodeURIComponent(t)}` }, t))),
512
+ ...(s.products ?? []).map((p) => el("li", {}, href ? el("a", { href: href.replaceAll("{handle}", encodeURIComponent(p.handle)) }, p.title) : p.title)),
513
+ ];
514
+ list.append(...items);
515
+ list.hidden = items.length === 0;
516
+ input.setAttribute("aria-expanded", String(items.length > 0));
517
+ if (live) live.textContent = items.length ? S("{count} suggestions", { count: items.length }) : "";
518
+ } catch {
519
+ clear();
520
+ }
521
+ }, 200);
522
+ });
523
+ });
524
+
525
+ const scan = () => {
526
+ document.querySelectorAll<HTMLElement>(".redeemid-locked[data-redeemid-reason]").forEach((wall) => {
527
+ send("wall_shown", { entry: wall.dataset.redeemidEntry ?? "", level: wall.dataset.redeemidLevel ?? "", reason: wall.dataset.redeemidReason ?? "" });
528
+ });
529
+ document.querySelectorAll<HTMLElement>("[data-redeemid-left]").forEach((note) => {
530
+ send("metered_view", { left: Number(note.dataset.redeemidLeft) || 0 });
531
+ });
532
+ products();
533
+ suggestions();
534
+ send("ready", { api_version: config.apiVersion });
535
+ };
536
+
537
+ window.redeemid = Object.freeze({
538
+ version: config.version,
539
+ apiVersion: config.apiVersion,
540
+ events: Object.freeze(EVENTS.slice()),
541
+ subscribe,
542
+ emit,
543
+ state,
544
+ refresh,
545
+ signIn,
546
+ signOut,
547
+ cart,
548
+ openCart,
549
+ closeCart,
550
+ search,
551
+ suggest,
552
+ collection,
553
+ });
554
+
555
+ drawer()?.addEventListener("close", () => {
556
+ if (opener instanceof HTMLElement) opener.focus();
557
+ });
558
+
559
+ if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", scan, { once: true });
560
+ else scan();
561
+ })();
562
+
563
+ export {};
@@ -3,7 +3,7 @@
3
3
  * The level an entry needs, read from its rules (and from a paywall break
4
4
  * with levels):
5
5
  *
6
- * <AccessBadge collection="posts" terms={post.data.terms} content={post.data.content} />
6
+ * <AccessBadge collection="posts" terms={post.data.terms} content={post.data.content} id={post.id} />
7
7
  *
8
8
  * Open entries show `free` ("Free"; pass free="" to show nothing). The badge
9
9
  * carries `data-redeemid-level` (the level slugs) for styling, and
@@ -19,21 +19,23 @@ interface Props {
19
19
  terms?: Terms;
20
20
  /** The entry's body, so a paywall break's levels count. */
21
21
  content?: unknown[] | null;
22
+ /** The entry's id or slug, so its own rule from the editor counts. */
23
+ id?: string;
22
24
  free?: string;
23
25
  class?: string;
24
26
  }
25
27
 
26
- const config = siteConfig((Astro.locals as { emdash?: { config?: unknown } }).emdash?.config);
27
- const { collection, terms, content, free = text(config, "Free"), class: className } = Astro.props;
28
+ const config = await siteConfig(Astro.locals);
29
+ const { collection, terms, content, id, free = text(config, "Free"), class: className } = Astro.props;
28
30
  const split = splitAtPaywall(content);
29
31
  const required = split.paywall && split.levels.length
30
32
  ? [split.levels]
31
- : await entryRequirement(config, collection, terms);
33
+ : await entryRequirement(config, collection, terms, id);
32
34
  const label = describe(required, config?.levels, free, text(config, "Signed in"));
33
35
  const item = soldItem(content);
34
36
  const owner = item ? (await viewer(Astro)).member : null;
35
37
  const owned = Boolean(owner && owner.status !== "disabled" && holdsItem(item, owner.tags));
36
- const locked = required !== null && !owned && !(await canRead(Astro, { collection, terms }, { required })).allowed;
38
+ const locked = required !== null && !owned && !(await canRead(Astro, { collection, terms, id }, { required })).allowed;
37
39
  const slugs = required?.flat().join(" ") || (required ? "signed-in" : "free");
38
40
  ---
39
41
 
@@ -7,7 +7,7 @@ import { accountUrl, signInUrl, signOutUrl, viewer } from "../access.js";
7
7
  import { text } from "../text.js";
8
8
 
9
9
  const v = await viewer(Astro);
10
- const config = siteConfig((Astro.locals as { emdash?: { config?: unknown } }).emdash?.config);
10
+ const config = await siteConfig(Astro.locals);
11
11
  const here = Astro.url.pathname + Astro.url.search;
12
12
  const names = (config?.levels ?? []).filter((l) => v.levels.includes(l.slug)).map((l) => l.name);
13
13
  ---