@redeemid/emdash 0.0.16 → 0.0.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +447 -232
- package/bin/redeemid-emdash.mjs +13 -1
- package/docs/developer-api.md +126 -0
- package/docs/shopify-setup.md +1 -1
- package/package.json +3 -1
- package/src/access-admin.ts +754 -0
- package/src/access.ts +84 -18
- package/src/admin-api.ts +9 -2
- package/src/agents.ts +62 -3
- package/src/api-version.ts +6 -0
- package/src/blocks/CollectionBlock.astro +4 -1
- package/src/blocks/SearchBlock.astro +14 -0
- package/src/blocks/index.ts +2 -0
- package/src/blocks-names.ts +1 -0
- package/src/cart-ops.ts +164 -0
- package/src/catalog.ts +287 -0
- package/src/checkout.ts +73 -0
- package/src/client/redeemid.ts +563 -0
- package/src/components/AccessBadge.astro +7 -5
- package/src/components/Account.astro +1 -1
- package/src/components/BrowseControls.astro +87 -0
- package/src/components/Cart.astro +12 -6
- package/src/components/CartDrawer.astro +59 -0
- package/src/components/CartView.astro +2 -1
- package/src/components/Collection.astro +23 -18
- package/src/components/Content.astro +10 -5
- package/src/components/Contribution.astro +1 -1
- package/src/components/Gate.astro +4 -2
- package/src/components/LockedPanel.astro +5 -3
- package/src/components/MemberCard.astro +1 -1
- package/src/components/Notices.astro +3 -28
- package/src/components/Plan.astro +1 -1
- package/src/components/Preferences.astro +1 -1
- package/src/components/Product.astro +116 -25
- package/src/components/ProductGrid.astro +52 -0
- package/src/components/ProductSearch.astro +83 -0
- package/src/components/Recommendations.astro +45 -0
- package/src/components/RedeemIDClient.astro +45 -0
- package/src/components/YourData.astro +1 -1
- package/src/config.ts +31 -5
- package/src/contract.ts +50 -0
- package/src/descriptor.ts +7 -0
- package/src/events.ts +3 -0
- package/src/features.ts +1 -1
- package/src/files.ts +1 -1
- package/src/guard.ts +5 -3
- package/src/index.ts +3 -0
- package/src/log.ts +96 -0
- package/src/monitor.ts +560 -0
- package/src/notices.ts +29 -0
- package/src/plugin.ts +234 -88
- package/src/rate-limit.ts +7 -0
- package/src/routes/api/cart.ts +84 -0
- package/src/routes/api/search.ts +49 -0
- package/src/routes/api/shared.ts +4 -0
- package/src/routes/api/state.ts +69 -0
- package/src/routes/buy.ts +4 -3
- package/src/routes/callback.ts +23 -5
- package/src/routes/cart-edit.ts +15 -66
- package/src/routes/cart.ts +4 -2
- package/src/routes/preferences.ts +2 -2
- package/src/routes/privacy.ts +2 -2
- package/src/routes/shared.ts +11 -0
- package/src/routes/webhook.ts +24 -11
- package/src/settings.ts +195 -0
- package/src/shopify.ts +37 -1
- package/src/site-config.ts +38 -5
- package/src/staff.ts +12 -23
- package/src/storage.ts +12 -0
- package/src/storefront.ts +231 -59
- package/src/text.ts +43 -2
- package/src/version.ts +1 -1
- package/src/webhooks.ts +2 -1
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
|
-
|
|
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
|
-
|
|
249
|
-
|
|
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 {
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
|
|
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: [
|
|
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.
|
|
1
|
+
export const VERSION = "0.0.18";
|
package/src/webhooks.ts
CHANGED
|
@@ -75,7 +75,8 @@ function str(raw: unknown): string | null {
|
|
|
75
75
|
return typeof raw === "string" && raw.trim() !== "" ? raw.trim() : null;
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
|
|
78
|
+
/** The Shopify customer a payload is about, when it names one. */
|
|
79
|
+
export function customerOf(payload: Record<string, unknown>): string | null {
|
|
79
80
|
const nested = (payload.customer as { id?: unknown } | undefined)?.id;
|
|
80
81
|
for (const c of [payload.id, payload.customerId, payload.customer_id, nested]) {
|
|
81
82
|
const id = numericId(c);
|