@redeemid/emdash 0.0.6 → 0.0.8

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/src/storefront.ts CHANGED
@@ -27,6 +27,7 @@ const planCache = new Map<string, { at: number; value: Plan | null }>();
27
27
 
28
28
  export function resetStorefrontCache(): void {
29
29
  planCache.clear();
30
+ shopCache.clear();
30
31
  }
31
32
 
32
33
  export async function storefront<T>(
@@ -169,18 +170,304 @@ export async function getCart(
169
170
  export async function addToCart(
170
171
  config: RedeemIDConfig,
171
172
  cartId: string | null,
172
- line: { variantId: string; sellingPlanId?: string | null },
173
- opts: { email?: string | null; buyerIp?: string | null; fetcher?: typeof fetch } = {},
173
+ line: { variantId: string; sellingPlanId?: string | null; quantity?: number },
174
+ opts: {
175
+ email?: string | null;
176
+ /** Cart attributes for a new cart, e.g. where the buyer came from. Keys starting "_" are hidden at checkout. */
177
+ attributes?: Record<string, string>;
178
+ buyerIp?: string | null;
179
+ fetcher?: typeof fetch;
180
+ } = {},
174
181
  ): Promise<Cart> {
175
- const lines = [{ merchandiseId: line.variantId, quantity: 1, ...(line.sellingPlanId ? { sellingPlanId: line.sellingPlanId } : {}) }];
182
+ const quantity = Math.max(1, Math.floor(line.quantity ?? 1));
183
+ const lines = [{ merchandiseId: line.variantId, quantity, ...(line.sellingPlanId ? { sellingPlanId: line.sellingPlanId } : {}) }];
176
184
  if (cartId) {
177
185
  const data = await storefront<{ cartLinesAdd: CartResult }>(config, CART_ADD, { id: cartId, lines }, opts).catch(() => null);
178
186
  if (data?.cartLinesAdd.cart) return data.cartLinesAdd.cart;
179
187
  }
180
188
  const input: Record<string, unknown> = { lines };
181
189
  if (opts.email) input.buyerIdentity = { email: opts.email };
190
+ const attributes = Object.entries(opts.attributes ?? {}).filter(([, v]) => v);
191
+ if (attributes.length) input.attributes = attributes.map(([key, value]) => ({ key, value }));
182
192
  const data = await storefront<{ cartCreate: CartResult }>(config, CART_NEW, { input }, opts);
183
193
  const cart = data.cartCreate.cart;
184
194
  if (!cart) throw new Error(data.cartCreate.userErrors[0]?.message ?? "Shopify returned no cart.");
185
195
  return cart;
186
196
  }
197
+
198
+ // ---------------------------------------------------------------- shop
199
+
200
+ export interface Money {
201
+ amount: string;
202
+ currencyCode: string;
203
+ }
204
+
205
+ export interface Image {
206
+ url: string;
207
+ altText: string | null;
208
+ }
209
+
210
+ export interface Variant {
211
+ id: string;
212
+ title: string;
213
+ availableForSale: boolean;
214
+ price: Money;
215
+ }
216
+
217
+ export interface Product {
218
+ handle: string;
219
+ title: string;
220
+ description: string;
221
+ availableForSale: boolean;
222
+ image: Image | null;
223
+ variants: Variant[];
224
+ /** The first selling plan, for subscription-only products. */
225
+ sellingPlanId: string | null;
226
+ }
227
+
228
+ export interface ProductCard {
229
+ handle: string;
230
+ title: string;
231
+ availableForSale: boolean;
232
+ image: Image | null;
233
+ /** The lowest variant price. */
234
+ price: Money | null;
235
+ }
236
+
237
+ export interface Collection {
238
+ handle: string;
239
+ title: string;
240
+ description: string;
241
+ products: ProductCard[];
242
+ }
243
+
244
+ const PRODUCT_QUERY = `query Product($handle: String!) {
245
+ product(handle: $handle) {
246
+ handle title description availableForSale requiresSellingPlan
247
+ featuredImage { url altText }
248
+ variants(first: 50) { nodes { id title availableForSale price { amount currencyCode } } }
249
+ sellingPlanGroups(first: 1) { nodes { sellingPlans(first: 1) { nodes { id } } } }
250
+ }
251
+ }`;
252
+
253
+ const COLLECTION_QUERY = `query Collection($handle: String!, $first: Int!) {
254
+ collection(handle: $handle) {
255
+ handle title description
256
+ products(first: $first) {
257
+ nodes { handle title availableForSale featuredImage { url altText } priceRange { minVariantPrice { amount currencyCode } } }
258
+ }
259
+ }
260
+ }`;
261
+
262
+ const shopCache = new Map<string, { at: number; value: unknown }>();
263
+
264
+ async function cached<T>(key: string, load: () => Promise<T>): Promise<T> {
265
+ const hit = shopCache.get(key);
266
+ if (hit && Date.now() - hit.at < PLAN_TTL_MS) return hit.value as T;
267
+ const value = await load();
268
+ shopCache.set(key, { at: Date.now(), value });
269
+ return value;
270
+ }
271
+
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
+ };
298
+ });
299
+ }
300
+
301
+ /** 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> {
303
+ 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 });
316
+ 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
+ };
329
+ });
330
+ }
331
+
332
+ /** Format Shopify money for display. */
333
+ export function money(m: Money | null | undefined, locale = "en-US"): string | null {
334
+ if (!m) return null;
335
+ const n = Number(m.amount);
336
+ if (!Number.isFinite(n)) return null;
337
+ return new Intl.NumberFormat(locale, { style: "currency", currency: m.currencyCode }).format(n);
338
+ }
339
+
340
+ // ---------------------------------------------------------------- cart details
341
+
342
+ export interface CartLine {
343
+ id: string;
344
+ quantity: number;
345
+ variantId: string;
346
+ variantTitle: string;
347
+ productTitle: string;
348
+ productHandle: string;
349
+ image: Image | null;
350
+ price: Money | null;
351
+ total: Money | null;
352
+ }
353
+
354
+ export interface CartDetails extends Cart {
355
+ lines: CartLine[];
356
+ subtotal: Money | null;
357
+ total: Money | null;
358
+ discountCodes: Array<{ code: string; applicable: boolean }>;
359
+ }
360
+
361
+ const CART_DETAIL_FIELDS = `${CART_FIELDS}
362
+ cost { subtotalAmount { amount currencyCode } totalAmount { amount currencyCode } }
363
+ discountCodes { code applicable }
364
+ lines(first: 100) { nodes {
365
+ id quantity
366
+ cost { totalAmount { amount currencyCode } }
367
+ merchandise { ... on ProductVariant { id title price { amount currencyCode } image { url altText } product { title handle featuredImage { url altText } } } }
368
+ } }`;
369
+
370
+ type RawCart = Cart & {
371
+ cost?: { subtotalAmount?: Money | null; totalAmount?: Money | null } | null;
372
+ discountCodes?: Array<{ code: string; applicable: boolean }>;
373
+ lines?: {
374
+ nodes: Array<{
375
+ id: string;
376
+ quantity: number;
377
+ cost?: { totalAmount?: Money | null } | null;
378
+ merchandise: {
379
+ id: string;
380
+ title: string;
381
+ price?: Money | null;
382
+ image?: Image | null;
383
+ product: { title: string; handle: string; featuredImage?: Image | null };
384
+ };
385
+ }>;
386
+ };
387
+ };
388
+
389
+ function details(c: RawCart): CartDetails {
390
+ return {
391
+ id: c.id,
392
+ checkoutUrl: c.checkoutUrl,
393
+ totalQuantity: c.totalQuantity,
394
+ subtotal: c.cost?.subtotalAmount ?? null,
395
+ total: c.cost?.totalAmount ?? null,
396
+ discountCodes: c.discountCodes ?? [],
397
+ lines: (c.lines?.nodes ?? []).map((l) => ({
398
+ id: l.id,
399
+ quantity: l.quantity,
400
+ variantId: l.merchandise.id,
401
+ variantTitle: l.merchandise.title,
402
+ productTitle: l.merchandise.product.title,
403
+ productHandle: l.merchandise.product.handle,
404
+ image: l.merchandise.image ?? l.merchandise.product.featuredImage ?? null,
405
+ price: l.merchandise.price ?? null,
406
+ total: l.cost?.totalAmount ?? null,
407
+ })),
408
+ };
409
+ }
410
+
411
+ type DetailResult = { cart: RawCart | null; userErrors: Array<{ message: string }> };
412
+
413
+ /** The cart with its lines, costs and discount codes. Null once Shopify no longer has it. */
414
+ export async function cartDetails(
415
+ config: RedeemIDConfig,
416
+ id: string,
417
+ opts: { buyerIp?: string | null; fetcher?: typeof fetch } = {},
418
+ ): Promise<CartDetails | null> {
419
+ const data = await storefront<{ cart: RawCart | null }>(config, `query Cart($id: ID!) { cart(id: $id) { ${CART_DETAIL_FIELDS} } }`, { id }, opts);
420
+ return data.cart ? details(data.cart) : null;
421
+ }
422
+
423
+ function result(r: DetailResult): CartDetails {
424
+ if (!r.cart) throw new Error(r.userErrors[0]?.message ?? "Shopify returned no cart.");
425
+ return details(r.cart);
426
+ }
427
+
428
+ /** Set line quantities; a quantity of 0 removes the line. */
429
+ export async function updateLines(
430
+ config: RedeemIDConfig,
431
+ cartId: string,
432
+ lines: Array<{ id: string; quantity: number }>,
433
+ opts: { buyerIp?: string | null; fetcher?: typeof fetch } = {},
434
+ ): Promise<CartDetails> {
435
+ const data = await storefront<{ cartLinesUpdate: DetailResult }>(
436
+ config,
437
+ `mutation CartUpdate($id: ID!, $lines: [CartLineUpdateInput!]!) { cartLinesUpdate(cartId: $id, lines: $lines) { cart { ${CART_DETAIL_FIELDS} } userErrors { message } } }`,
438
+ { id: cartId, lines },
439
+ opts,
440
+ );
441
+ return result(data.cartLinesUpdate);
442
+ }
443
+
444
+ export async function removeLines(
445
+ config: RedeemIDConfig,
446
+ cartId: string,
447
+ lineIds: string[],
448
+ opts: { buyerIp?: string | null; fetcher?: typeof fetch } = {},
449
+ ): Promise<CartDetails> {
450
+ const data = await storefront<{ cartLinesRemove: DetailResult }>(
451
+ config,
452
+ `mutation CartRemove($id: ID!, $lineIds: [ID!]!) { cartLinesRemove(cartId: $id, lineIds: $lineIds) { cart { ${CART_DETAIL_FIELDS} } userErrors { message } } }`,
453
+ { id: cartId, lineIds },
454
+ opts,
455
+ );
456
+ return result(data.cartLinesRemove);
457
+ }
458
+
459
+ /** Replace the cart's discount codes (an empty list clears them). */
460
+ export async function setDiscountCodes(
461
+ config: RedeemIDConfig,
462
+ cartId: string,
463
+ codes: string[],
464
+ opts: { buyerIp?: string | null; fetcher?: typeof fetch } = {},
465
+ ): Promise<CartDetails> {
466
+ const data = await storefront<{ cartDiscountCodesUpdate: DetailResult }>(
467
+ config,
468
+ `mutation CartDiscount($id: ID!, $codes: [String!]) { cartDiscountCodesUpdate(cartId: $id, discountCodes: $codes) { cart { ${CART_DETAIL_FIELDS} } userErrors { message } } }`,
469
+ { id: cartId, codes },
470
+ opts,
471
+ );
472
+ return result(data.cartDiscountCodesUpdate);
473
+ }
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.0.6";
1
+ export const VERSION = "0.0.8";