@redeemid/emdash 0.0.17 → 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 +50 -18
- package/bin/redeemid-emdash.mjs +13 -1
- package/docs/developer-api.md +126 -0
- package/package.json +3 -1
- package/src/agents.ts +55 -0
- 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/BrowseControls.astro +87 -0
- package/src/components/Cart.astro +11 -5
- package/src/components/CartDrawer.astro +59 -0
- package/src/components/CartView.astro +1 -0
- package/src/components/Collection.astro +22 -17
- package/src/components/Content.astro +3 -2
- package/src/components/LockedPanel.astro +4 -2
- package/src/components/Notices.astro +2 -27
- package/src/components/Product.astro +115 -24
- 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/config.ts +14 -0
- package/src/contract.ts +50 -0
- package/src/index.ts +3 -0
- package/src/notices.ts +29 -0
- package/src/plugin.ts +32 -2
- 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 +2 -1
- package/src/routes/cart-edit.ts +15 -66
- package/src/routes/cart.ts +4 -2
- package/src/routes/shared.ts +11 -0
- package/src/shopify.ts +37 -1
- package/src/storefront.ts +231 -59
- package/src/text.ts +43 -2
- package/src/version.ts +1 -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 {};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Sort, filter and paging controls for a page of products (<Collection
|
|
4
|
+
* browse> and <ProductSearch>). A plain GET form: the choice lives in the
|
|
5
|
+
* page URL, so it can be shared, bookmarked and cached, and works without
|
|
6
|
+
* JavaScript. Filters are the ones Shopify offers for these products.
|
|
7
|
+
*/
|
|
8
|
+
import { browseHref, SEARCH_SORTS, SORTS, type Filter, type PageInfo, type Sort } from "../catalog.js";
|
|
9
|
+
import { siteConfig } from "../site-config.js";
|
|
10
|
+
import { text } from "../text.js";
|
|
11
|
+
|
|
12
|
+
interface Props {
|
|
13
|
+
filters: Filter[];
|
|
14
|
+
pageInfo: PageInfo;
|
|
15
|
+
/** Search pages offer fewer sort orders. */
|
|
16
|
+
search?: boolean;
|
|
17
|
+
/** The search words, kept in the form. */
|
|
18
|
+
query?: string;
|
|
19
|
+
id: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const { filters, pageInfo, search = false, query, id } = Astro.props;
|
|
23
|
+
const config = await siteConfig(Astro.locals);
|
|
24
|
+
const url = Astro.url;
|
|
25
|
+
const active = new Set(url.searchParams.getAll("filter"));
|
|
26
|
+
const sort = url.searchParams.get("sort") ?? "";
|
|
27
|
+
const labels: Record<Sort, string> = {
|
|
28
|
+
relevance: text(config, "Relevance"),
|
|
29
|
+
"best-selling": text(config, "Best selling"),
|
|
30
|
+
newest: text(config, "Newest"),
|
|
31
|
+
"price-asc": text(config, "Price, low to high"),
|
|
32
|
+
"price-desc": text(config, "Price, high to low"),
|
|
33
|
+
"title-asc": text(config, "Title, A to Z"),
|
|
34
|
+
"title-desc": text(config, "Title, Z to A"),
|
|
35
|
+
};
|
|
36
|
+
const sorts = search ? SEARCH_SORTS : SORTS.filter((s) => s !== "relevance");
|
|
37
|
+
const lists = filters.filter((f) => f.type !== "PRICE_RANGE" && f.values.length > 0);
|
|
38
|
+
const price = filters.find((f) => f.type === "PRICE_RANGE");
|
|
39
|
+
const filtered = active.size > 0 || url.searchParams.has("price_min") || url.searchParams.has("price_max");
|
|
40
|
+
const clearHref = browseHref(url, { filter: null, price_min: null, price_max: null });
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
<form method="get" action={url.pathname} class="redeemid-browse" aria-labelledby={`${id}-heading`}>
|
|
44
|
+
<h2 id={`${id}-heading`} style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap">{text(config, "Sort and filter")}</h2>
|
|
45
|
+
{query !== undefined && <input type="hidden" name="q" value={query} />}
|
|
46
|
+
<p class="redeemid-browse__sort">
|
|
47
|
+
<label for={`${id}-sort`}>{text(config, "Sort by")}</label>
|
|
48
|
+
<select id={`${id}-sort`} name="sort">
|
|
49
|
+
<option value="">{text(config, search ? "Relevance" : "Featured")}</option>
|
|
50
|
+
{sorts.filter((s) => !(search && s === "relevance")).map((s) => (
|
|
51
|
+
<option value={s} selected={sort === s}>{labels[s]}</option>
|
|
52
|
+
))}
|
|
53
|
+
</select>
|
|
54
|
+
</p>
|
|
55
|
+
{lists.map((f, i) => (
|
|
56
|
+
<fieldset class="redeemid-browse__filter">
|
|
57
|
+
<legend>{f.label}</legend>
|
|
58
|
+
{f.values.map((v, j) => (
|
|
59
|
+
<p>
|
|
60
|
+
<input type="checkbox" id={`${id}-f${i}-${j}`} name="filter" value={v.input} checked={active.has(v.input)} />
|
|
61
|
+
<label for={`${id}-f${i}-${j}`}>{text(config, "{label} ({count})", { label: v.label, count: v.count })}</label>
|
|
62
|
+
</p>
|
|
63
|
+
))}
|
|
64
|
+
</fieldset>
|
|
65
|
+
))}
|
|
66
|
+
{price && (
|
|
67
|
+
<fieldset class="redeemid-browse__filter">
|
|
68
|
+
<legend>{price.label || text(config, "Price")}</legend>
|
|
69
|
+
<p>
|
|
70
|
+
<label for={`${id}-min`}>{text(config, "From")}</label>
|
|
71
|
+
<input id={`${id}-min`} name="price_min" type="number" min="0" step="any" inputmode="decimal" value={url.searchParams.get("price_min") ?? ""} />
|
|
72
|
+
<label for={`${id}-max`}>{text(config, "To")}</label>
|
|
73
|
+
<input id={`${id}-max`} name="price_max" type="number" min="0" step="any" inputmode="decimal" value={url.searchParams.get("price_max") ?? ""} />
|
|
74
|
+
</p>
|
|
75
|
+
</fieldset>
|
|
76
|
+
)}
|
|
77
|
+
<p class="redeemid-browse__actions">
|
|
78
|
+
<button type="submit">{text(config, "Apply")}</button>
|
|
79
|
+
{filtered && <a href={clearHref}>{text(config, "Clear filters")}</a>}
|
|
80
|
+
</p>
|
|
81
|
+
</form>
|
|
82
|
+
{(pageInfo.hasPreviousPage || pageInfo.hasNextPage) && (
|
|
83
|
+
<nav class="redeemid-browse__pages" aria-label={text(config, "Pages")}>
|
|
84
|
+
{pageInfo.hasPreviousPage && pageInfo.startCursor && <a rel="prev" href={browseHref(url, { before: pageInfo.startCursor, after: null })}>{text(config, "Previous page")}</a>}
|
|
85
|
+
{pageInfo.hasNextPage && pageInfo.endCursor && <a rel="next" href={browseHref(url, { after: pageInfo.endCursor, before: null })}>{text(config, "Next page")}</a>}
|
|
86
|
+
</nav>
|
|
87
|
+
)}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
---
|
|
2
2
|
/**
|
|
3
|
-
* Cart link with the item count from the visitor's Shopify cart. It goes
|
|
4
|
-
*
|
|
3
|
+
* Cart link with the item count from the visitor's Shopify cart. It goes to
|
|
4
|
+
* that cart's Shopify checkout, or to `href` (a cart page with <CartView>).
|
|
5
|
+
* With `drawer`, and <CartDrawer /> and <RedeemIDClient /> on the page, it
|
|
6
|
+
* opens the drawer instead; the link still works without JavaScript.
|
|
5
7
|
*/
|
|
6
8
|
import { cartUrl } from "../access.js";
|
|
7
9
|
import { readCart } from "../cart.js";
|
|
@@ -10,15 +12,19 @@ import { text } from "../text.js";
|
|
|
10
12
|
|
|
11
13
|
interface Props {
|
|
12
14
|
label?: string;
|
|
15
|
+
/** A cart page to link to instead of checkout. */
|
|
16
|
+
href?: string;
|
|
17
|
+
/** Open the cart drawer. */
|
|
18
|
+
drawer?: boolean;
|
|
13
19
|
}
|
|
14
20
|
|
|
15
21
|
const config = await siteConfig(Astro.locals);
|
|
16
|
-
const { label = text(config, "Cart") } = Astro.props;
|
|
22
|
+
const { label = text(config, "Cart"), href, drawer = false } = Astro.props;
|
|
17
23
|
const ref = await readCart(Astro.session);
|
|
18
24
|
const count = ref?.count ?? 0;
|
|
19
25
|
const here = Astro.url.pathname + Astro.url.search;
|
|
20
26
|
---
|
|
21
27
|
|
|
22
|
-
<a class="redeemid-cart" href={cartUrl(here)} data-redeemid-cart-count={count}>
|
|
23
|
-
{label}
|
|
28
|
+
<a class="redeemid-cart" href={href ?? cartUrl(here)} data-redeemid-cart-count={count} data-redeemid-cart-link={drawer ? "" : undefined}>
|
|
29
|
+
{label}<span class="redeemid-cart__count" hidden={count === 0}>{count > 0 ? ` (${count})` : ""}</span>
|
|
24
30
|
</a>
|