@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.
Files changed (46) 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/index.ts +3 -0
  31. package/src/notices.ts +29 -0
  32. package/src/plugin.ts +32 -2
  33. package/src/rate-limit.ts +7 -0
  34. package/src/routes/api/cart.ts +84 -0
  35. package/src/routes/api/search.ts +49 -0
  36. package/src/routes/api/shared.ts +4 -0
  37. package/src/routes/api/state.ts +69 -0
  38. package/src/routes/buy.ts +4 -3
  39. package/src/routes/callback.ts +2 -1
  40. package/src/routes/cart-edit.ts +15 -66
  41. package/src/routes/cart.ts +4 -2
  42. package/src/routes/shared.ts +11 -0
  43. package/src/shopify.ts +37 -1
  44. package/src/storefront.ts +231 -59
  45. package/src/text.ts +43 -2
  46. package/src/version.ts +1 -1
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
+ }
package/src/text.ts CHANGED
@@ -41,7 +41,7 @@ export const STRINGS = {
41
41
  "Taxes and shipping are worked out at checkout.",
42
42
  "Checkout",
43
43
  ],
44
- collection: ["From {price}", "Sold out"],
44
+ collection: ["From {price}", "Sold out", "Add to cart"],
45
45
  contribution: ["Give", "Another amount ({currency})", "Quantity ({price} each)"],
46
46
  meter: ["This is your last free read this week.", "This is your last free read this month.", "You have 1 free read left this week.", "You have 1 free read left this month.", "You have {count} free reads left this week.", "You have {count} free reads left this month."],
47
47
  wall: [
@@ -113,7 +113,48 @@ export const STRINGS = {
113
113
  ],
114
114
  plan: ["Join", "You're on this plan.", "Not available right now."],
115
115
  preferences: ["Your preferences", "Save preferences", "Your preferences can't be loaded right now. Try again in a few minutes.", "Not set", "Yes", "No", "One per line."],
116
- product: ["Add to cart", "Buy now", "Option", "{option} ({price})", "{option} ({price}) sold out", "Quantity", "Sold out."],
116
+ product: [
117
+ "Add to cart",
118
+ "Buy now",
119
+ "Quantity",
120
+ "Sold out.",
121
+ "Image {number} of {count}",
122
+ "Purchase options",
123
+ "One-time purchase",
124
+ "{group}: {plan}",
125
+ "Not available in this combination.",
126
+ "Pair it with",
127
+ "You may also like",
128
+ ],
129
+ drawer: ["Your cart", "Close", "Loading your cart…"],
130
+ search: [
131
+ "Search products",
132
+ "Search",
133
+ "{count} suggestions",
134
+ "Search isn't available right now. Try again in a few minutes.",
135
+ "No products match “{query}”.",
136
+ "1 result for “{query}”",
137
+ "{count} results for “{query}”",
138
+ "Sort and filter",
139
+ "Sort by",
140
+ "Featured",
141
+ "Relevance",
142
+ "Best selling",
143
+ "Newest",
144
+ "Price, low to high",
145
+ "Price, high to low",
146
+ "Title, A to Z",
147
+ "Title, Z to A",
148
+ "{label} ({count})",
149
+ "Price",
150
+ "From",
151
+ "To",
152
+ "Apply",
153
+ "Clear filters",
154
+ "Pages",
155
+ "Previous page",
156
+ "Next page",
157
+ ],
117
158
  files: ["File not available", "Sign in to download this file.", "Sign in", "Your membership doesn't include this file."],
118
159
  } as const;
119
160
 
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.0.17";
1
+ export const VERSION = "0.0.18";