@redeemid/emdash 0.0.17 → 0.0.19

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 (50) hide show
  1. package/README.md +50 -18
  2. package/bin/redeemid-emdash.mjs +13 -1
  3. package/docs/developer-api.md +126 -0
  4. package/package.json +3 -1
  5. package/src/agents.ts +55 -0
  6. package/src/api-version.ts +6 -0
  7. package/src/blocks/CollectionBlock.astro +4 -1
  8. package/src/blocks/SearchBlock.astro +14 -0
  9. package/src/blocks/index.ts +2 -0
  10. package/src/blocks-names.ts +1 -0
  11. package/src/cart-ops.ts +164 -0
  12. package/src/catalog.ts +287 -0
  13. package/src/checkout.ts +73 -0
  14. package/src/client/redeemid.ts +563 -0
  15. package/src/components/BrowseControls.astro +87 -0
  16. package/src/components/Cart.astro +11 -5
  17. package/src/components/CartDrawer.astro +59 -0
  18. package/src/components/CartView.astro +1 -0
  19. package/src/components/Collection.astro +22 -17
  20. package/src/components/Content.astro +3 -2
  21. package/src/components/LockedPanel.astro +4 -2
  22. package/src/components/Notices.astro +2 -27
  23. package/src/components/Product.astro +115 -24
  24. package/src/components/ProductGrid.astro +52 -0
  25. package/src/components/ProductSearch.astro +83 -0
  26. package/src/components/Recommendations.astro +45 -0
  27. package/src/components/RedeemIDClient.astro +45 -0
  28. package/src/config.ts +14 -0
  29. package/src/contract.ts +50 -0
  30. package/src/files.ts +39 -1
  31. package/src/guard.ts +10 -3
  32. package/src/index.ts +3 -0
  33. package/src/log.ts +10 -3
  34. package/src/notices.ts +29 -0
  35. package/src/plugin.ts +32 -2
  36. package/src/rate-limit.ts +7 -0
  37. package/src/routes/api/cart.ts +84 -0
  38. package/src/routes/api/search.ts +49 -0
  39. package/src/routes/api/shared.ts +4 -0
  40. package/src/routes/api/state.ts +69 -0
  41. package/src/routes/buy.ts +4 -3
  42. package/src/routes/callback.ts +4 -2
  43. package/src/routes/cart-edit.ts +15 -66
  44. package/src/routes/cart.ts +4 -2
  45. package/src/routes/shared.ts +11 -0
  46. package/src/routes/webhook.ts +20 -2
  47. package/src/shopify.ts +37 -1
  48. package/src/storefront.ts +231 -59
  49. package/src/text.ts +43 -2
  50. package/src/version.ts +1 -1
@@ -2,95 +2,44 @@
2
2
  * POST /_emdash/api/auth/redeemid/cart-edit: change the visitor's Shopify cart.
3
3
  *
4
4
  * Fields: `action` (add | update | remove | discount), `return`, and
