@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.
- package/README.md +447 -232
- package/bin/redeemid-emdash.mjs +13 -1
- package/docs/developer-api.md +126 -0
- package/docs/shopify-setup.md +1 -1
- package/package.json +3 -1
- package/src/access-admin.ts +754 -0
- package/src/access.ts +84 -18
- package/src/admin-api.ts +9 -2
- package/src/agents.ts +62 -3
- package/src/api-version.ts +6 -0
- package/src/blocks/CollectionBlock.astro +4 -1
- package/src/blocks/SearchBlock.astro +14 -0
- package/src/blocks/index.ts +2 -0
- package/src/blocks-names.ts +1 -0
- package/src/cart-ops.ts +164 -0
- package/src/catalog.ts +287 -0
- package/src/checkout.ts +73 -0
- package/src/client/redeemid.ts +563 -0
- package/src/components/AccessBadge.astro +7 -5
- package/src/components/Account.astro +1 -1
- package/src/components/BrowseControls.astro +87 -0
- package/src/components/Cart.astro +12 -6
- package/src/components/CartDrawer.astro +59 -0
- package/src/components/CartView.astro +2 -1
- package/src/components/Collection.astro +23 -18
- package/src/components/Content.astro +10 -5
- package/src/components/Contribution.astro +1 -1
- package/src/components/Gate.astro +4 -2
- package/src/components/LockedPanel.astro +5 -3
- package/src/components/MemberCard.astro +1 -1
- package/src/components/Notices.astro +3 -28
- package/src/components/Plan.astro +1 -1
- package/src/components/Preferences.astro +1 -1
- package/src/components/Product.astro +116 -25
- package/src/components/ProductGrid.astro +52 -0
- package/src/components/ProductSearch.astro +83 -0
- package/src/components/Recommendations.astro +45 -0
- package/src/components/RedeemIDClient.astro +45 -0
- package/src/components/YourData.astro +1 -1
- package/src/config.ts +31 -5
- package/src/contract.ts +50 -0
- package/src/descriptor.ts +7 -0
- package/src/events.ts +3 -0
- package/src/features.ts +1 -1
- package/src/files.ts +1 -1
- package/src/guard.ts +5 -3
- package/src/index.ts +3 -0
- package/src/log.ts +96 -0
- package/src/monitor.ts +560 -0
- package/src/notices.ts +29 -0
- package/src/plugin.ts +234 -88
- package/src/rate-limit.ts +7 -0
- package/src/routes/api/cart.ts +84 -0
- package/src/routes/api/search.ts +49 -0
- package/src/routes/api/shared.ts +4 -0
- package/src/routes/api/state.ts +69 -0
- package/src/routes/buy.ts +4 -3
- package/src/routes/callback.ts +23 -5
- package/src/routes/cart-edit.ts +15 -66
- package/src/routes/cart.ts +4 -2
- package/src/routes/preferences.ts +2 -2
- package/src/routes/privacy.ts +2 -2
- package/src/routes/shared.ts +11 -0
- package/src/routes/webhook.ts +24 -11
- package/src/settings.ts +195 -0
- package/src/shopify.ts +37 -1
- package/src/site-config.ts +38 -5
- package/src/staff.ts +12 -23
- package/src/storage.ts +12 -0
- package/src/storefront.ts +231 -59
- package/src/text.ts +43 -2
- package/src/version.ts +1 -1
- 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(
|
|
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(
|
|
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
|
---
|