5
- * - add: `product` (handle), optional `variant` (must be one of that
6
- * product's variants, read again from Shopify), `quantity` (1 to 1000),
5
+ * - add: `product` (handle); the variant by `variant` (an id of that
6
+ * product's, read again from Shopify) or by one `option.<Name>` field per
7
+ * option (e.g. option.Size=M); `selling_plan` (one of the product's
8
+ * selling plans, or empty for one-time); `quantity` (1 to 1000);
7
9
  * `then` = "checkout" to go straight to checkout;
8
10
  * - update: `line` and `quantity` (0 removes the line);
9
11
  * - remove: `line`;
10
12
  * - discount: `code` (empty clears the codes).
11
13
  *
12
- * Prices, discounts and availability come from Shopify; nothing posted is
13
- * trusted beyond the ids it names. Back to `return` with a notice:
14
- * cart_added, cart_updated, discount_applied, discount_not_applicable,
15
- * plan_unavailable, cart_empty or checkout_failed.
14
+ * Back to `return` with a notice: cart_added, cart_updated,
15
+ * discount_applied, discount_not_applicable, plan_unavailable, cart_empty or
16
+ * checkout_failed. The JSON twin is api/cart.
16
17
  */
17
18
 
18
19
  import type { APIRoute } from "astro";
19
20
 
20
21
  import { safeReturn } from "../access.js";
21
- import { readCart, saveCart } from "../cart.js";
22
+ import { cartInputFromForm, editCart } from "../cart-ops.js";
23
+ import { checkoutFor } from "../checkout.js";
22
24
  import { readConfig, storeDomain } from "../config.js";
23
- import { memberForUser, stores } from "../storage.js";
24
- import { addToCart, cartDetails, product, removeLines, setDiscountCodes, updateLines } from "../storefront.js";
25
25
  import { fail } from "./shared.js";
26
26
 
27
27
  export const prerender = false;
28
28
 
29
- const MAX_QUANTITY = 1000;
30
-
31
29
  export const POST: APIRoute = async ({ request, locals, session, redirect }) => {
32
30
  const form = await request.formData().catch(() => null);
33
- const field = (k: string) => String(form?.get(k) ?? "").trim();
34
- const returnTo = safeReturn(field("return") || "/");
31
+ const returnTo = safeReturn(String(form?.get("return") ?? "").trim() || "/");
35
32
  const emdash = (locals as { emdash?: { config?: unknown; db?: unknown } }).emdash;
36
33
  const config = readConfig(emdash?.config);
37
34
  if (!config || !storeDomain(config)) return fail(redirect, returnTo, "not_configured");
38
- const buyerIp = request.headers.get("cf-connecting-ip");
39
- const action = field("action");
40
35
 
41
36
  try {
42
- if (action === "add") {
43
- const handle = field("product");
44
- const quantity = Number.parseInt(field("quantity") || "1", 10);
45
- if (!/^[a-z0-9][a-z0-9-]*$/i.test(handle) || !Number.isFinite(quantity) || quantity < 1 || quantity > MAX_QUANTITY) {
46
- return fail(redirect, returnTo, "plan_unavailable");
47
- }
48
- const p = await product(config, handle);
49
- const wanted = field("variant");
50
- const variant = wanted ? p?.variants.find((v) => v.id === wanted) : p?.variants.find((v) => v.availableForSale);
51
- if (!p || !variant?.availableForSale) return fail(redirect, returnTo, "plan_unavailable");
52
- const user = (await session?.get("user")) as { id?: string } | undefined;
53
- const member = user?.id && emdash?.db ? await memberForUser(stores(emdash.db), user.id) : null;
54
- const current = await readCart(session);
55
- const cart = await addToCart(
56
- config,
57
- current?.id ?? null,
58
- { variantId: variant.id, sellingPlanId: p.sellingPlanId, quantity },
59
- { email: member?.email ?? null, attributes: { _redeemid_page: returnTo }, buyerIp },
60
- );
61
- saveCart(session, { id: cart.id, count: cart.totalQuantity });
62
- return field("then") === "checkout" ? redirect(cart.checkoutUrl, 303) : fail(redirect, returnTo, "cart_added");
63
- }
64
-
65
- const ref = await readCart(session);
66
- if (!ref) return fail(redirect, returnTo, "cart_empty");
67
- const cart = await cartDetails(config, ref.id, { buyerIp });
68
- if (!cart) {
69
- saveCart(session, null);
70
- return fail(redirect, returnTo, "cart_empty");
71
- }
72
- const line = field("line");
73
- const owns = cart.lines.some((l) => l.id === line);
74
-
75
- if (action === "update" || action === "remove") {
76
- if (!owns) return fail(redirect, returnTo, "cart_updated");
77
- const quantity = action === "remove" ? 0 : Number.parseInt(field("quantity"), 10);
78
- if (!Number.isFinite(quantity) || quantity < 0 || quantity > MAX_QUANTITY) return fail(redirect, returnTo, "cart_updated");
79
- const next = quantity === 0 ? await removeLines(config, cart.id, [line], { buyerIp }) : await updateLines(config, cart.id, [{ id: line, quantity }], { buyerIp });
80
- saveCart(session, { id: next.id, count: next.totalQuantity });
81
- return fail(redirect, returnTo, "cart_updated");
82
- }
83
-
84
- if (action === "discount") {
85
- const code = field("code").slice(0, 100);
86
- const next = await setDiscountCodes(config, cart.id, code ? [code] : [], { buyerIp });
87
- saveCart(session, { id: next.id, count: next.totalQuantity });
88
- if (!code) return fail(redirect, returnTo, "cart_updated");
89
- const applied = next.discountCodes.find((d) => d.code.toLowerCase() === code.toLowerCase());
90
- return fail(redirect, returnTo, applied?.applicable ? "discount_applied" : "discount_not_applicable");
37
+ const input = { ...cartInputFromForm(form), page: returnTo };
38
+ const out = await editCart(input, { config, session, request, db: emdash?.db });
39
+ if (out.base && String(form?.get("then") ?? "") === "checkout") {
40
+ return redirect(await checkoutFor(config, out.base, { session, request, emdashConfig: emdash?.config }), 303);
91
41
  }
92
-
93
- return fail(redirect, returnTo, "cart_updated");
42
+ return fail(redirect, returnTo, out.notice);
94
43
  } catch {
95
44
  return fail(redirect, returnTo, "checkout_failed");
96
45
  }
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * GET /_emdash/api/auth/redeemid/cart?return=/path
3
3
  *
4
- * Sends the visitor to their Shopify cart's checkout. With no cart, or one
4
+ * Sends the visitor to their Shopify cart's checkout, signed in for members
5
+ * (see checkout.ts). With no cart, or one
5
6
  * Shopify has closed (its checkout completed), they go back with the
6
7
  * `cart_empty` notice and the stored cart is cleared.
7
8
  */
@@ -11,6 +12,7 @@ import type { APIRoute } from "astro";
11
12
  import { safeReturn } from "../access.js";
12
13
  import { readCart, saveCart } from "../cart.js";
13
14
  import { readConfig, storeDomain } from "../config.js";
15
+ import { checkoutFor } from "../checkout.js";
14
16
  import { getCart } from "../storefront.js";
15
17
  import { fail } from "./shared.js";
16
18
 
@@ -30,7 +32,7 @@ export const GET: APIRoute = async ({ request, locals, session, redirect }) => {
30
32
  return fail(redirect, returnTo, "cart_empty");
31
33
  }
32
34
  saveCart(session, { id: cart.id, count: cart.totalQuantity });
33
- return redirect(cart.checkoutUrl, 303);
35
+ return redirect(await checkoutFor(config, cart, { session, request, emdashConfig: (locals as { emdash?: { config?: unknown } }).emdash?.config }), 303);
34
36
  } catch {
35
37
  return fail(redirect, returnTo, "checkout_failed");
36
38
  }
@@ -2,6 +2,17 @@ import { getPublicOrigin } from "emdash/api/route-utils";
2
2
 
3
3
  export const SESSION_FLOW = "redeemid:flow";
4
4
  export const SESSION_ID_TOKEN = "redeemid:id_token";
5
+ /**
6
+ * The member's Customer Account API tokens, kept only in the server-side
7
+ * session, to open Shopify's checkout signed in. Never sent to the browser.
8
+ */
9
+ export const SESSION_CUSTOMER_TOKEN = "redeemid:customer_token";
10
+
11
+ export interface CustomerToken {
12
+ accessToken: string;
13
+ refreshToken: string | null;
14
+ expiresAt: number | null;
15
+ }
5
16
  /** A sign-in started more than this long ago is refused. */
6
17
  export const FLOW_TTL_MS = 10 * 60 * 1000;
7
18
 
@@ -19,6 +19,11 @@ import { apply, customerOf, kindOf, verifyHmac } from "../webhooks.js";
19
19
  export const prerender = false;
20
20
 
21
21
  const MAX_BYTES = 1024 * 1024;
22
+ /** A delivery still "pending" after this long never finished. */
23
+ const STALE_PENDING_MS = 60 * 1000;
24
+ /** At most one "Bad signature" entry per this long, so junk requests can't fill App events. */
25
+ const REJECT_LOG_MS = 60 * 1000;
26
+ let lastRejectLogged = 0;
22
27
 
23
28
  export const POST: APIRoute = async ({ request, locals }) => {
24
29
  const emdash = (locals as { emdash?: { db?: unknown; config?: unknown } }).emdash;
@@ -31,7 +36,10 @@ export const POST: APIRoute = async ({ request, locals }) => {
31
36
  const ok = await verifyHmac(body, request.headers.get("x-shopify-hmac-sha256") ?? "", secret);
32
37
  const topic = (request.headers.get("x-shopify-topic") ?? "").toLowerCase();
33
38
  if (!ok) {
34
- await record({ event: "webhook.rejected", label: topic || "unknown", status: "rejected", level: "warn", origin: "webhook", note: "Bad signature" });
39
+ if (Date.now() - lastRejectLogged > REJECT_LOG_MS) {
40
+ lastRejectLogged = Date.now();
41
+ await record({ event: "webhook.rejected", label: topic || "unknown", status: "rejected", level: "warn", origin: "webhook", note: "Bad signature" });
42
+ }
35
43
  return new Response("Bad signature", { status: 401 });
36
44
  }
37
45
 
@@ -51,7 +59,17 @@ export const POST: APIRoute = async ({ request, locals }) => {
51
59
  const customerId = topic === "shop/redact" ? null : customerOf(payload);
52
60
  const receivedAt = new Date().toISOString();
53
61
  if (delivery) {
54
- const claimed = await s.deliveries.compareAndSet(delivery, null, { topic, receivedAt, outcome: "pending", customerId });
62
+ const claim = () => s.deliveries.compareAndSet(delivery, null, { topic, receivedAt, outcome: "pending", customerId });
63
+ let claimed = await claim();
64
+ if (!claimed.applied) {
65
+ // A delivery left "pending" was cut off before it finished (a
66
+ // timeout, a crash): let Shopify's retry run it again.
67
+ const earlier = await s.deliveries.get(delivery);
68
+ if (earlier?.outcome === "pending" && Date.now() - Date.parse(earlier.receivedAt) > STALE_PENDING_MS) {
69
+ await s.deliveries.delete(delivery);
70
+ claimed = await claim();
71
+ }
72
+ }
55
73
  if (!claimed.applied) {
56
74
  await record({ event: "webhook.received", label: topic, status: "duplicate", origin: "webhook", customerId });
57
75
  return new Response("Duplicate", { status: 200 });
package/src/shopify.ts CHANGED
@@ -127,6 +127,42 @@ export function authorizeUrl(
127
127
  export interface Tokens {
128
128
  accessToken: string;
129
129
  idToken: string;
130
+ /** Present when Shopify issued one; used to renew the access token for a signed-in checkout. */
131
+ refreshToken?: string | null;
132
+ /** When the access token expires (ms since epoch), when Shopify said. */
133
+ expiresAt?: number | null;
134
+ }
135
+
136
+ function tokenTimes(data: Record<string, unknown>): { refreshToken: string | null; expiresAt: number | null } {
137
+ const ttl = typeof data.expires_in === "number" ? data.expires_in : Number(data.expires_in);
138
+ return {
139
+ refreshToken: typeof data.refresh_token === "string" ? data.refresh_token : null,
140
+ expiresAt: Number.isFinite(ttl) && ttl > 0 ? Date.now() + ttl * 1000 : null,
141
+ };
142
+ }
143
+
144
+ /**
145
+ * Renew a Customer Account API access token with its refresh token. Throws
146
+ * when Shopify refuses (the member's store session ended).
147
+ */
148
+ export async function refreshTokens(
149
+ d: Discovery,
150
+ args: { clientId: string; clientSecret: string; origin?: string; refreshToken: string },
151
+ fetcher: typeof fetch = fetch,
152
+ ): Promise<{ accessToken: string; refreshToken: string | null; expiresAt: number | null }> {
153
+ const body = new URLSearchParams({ grant_type: "refresh_token", client_id: args.clientId, refresh_token: args.refreshToken });
154
+ const headers: Record<string, string> = {
155
+ "Content-Type": "application/x-www-form-urlencoded",
156
+ Accept: "application/json",
157
+ "User-Agent": USER_AGENT,
158
+ };
159
+ if (args.origin) headers.Origin = args.origin;
160
+ if (args.clientSecret) headers.Authorization = `Basic ${btoa(`${args.clientId}:${args.clientSecret}`)}`;
161
+ const res = await fetcher(d.tokenEndpoint, { method: "POST", headers, body });
162
+ const data = (await res.json().catch(() => ({}))) as Record<string, unknown>;
163
+ if (!res.ok || typeof data.access_token !== "string") throw new ShopifyError("token_failed", `Token refresh failed (${res.status}).`);
164
+ const t = tokenTimes(data);
165
+ return { accessToken: data.access_token, refreshToken: t.refreshToken ?? args.refreshToken, expiresAt: t.expiresAt };
130
166
  }
131
167
 
132
168
  export async function exchangeCode(
@@ -155,7 +191,7 @@ export async function exchangeCode(
155
191
  if (!res.ok || typeof data.access_token !== "string" || typeof data.id_token !== "string") {
156
192
  throw new ShopifyError("token_failed", `Token exchange failed (${res.status}).`);
157
193
  }
158
- return { accessToken: data.access_token, idToken: data.id_token };
194
+ return { accessToken: data.access_token, idToken: data.id_token, ...tokenTimes(data) };
159
195
  }
160
196
 
161
197
  /** Verify the ID token's signature, issuer, audience, expiry and nonce. */
package/src/storefront.ts CHANGED
@@ -30,12 +30,61 @@ export function resetStorefrontCache(): void {
30
30
  shopCache.clear();
31
31
  }
32
32
 
33
+ /**
34
+ * Where the buyer is, for local prices and currency (Shopify Markets). Both
35
+ * are optional; Shopify uses the store's defaults without them.
36
+ */
37
+ export interface StoreContext {
38
+ /** ISO 3166 two-letter country code, e.g. "CA". */
39
+ country?: string | null;
40
+ /** Shopify language code, e.g. "FR" or "PT_BR". */
41
+ language?: string | null;
42
+ }
43
+
44
+ /** A clean country code, or null. */
45
+ export function countryCode(raw: unknown): string | null {
46
+ const c = typeof raw === "string" ? raw.trim().toUpperCase() : "";
47
+ return /^[A-Z]{2}$/.test(c) && c !== "XX" && c !== "T1" ? c : null;
48
+ }
49
+
50
+ function languageCode(raw: unknown): string | null {
51
+ const l = typeof raw === "string" ? raw.trim().toUpperCase().replace("-", "_") : "";
52
+ return /^[A-Z]{2}(_[A-Z]{2})?$/.test(l) ? l : null;
53
+ }
54
+
55
+ /**
56
+ * The buyer's context from config `market`: a fixed country, or "visitor"
57
+ * for the country Cloudflare reports for the request (CF-IPCountry).
58
+ */
59
+ export function storeContext(config: RedeemIDConfig | null | undefined, request?: Request | null): StoreContext {
60
+ const m = config?.market;
61
+ if (!m) return {};
62
+ const country = m.country === "visitor" ? countryCode(request?.headers.get("cf-ipcountry")) : countryCode(m.country);
63
+ return { country, language: languageCode(m.language) };
64
+ }
65
+
66
+ function contextKey(ctx: StoreContext | undefined): string {
67
+ return `${ctx?.country ?? ""}|${ctx?.language ?? ""}`;
68
+ }
69
+
70
+ /** Add Shopify's @inContext directive to an operation, when there is a context. */
71
+ export function withContext(query: string, ctx: StoreContext | undefined): string {
72
+ const parts: string[] = [];
73
+ const country = countryCode(ctx?.country);
74
+ const language = languageCode(ctx?.language);
75
+ if (country) parts.push(`country: ${country}`);
76
+ if (language) parts.push(`language: ${language}`);
77
+ if (!parts.length) return query;
78
+ return query.replace(/^(\s*(?:query|mutation)\s+\w+(?:\([^)]*\))?)/, `$1 @inContext(${parts.join(", ")})`);
79
+ }
80
+
33
81
  export async function storefront<T>(
34
82
  config: RedeemIDConfig,
35
83
  query: string,
36
84
  variables: Record<string, unknown> = {},
37
- opts: { buyerIp?: string | null; fetcher?: typeof fetch } = {},
85
+ opts: { buyerIp?: string | null; fetcher?: typeof fetch; context?: StoreContext } = {},
38
86
  ): Promise<T> {
87
+ query = withContext(query, opts.context);
39
88
  const headers: Record<string, string> = {
40
89
  "Content-Type": "application/json",
41
90
  Accept: "application/json",
@@ -177,6 +226,8 @@ export async function addToCart(
177
226
  attributes?: Record<string, string>;
178
227
  buyerIp?: string | null;
179
228
  fetcher?: typeof fetch;
229
+ /** A new cart's country, for local prices. */
230
+ context?: StoreContext;
180
231
  } = {},
181
232
  ): Promise<Cart> {
182
233
  const quantity = Math.max(1, Math.floor(line.quantity ?? 1));
@@ -186,7 +237,11 @@ export async function addToCart(
186
237
  if (data?.cartLinesAdd.cart) return data.cartLinesAdd.cart;
187
238
  }
188
239
  const input: Record<string, unknown> = { lines };
189
- if (opts.email) input.buyerIdentity = { email: opts.email };
240
+ const identity: Record<string, string> = {};
241
+ if (opts.email) identity.email = opts.email;
242
+ const country = countryCode(opts.context?.country);
243
+ if (country) identity.countryCode = country;
244
+ if (Object.keys(identity).length) input.buyerIdentity = identity;
190
245
  const attributes = Object.entries(opts.attributes ?? {}).filter(([, v]) => v);
191
246
  if (attributes.length) input.attributes = attributes.map(([key, value]) => ({ key, value }));
192
247
  const data = await storefront<{ cartCreate: CartResult }>(config, CART_NEW, { input }, opts);
@@ -212,17 +267,57 @@ export interface Variant {
212
267
  title: string;
213
268
  availableForSale: boolean;
214
269
  price: Money;
270
+ /** The price before a sale, when Shopify has one above `price`. */
271
+ compareAtPrice?: Money | null;
272
+ sku?: string | null;
273
+ image?: Image | null;
274
+ /** Option name and value pairs, e.g. [{ name: "Size", value: "M" }]. */
275
+ selectedOptions?: Array<{ name: string; value: string }>;
276
+ }
277
+
278
+ export interface SellingPlan {
279
+ id: string;
280
+ name: string;
281
+ /** The group's name, e.g. "Subscribe and save". */
282
+ group: string;
215
283
  }
216
284
 
217
285
  export interface Product {
286
+ /** Shopify's product id (gid). */
287
+ id?: string;
218
288
  handle: string;
219
289
  title: string;
220
290
  description: string;
291
+ /** Shopify's HTML description, as the merchant wrote it. */
292
+ descriptionHtml?: string;
293
+ vendor?: string;
294
+ productType?: string;
221
295
  availableForSale: boolean;
222
296
  image: Image | null;
297
+ /** Every image, featured first (up to 20). */
298
+ images?: Image[];
299
+ /** The product's options and their values, e.g. Size: S, M, L. Absent or one "Title" option means none. */
300
+ options?: Array<{ name: string; values: string[] }>;
223
301
  variants: Variant[];
224
302
  /** The first selling plan, for subscription-only products. */
225
303
  sellingPlanId: string | null;
304
+ /** True when the product can only be bought on a selling plan. */
305
+ requiresSellingPlan?: boolean;
306
+ /** Every selling plan offered (subscriptions, pre-orders), in Shopify's order. */
307
+ sellingPlans?: SellingPlan[];
308
+ seo?: { title: string | null; description: string | null };
309
+ }
310
+
311
+ /** Real options to choose from: none for a product with only Shopify's "Default Title". */
312
+ export function realOptions(p: Product): Array<{ name: string; values: string[] }> {
313
+ const opts = p.options ?? [];
314
+ if (opts.length === 1 && opts[0]?.values.length === 1 && opts[0].values[0] === "Default Title") return [];
315
+ return opts.filter((o) => o.values.length > 0);
316
+ }
317
+
318
+ /** The variant matching the chosen option values, or undefined. */
319
+ export function variantFor(p: Product, chosen: Record<string, string>): Variant | undefined {
320
+ return p.variants.find((v) => (v.selectedOptions ?? []).every((o) => chosen[o.name] === o.value));
226
321
  }
227
322
 
228
323
  export interface ProductCard {
@@ -232,6 +327,10 @@ export interface ProductCard {
232
327
  image: Image | null;
233
328
  /** The lowest variant price. */
234
329
  price: Money | null;
330
+ /** The lowest compare-at price, when the product is on sale. */
331
+ compareAtPrice?: Money | null;
332
+ /** The variant to add straight from a card: set only when the product has exactly one and needs no selling plan. */
333
+ quickAddVariantId?: string | null;
235
334
  }
236
335
 
237
336
  export interface Collection {
@@ -243,18 +342,53 @@ export interface Collection {
243
342
 
244
343
  const PRODUCT_QUERY = `query Product($handle: String!) {
245
344
  product(handle: $handle) {
246
- handle title description availableForSale requiresSellingPlan
345
+ id handle title description descriptionHtml vendor productType availableForSale requiresSellingPlan
247
346
  featuredImage { url altText }
248
- variants(first: 50) { nodes { id title availableForSale price { amount currencyCode } } }
249
- sellingPlanGroups(first: 1) { nodes { sellingPlans(first: 1) { nodes { id } } } }
347
+ images(first: 20) { nodes { url altText } }
348
+ options { name optionValues { name } }
349
+ variants(first: 100) { nodes { id title availableForSale sku price { amount currencyCode } compareAtPrice { amount currencyCode } image { url altText } selectedOptions { name value } } }
350
+ sellingPlanGroups(first: 5) { nodes { name sellingPlans(first: 10) { nodes { id name } } } }
351
+ seo { title description }
250
352
  }
251
353
  }`;
252
354
 
355
+ /** The fields a product card needs. */
356
+ export const CARD_FIELDS = `handle title availableForSale requiresSellingPlan featuredImage { url altText }
357
+ priceRange { minVariantPrice { amount currencyCode } }
358
+ compareAtPriceRange { minVariantPrice { amount currencyCode } }
359
+ variants(first: 2) { nodes { id availableForSale } }`;
360
+
361
+ export type RawCard = {
362
+ handle: string;
363
+ title: string;
364
+ availableForSale: boolean;
365
+ requiresSellingPlan?: boolean;
366
+ featuredImage: Image | null;
367
+ priceRange?: { minVariantPrice: Money } | null;
368
+ compareAtPriceRange?: { minVariantPrice: Money } | null;
369
+ variants?: { nodes: Array<{ id: string; availableForSale: boolean }> };
370
+ };
371
+
372
+ export function toCard(p: RawCard): ProductCard {
373
+ const price = p.priceRange?.minVariantPrice ?? null;
374
+ const compare = p.compareAtPriceRange?.minVariantPrice ?? null;
375
+ const only = p.variants?.nodes.length === 1 ? p.variants.nodes[0] : null;
376
+ return {
377
+ handle: p.handle,
378
+ title: p.title,
379
+ availableForSale: p.availableForSale,
380
+ image: p.featuredImage ?? null,
381
+ price,
382
+ compareAtPrice: compare && price && Number(compare.amount) > Number(price.amount) ? compare : null,
383
+ quickAddVariantId: only?.availableForSale && !p.requiresSellingPlan ? only.id : null,
384
+ };
385
+ }
386
+
253
387
  const COLLECTION_QUERY = `query Collection($handle: String!, $first: Int!) {
254
388
  collection(handle: $handle) {
255
389
  handle title description
256
390
  products(first: $first) {
257
- nodes { handle title availableForSale featuredImage { url altText } priceRange { minVariantPrice { amount currencyCode } } }
391
+ nodes { ${CARD_FIELDS} }
258
392
  }
259
393
  }
260
394
  }`;
@@ -269,66 +403,74 @@ async function cached<T>(key: string, load: () => Promise<T>): Promise<T> {
269
403
  return value;
270
404
  }
271
405
 
272
- /** A product with its variants, cached for five minutes. Null when Shopify has no such product. */
273
- export async function product(config: RedeemIDConfig, handle: string, fetcher?: typeof fetch): Promise<Product | null> {
274
- return cached(`${shopOrigin(config)}|product|${handle}`, async () => {
275
- type Data = {
276
- product: {
277
- handle: string;
278
- title: string;
279
- description: string;
280
- availableForSale: boolean;
281
- requiresSellingPlan: boolean;
282
- featuredImage: Image | null;
283
- variants: { nodes: Variant[] };
284
- sellingPlanGroups: { nodes: Array<{ sellingPlans: { nodes: Array<{ id: string }> } }> };
285
- } | null;
286
- };
287
- const { product: p } = await storefront<Data>(config, PRODUCT_QUERY, { handle }, { fetcher });
288
- if (!p) return null;
289
- return {
290
- handle: p.handle,
291
- title: p.title,
292
- description: p.description,
293
- availableForSale: p.availableForSale,
294
- image: p.featuredImage ?? null,
295
- variants: p.variants.nodes,
296
- sellingPlanId: p.requiresSellingPlan ? (p.sellingPlanGroups.nodes[0]?.sellingPlans.nodes[0]?.id ?? null) : null,
297
- };
406
+ type RawProduct = {
407
+ id: string;
408
+ handle: string;
409
+ title: string;
410
+ description: string;
411
+ descriptionHtml?: string;
412
+ vendor?: string;
413
+ productType?: string;
414
+ availableForSale: boolean;
415
+ requiresSellingPlan: boolean;
416
+ featuredImage: Image | null;
417
+ images?: { nodes: Image[] };
418
+ options?: Array<{ name: string; optionValues?: Array<{ name: string }> }>;
419
+ variants: { nodes: Variant[] };
420
+ sellingPlanGroups: { nodes: Array<{ name?: string; sellingPlans: { nodes: Array<{ id: string; name?: string }> } }> };
421
+ seo?: { title: string | null; description: string | null } | null;
422
+ };
423
+
424
+ function toProduct(p: RawProduct): Product {
425
+ const plans: SellingPlan[] = p.sellingPlanGroups.nodes.flatMap((g) => g.sellingPlans.nodes.map((sp) => ({ id: sp.id, name: sp.name ?? "", group: g.name ?? "" })));
426
+ const images = p.images?.nodes ?? [];
427
+ const featured = p.featuredImage ?? null;
428
+ return {
429
+ id: p.id,
430
+ handle: p.handle,
431
+ title: p.title,
432
+ description: p.description,
433
+ descriptionHtml: p.descriptionHtml ?? "",
434
+ vendor: p.vendor ?? "",
435
+ productType: p.productType ?? "",
436
+ availableForSale: p.availableForSale,
437
+ image: featured,
438
+ images: featured ? [featured, ...images.filter((i) => i.url !== featured.url)] : images,
439
+ options: (p.options ?? []).map((o) => ({ name: o.name, values: (o.optionValues ?? []).map((v) => v.name) })),
440
+ variants: p.variants.nodes,
441
+ sellingPlanId: p.requiresSellingPlan ? (plans[0]?.id ?? null) : null,
442
+ requiresSellingPlan: p.requiresSellingPlan,
443
+ sellingPlans: plans,
444
+ seo: p.seo ?? { title: null, description: null },
445
+ };
446
+ }
447
+
448
+ /** A product with its variants, options, images and selling plans, cached for five minutes. Null when Shopify has no such product. */
449
+ export async function product(config: RedeemIDConfig, handle: string, fetcher?: typeof fetch, context?: StoreContext): Promise<Product | null> {
450
+ return cached(`${shopOrigin(config)}|product|${handle}|${contextKey(context)}`, async () => {
451
+ const { product: p } = await storefront<{ product: RawProduct | null }>(config, PRODUCT_QUERY, { handle }, { fetcher, context });
452
+ return p ? toProduct(p) : null;
298
453
  });
299
454
  }
300
455
 
301
456
  /** A collection's first `first` products, cached for five minutes. Null when Shopify has no such collection. */
302
- export async function collection(config: RedeemIDConfig, handle: string, first = 12, fetcher?: typeof fetch): Promise<Collection | null> {
457
+ export async function collection(config: RedeemIDConfig, handle: string, first = 12, fetcher?: typeof fetch, context?: StoreContext): Promise<Collection | null> {
303
458
  const n = Math.max(1, Math.min(50, Math.floor(first)));
304
- return cached(`${shopOrigin(config)}|collection|${handle}|${n}`, async () => {
305
- type Data = {
306
- collection: {
307
- handle: string;
308
- title: string;
309
- description: string;
310
- products: {
311
- nodes: Array<{ handle: string; title: string; availableForSale: boolean; featuredImage: Image | null; priceRange: { minVariantPrice: Money } }>;
312
- };
313
- } | null;
314
- };
315
- const { collection: c } = await storefront<Data>(config, COLLECTION_QUERY, { handle, first: n }, { fetcher });
459
+ return cached(`${shopOrigin(config)}|collection|${handle}|${n}|${contextKey(context)}`, async () => {
460
+ type Data = { collection: { handle: string; title: string; description: string; products: { nodes: RawCard[] } } | null };
461
+ const { collection: c } = await storefront<Data>(config, COLLECTION_QUERY, { handle, first: n }, { fetcher, context });
316
462
  if (!c) return null;
317
- return {
318
- handle: c.handle,
319
- title: c.title,
320
- description: c.description,
321
- products: c.products.nodes.map((p) => ({
322
- handle: p.handle,
323
- title: p.title,
324
- availableForSale: p.availableForSale,
325
- image: p.featuredImage ?? null,
326
- price: p.priceRange?.minVariantPrice ?? null,
327
- })),
328
- };
463
+ return { handle: c.handle, title: c.title, description: c.description, products: c.products.nodes.map(toCard) };
329
464
  });
330
465
  }
331
466
 
467
+ /** Cache a Storefront read for five minutes, by key. Used by the catalog module. */
468
+ export function cachedRead<T>(key: string, load: () => Promise<T>): Promise<T> {
469
+ return cached(key, load);
470
+ }
471
+
472
+ export { contextKey };
473
+
332
474
  /** Format Shopify money for display. */
333
475
  export function money(m: Money | null | undefined, locale = "en-US"): string | null {
334
476
  if (!m) return null;
@@ -349,6 +491,10 @@ export interface CartLine {
349
491
  image: Image | null;
350
492
  price: Money | null;
351
493
  total: Money | null;
494
+ /** The selling plan the line is bought on (a subscription), by name. */
495
+ sellingPlan?: string | null;
496
+ /** The line's price per item before a sale, when there is one. */
497
+ compareAtPrice?: Money | null;
352
498
  }
353
499
 
354
500
  export interface CartDetails extends Cart {
@@ -363,7 +509,8 @@ const CART_DETAIL_FIELDS = `${CART_FIELDS}
363
509
  discountCodes { code applicable }
364
510
  lines(first: 100) { nodes {
365
511
  id quantity
366
- cost { totalAmount { amount currencyCode } }
512
+ cost { totalAmount { amount currencyCode } compareAtAmountPerQuantity { amount currencyCode } }
513
+ sellingPlanAllocation { sellingPlan { name } }
367
514
  merchandise { ... on ProductVariant { id title price { amount currencyCode } image { url altText } product { title handle featuredImage { url altText } } } }
368
515
  } }`;
369
516
 
@@ -374,7 +521,8 @@ type RawCart = Cart & {
374
521
  nodes: Array<{
375
522
  id: string;
376
523
  quantity: number;
377
- cost?: { totalAmount?: Money | null } | null;
524
+ cost?: { totalAmount?: Money | null; compareAtAmountPerQuantity?: Money | null } | null;
525
+ sellingPlanAllocation?: { sellingPlan?: { name?: string } | null } | null;
378
526
  merchandise: {
379
527
  id: string;
380
528
  title: string;
@@ -404,6 +552,8 @@ function details(c: RawCart): CartDetails {
404
552
  image: l.merchandise.image ?? l.merchandise.product.featuredImage ?? null,
405
553
  price: l.merchandise.price ?? null,
406
554
  total: l.cost?.totalAmount ?? null,
555
+ sellingPlan: l.sellingPlanAllocation?.sellingPlan?.name ?? null,
556
+ compareAtPrice: l.cost?.compareAtAmountPerQuantity ?? null,
407
557
  })),
408
558
  };
409
559
  }
@@ -471,3 +621,25 @@ export async function setDiscountCodes(
471
621
  );
472
622
  return result(data.cartDiscountCodesUpdate);
473
623
  }
624
+
625
+ /**
626
+ * Sign the cart in as the member, with their Customer Account API access
627
+ * token, so Shopify's checkout opens signed in and the order lands on their
628
+ * customer. Returns the cart's fresh checkout URL, or null when Shopify
629
+ * refused (the visitor then checks out as a guest).
630
+ */
631
+ export async function signInCart(
632
+ config: RedeemIDConfig,
633
+ cartId: string,
634
+ customerAccessToken: string,
635
+ opts: { buyerIp?: string | null; fetcher?: typeof fetch } = {},
636
+ ): Promise<string | null> {
637
+ const data = await storefront<{ cartBuyerIdentityUpdate: { cart: { checkoutUrl: string } | null; userErrors: Array<{ message: string }> } }>(
638
+ config,
639
+ `mutation CartIdentity($id: ID!, $identity: CartBuyerIdentityInput!) { cartBuyerIdentityUpdate(cartId: $id, buyerIdentity: $identity) { cart { checkoutUrl } userErrors { message } } }`,
640
+ { id: cartId, identity: { customerAccessToken } },
641
+ opts,
642
+ ).catch(() => null);
643
+ const cart = data?.cartBuyerIdentityUpdate.cart;
644
+ return cart && !data?.cartBuyerIdentityUpdate.userErrors.length ? cart.checkoutUrl : null;
645
+ }