@asteby/storefront 0.4.0 → 0.6.0

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/dist/mock.cjs CHANGED
@@ -24,6 +24,26 @@ function rankByDistance(items, origin, { radiusKm, limit, getPoint = (i) => i }
24
24
  return limit === void 0 ? out : out.slice(0, Math.max(0, limit));
25
25
  }
26
26
 
27
+ // src/availability.ts
28
+ function resolveAvailability(source, options = {}) {
29
+ const a = source.availability;
30
+ if (a && typeof a === "object" && typeof a.status === "string") return a;
31
+ const qty = typeof source.quantity_available === "number" ? source.quantity_available : null;
32
+ const sellable = source.available_for_sale && (qty === null || qty > 0);
33
+ const threshold = options.lowStockThreshold;
34
+ const low = sellable && qty !== null && typeof threshold === "number" && threshold > 0 && qty <= threshold;
35
+ const status = !sellable ? "out_of_stock" : low ? "low_stock" : "in_stock";
36
+ return {
37
+ status,
38
+ quantity: sellable ? qty : null,
39
+ max_per_order: sellable ? qty : null,
40
+ restock_eta: null,
41
+ preorder_release_at: null,
42
+ // Old servers have no subscription route: never offer a form that cannot work.
43
+ notify_available: false
44
+ };
45
+ }
46
+
27
47
  // src/mock/seed.ts
28
48
  var MOCK_IMAGE_URL = (seed, width = 800, height = 800) => `https://picsum.photos/seed/${encodeURIComponent(seed)}/${width}/${height}`;
29
49
  var MOCK_CURRENCY = "MXN";
@@ -362,15 +382,17 @@ function buildCustomers() {
362
382
 
363
383
  // src/mock/index.ts
364
384
  var HttpError = class extends Error {
365
- constructor(status, code, message, field) {
385
+ constructor(status, code, message, field, details) {
366
386
  super(message);
367
387
  this.status = status;
368
388
  this.code = code;
369
389
  this.field = field;
390
+ this.details = details;
370
391
  }
371
392
  status;
372
393
  code;
373
394
  field;
395
+ details;
374
396
  };
375
397
  var PUBLIC_CACHE = "public, max-age=60, stale-while-revalidate=600";
376
398
  var PRIVATE_CACHE = "private, no-store";
@@ -451,10 +473,36 @@ function createMockStorefront(options = {}) {
451
473
  sessions: /* @__PURE__ */ new Map(),
452
474
  idempotency: /* @__PURE__ */ new Map(),
453
475
  checkouts: /* @__PURE__ */ new Map(),
476
+ payments: /* @__PURE__ */ new Map(),
477
+ restock: /* @__PURE__ */ new Set(),
454
478
  seq: 0
455
479
  };
456
480
  }
481
+ const lowStockThreshold = options.lowStockThreshold ?? 5;
482
+ function syncAvailability(products) {
483
+ for (const p of products) {
484
+ for (const v of p.variants) {
485
+ const qty = v.quantity_available;
486
+ const sellable = v.available_for_sale && (qty === null || qty > 0);
487
+ v.available_for_sale = sellable;
488
+ const low = sellable && qty !== null && lowStockThreshold > 0 && qty <= lowStockThreshold;
489
+ v.availability = {
490
+ status: !sellable ? "out_of_stock" : low ? "low_stock" : "in_stock",
491
+ quantity: low ? qty : null,
492
+ max_per_order: sellable ? qty === null ? null : Math.min(qty, 10) : null,
493
+ restock_eta: null,
494
+ preorder_release_at: null,
495
+ notify_available: !sellable
496
+ };
497
+ }
498
+ p.available_for_sale = p.variants.some((v) => v.available_for_sale);
499
+ const rank = ["in_stock", "low_stock", "backorder", "preorder", "out_of_stock"];
500
+ const best = p.variants.map((v) => v.availability).sort((a, b) => rank.indexOf(a.status) - rank.indexOf(b.status))[0];
501
+ p.availability = best ?? resolveAvailability(p);
502
+ }
503
+ }
457
504
  let state = seed();
505
+ syncAvailability(state.products);
458
506
  const nextId = (prefix) => `${prefix}_${(++state.seq).toString(36)}${Math.random().toString(36).slice(2, 8)}`;
459
507
  function findVariant(variantId) {
460
508
  for (const product of state.products) {
@@ -573,7 +621,8 @@ function createMockStorefront(options = {}) {
573
621
  }
574
622
  function validateLines(lines, cart) {
575
623
  if (!Array.isArray(lines)) throw new HttpError(400, "validation", "`lines` must be an array", "lines");
576
- return lines.map((raw, i) => {
624
+ const toCheck = [];
625
+ const out = lines.map((raw, i) => {
577
626
  const l = raw;
578
627
  if (typeof l.variant_id !== "string") throw new HttpError(400, "validation", "variant_id is required", `lines[${i}].variant_id`);
579
628
  const quantity = l.quantity === void 0 ? 1 : l.quantity;
@@ -582,15 +631,36 @@ function createMockStorefront(options = {}) {
582
631
  }
583
632
  const found = findVariant(l.variant_id);
584
633
  if (!found) throw new HttpError(400, "validation", `Unknown variant ${l.variant_id}`, `lines[${i}].variant_id`);
585
- const already = cart?.lines.find((x) => x.variant_id === l.variant_id)?.quantity ?? 0;
586
- checkStock(found.variant, already + quantity, `lines[${i}].quantity`);
634
+ const existing = cart?.lines.find((x) => x.variant_id === l.variant_id);
635
+ toCheck.push({ variant: found.variant, quantity: (existing?.quantity ?? 0) + quantity, lineId: existing?.id ?? null, field: `lines[${i}].quantity` });
587
636
  return { variant_id: l.variant_id, quantity };
588
637
  });
638
+ checkStock(toCheck);
639
+ return out;
589
640
  }
590
- function checkStock(variant, quantity, field) {
591
- if (!variant.available_for_sale || variant.quantity_available !== null && quantity > variant.quantity_available) {
592
- throw new HttpError(409, "insufficient_stock", `Only ${variant.quantity_available ?? 0} available for ${variant.sku}`, field);
641
+ function visibleMax(variant) {
642
+ const a = variant.availability;
643
+ if (!a || a.status === "out_of_stock") return 0;
644
+ return a.status === "low_stock" ? a.quantity : null;
645
+ }
646
+ function lineProblem(variant, quantity) {
647
+ if (!variant.available_for_sale) return { code: "out_of_stock", max_quantity: 0 };
648
+ if (variant.quantity_available !== null && quantity > variant.quantity_available) {
649
+ return { code: "insufficient_stock", max_quantity: visibleMax(variant) };
593
650
  }
651
+ return null;
652
+ }
653
+ function checkStock(items) {
654
+ const bad = items.filter((i) => lineProblem(i.variant, i.quantity));
655
+ if (bad.length === 0) return;
656
+ const lines = bad.map((i) => ({
657
+ line_id: i.lineId,
658
+ variant_id: i.variant.id,
659
+ sku: i.variant.sku,
660
+ status: i.variant.availability?.status ?? "out_of_stock",
661
+ max_quantity: visibleMax(i.variant)
662
+ }));
663
+ throw new HttpError(409, "insufficient_stock", "Algunos productos ya no tienen existencias suficientes", bad[0].field, { lines });
594
664
  }
595
665
  function addLines(cart, lines) {
596
666
  for (const l of lines) {
@@ -623,6 +693,7 @@ function createMockStorefront(options = {}) {
623
693
  lines.push({
624
694
  id: l.id,
625
695
  quantity: l.quantity,
696
+ problem: lineProblem(variant, l.quantity),
626
697
  merchandise: {
627
698
  ...variant,
628
699
  product: { handle: product.handle, title: product.title, featured_image: product.featured_image }
@@ -661,6 +732,7 @@ function createMockStorefront(options = {}) {
661
732
  total: fromCents(total, currency),
662
733
  tax_included: true
663
734
  },
735
+ note: cart.note,
664
736
  updated_at: cart.updated_at
665
737
  };
666
738
  }
@@ -744,7 +816,7 @@ function createMockStorefront(options = {}) {
744
816
  const nodes = skus.flatMap((sku) => {
745
817
  for (const p of state.products) {
746
818
  const v = p.variants.find((x) => x.sku === sku);
747
- if (v) return [{ sku, quantity_available: v.quantity_available }];
819
+ if (v) return [{ sku, quantity_available: v.quantity_available, ...v.availability ? { availability: v.availability } : {} }];
748
820
  }
749
821
  return [];
750
822
  });
@@ -758,6 +830,7 @@ function createMockStorefront(options = {}) {
758
830
  buyer: { email: null, phone: null, customer_id: null },
759
831
  shipping_address: null,
760
832
  delivery: { method: null, rate_id: null, branch_id: null },
833
+ note: null,
761
834
  updated_at: now()
762
835
  };
763
836
  if (body.lines !== void 0) addLines(cart, validateLines(body.lines));
@@ -781,8 +854,13 @@ function createMockStorefront(options = {}) {
781
854
  if (typeof u.quantity !== "number" || !Number.isInteger(u.quantity) || u.quantity < 0) {
782
855
  throw new HttpError(400, "validation", "quantity must be a non-negative integer", `lines[${i}].quantity`);
783
856
  }
784
- if (u.quantity > 0) checkStock(findVariant(line.variant_id).variant, u.quantity, `lines[${i}].quantity`);
785
857
  });
858
+ checkStock(
859
+ updates.flatMap((u, i) => {
860
+ const line = cart.lines.find((l) => l.id === u.id);
861
+ return u.quantity > 0 ? [{ variant: findVariant(line.variant_id).variant, quantity: u.quantity, lineId: line.id, field: `lines[${i}].quantity` }] : [];
862
+ })
863
+ );
786
864
  for (const u of updates) {
787
865
  const line = cart.lines.find((l) => l.id === u.id);
788
866
  line.quantity = u.quantity;
@@ -849,6 +927,40 @@ function createMockStorefront(options = {}) {
849
927
  cart.updated_at = now();
850
928
  return { body: renderCart(cart), cache: "private" };
851
929
  });
930
+ route("PUT", "/carts/:id/note", "cart:write", ({ params, body }) => {
931
+ const cart = getCartRecord(params[0]);
932
+ const raw = body.note;
933
+ if (raw !== null && raw !== void 0 && typeof raw !== "string") throw new HttpError(400, "validation", "note must be a string or null", "note");
934
+ const text = typeof raw === "string" ? raw.trim() : "";
935
+ if (text.length > 1e3) throw new HttpError(400, "validation", "note must be at most 1000 characters", "note");
936
+ cart.note = text || null;
937
+ cart.updated_at = now();
938
+ return { body: renderCart(cart), cache: "private" };
939
+ });
940
+ route("GET", "/carts/:id/recommendations", "cart:write", ({ params, search }) => {
941
+ const cart = getCartRecord(params[0]);
942
+ const first = Math.min(8, Math.max(1, Number.parseInt(search.get("first") ?? "4", 10) || 4));
943
+ const inCart = new Set(cart.lines.map((l) => findVariant(l.variant_id)?.product.id));
944
+ const nodes = [];
945
+ for (const p of state.products) {
946
+ if (inCart.has(p.id)) continue;
947
+ const variants = p.variants.filter((v) => v.available_for_sale);
948
+ if (variants.length === 0) continue;
949
+ nodes.push({
950
+ product: { id: p.id, handle: p.handle, title: p.title, featured_image: p.featured_image, options: p.options, vendor: p.vendor },
951
+ variants,
952
+ default_variant_id: variants[0].id,
953
+ source: "similar",
954
+ unlocks: null
955
+ });
956
+ if (nodes.length >= first) break;
957
+ }
958
+ return { body: { nodes, promotions: null }, cache: "private" };
959
+ });
960
+ route("GET", "/promotions", "catalog:read", () => ({
961
+ body: { nodes: [], announcements: options.announcements ?? [] },
962
+ cache: "public"
963
+ }));
852
964
  route("POST", "/carts/:id/checkout", "checkout:write", ({ params, body, headers }) => {
853
965
  const key = headers.get("idempotency-key");
854
966
  if (key) {
@@ -865,7 +977,7 @@ function createMockStorefront(options = {}) {
865
977
  }
866
978
  if (typeof body.payment_method !== "string" || !body.payment_method) throw new HttpError(400, "validation", "payment_method is required", "payment_method");
867
979
  if (typeof body.return_url !== "string" || !body.return_url) throw new HttpError(400, "validation", "return_url is required", "return_url");
868
- record.lines.forEach((l, i) => checkStock(findVariant(l.variant_id).variant, l.quantity, `lines[${i}].quantity`));
980
+ checkStock(record.lines.map((l, i) => ({ variant: findVariant(l.variant_id).variant, quantity: l.quantity, lineId: l.id, field: `lines[${i}].quantity` })));
869
981
  for (const l of record.lines) {
870
982
  const v = findVariant(l.variant_id).variant;
871
983
  if (v.quantity_available !== null) {
@@ -873,7 +985,7 @@ function createMockStorefront(options = {}) {
873
985
  v.available_for_sale = v.quantity_available > 0;
874
986
  }
875
987
  }
876
- for (const p of state.products) p.available_for_sale = p.variants.some((v) => v.available_for_sale);
988
+ syncAvailability(state.products);
877
989
  const orderId = nextId("ord");
878
990
  const number = String(1001 + state.orders.length - 1);
879
991
  const customerId = cart.buyer.customer_id ?? state.customers.find((c) => c.email && c.email === cart.buyer.email)?.id ?? null;
@@ -917,6 +1029,110 @@ function createMockStorefront(options = {}) {
917
1029
  if (!found) throw new HttpError(404, "not_found", "Checkout not found");
918
1030
  return { body: found, cache: "private" };
919
1031
  });
1032
+ const DECLINES = {
1033
+ tok_OTHE: "declined",
1034
+ tok_CALL: "call_issuer",
1035
+ tok_FUND: "insufficient_funds",
1036
+ tok_SECU: "invalid_cvc",
1037
+ tok_EXPI: "invalid_expiry",
1038
+ tok_FORM: "invalid_card_data"
1039
+ };
1040
+ const maxPaymentAttempts = options.maxPaymentAttempts ?? 5;
1041
+ route("GET", "/checkouts/:id/payment-config", "checkout:write", ({ params }) => {
1042
+ const found = state.checkouts.get(params[0]);
1043
+ if (!found) throw new HttpError(404, "not_found", "Checkout not found");
1044
+ const st = state.payments.get(params[0]);
1045
+ const config = {
1046
+ provider: "mock",
1047
+ flow: "embedded",
1048
+ option: "card",
1049
+ payable: found.checkout.status === "pending" && (st?.attempts ?? 0) < maxPaymentAttempts,
1050
+ reason: null,
1051
+ amount: found.checkout.total,
1052
+ currency: found.checkout.total.currency_code,
1053
+ public_key: "TEST-mock-public-key",
1054
+ methods: ["card", "cash"],
1055
+ options: [{ key: "card", redirect: false, max_installments: 12 }, { key: "cash", redirect: false }],
1056
+ client: { kind: "mock", sdk_url: "https://mock.local/sdk.js", public_key: "TEST-mock-public-key" },
1057
+ checkout: found.checkout,
1058
+ action: st?.last?.payment.action ?? null,
1059
+ attempts_left: maxPaymentAttempts - (st?.attempts ?? 0)
1060
+ };
1061
+ return { body: config, cache: "private" };
1062
+ });
1063
+ route("POST", "/checkouts/:id/payment", "checkout:write", ({ params, body, headers }) => {
1064
+ const key = headers.get("idempotency-key");
1065
+ if (!key) throw new HttpError(400, "validation", "Idempotency-Key header is required", "Idempotency-Key");
1066
+ const id = params[0];
1067
+ const prev = state.idempotency.get(`pay:${id}:${key}`);
1068
+ if (prev) return { status: prev.status, body: prev.body, cache: "private" };
1069
+ const found = state.checkouts.get(id);
1070
+ if (!found) throw new HttpError(404, "not_found", "Checkout not found");
1071
+ const method = body.method;
1072
+ if (method !== "card" && method !== "cash" && method !== "bank_transfer") throw new HttpError(400, "validation", "method must be card, cash or bank_transfer", "method");
1073
+ const data = body.provider_data;
1074
+ if (typeof data !== "object" || data === null || Array.isArray(data)) throw new HttpError(400, "validation", "provider_data is required", "provider_data");
1075
+ const exp = body.expected_total;
1076
+ if (exp && exp.amount !== void 0 && toCents(exp.amount) !== toCents(found.checkout.total.amount)) {
1077
+ throw new HttpError(422, "price_mismatch", "The total changed", "expected_total", { cost: found.checkout.total });
1078
+ }
1079
+ const st = state.payments.get(id) ?? { attempts: 0, last: null };
1080
+ if (found.checkout.status === "paid" || st.last?.payment.status === "pending" || st.last?.payment.status === "requires_action") {
1081
+ throw new HttpError(409, "conflict", "A payment is already in progress", "payment_in_progress");
1082
+ }
1083
+ if (found.checkout.stock_hold_expires_at && Date.parse(found.checkout.stock_hold_expires_at) < Date.now()) {
1084
+ throw new HttpError(409, "expired", "The reservation expired");
1085
+ }
1086
+ if (st.attempts >= maxPaymentAttempts) throw new HttpError(429, "rate_limited", "too_many_attempts");
1087
+ st.attempts += 1;
1088
+ const token = typeof data.token === "string" ? data.token : "";
1089
+ const attemptsLeft = maxPaymentAttempts - st.attempts;
1090
+ let payment;
1091
+ if (method !== "card") {
1092
+ const pointId = String(data.payment_method_id ?? "oxxo");
1093
+ payment = {
1094
+ provider: "mock",
1095
+ status: "requires_action",
1096
+ action: {
1097
+ type: "voucher",
1098
+ voucher: {
1099
+ payment_point: pointId,
1100
+ reference: `${pointId.toUpperCase()}-${id.slice(-6).toUpperCase()}`,
1101
+ voucher_url: `https://mock.local/voucher/${id}`,
1102
+ amount: found.checkout.total.amount,
1103
+ currency: found.checkout.total.currency_code,
1104
+ expires_at: new Date(Date.now() + 48 * 36e5).toISOString()
1105
+ }
1106
+ }
1107
+ };
1108
+ } else if (token in DECLINES) {
1109
+ payment = { provider: "mock", status: "failed", decline_code: DECLINES[token], retryable: true };
1110
+ } else if (token === "tok_CONT") {
1111
+ payment = { provider: "mock", status: "pending", action: { type: "none" } };
1112
+ } else if (token === "tok_3DS") {
1113
+ payment = { provider: "mock", status: "requires_action", action: { type: "challenge", challenge: { url: "https://mock.local/3ds", creq: "creq" } } };
1114
+ } else {
1115
+ payment = { provider: "mock", status: "paid" };
1116
+ found.checkout.status = "paid";
1117
+ }
1118
+ payment.outcome = payment.status === "paid" ? "approved" : payment.status === "failed" ? "rejected" : payment.action?.type === "challenge" ? "challenge" : "pending";
1119
+ const result = { attempt: { n: st.attempts }, payment, checkout: found.checkout, attempts_left: attemptsLeft };
1120
+ st.last = result;
1121
+ state.payments.set(id, st);
1122
+ state.idempotency.set(`pay:${id}:${key}`, { status: 200, body: result });
1123
+ return { body: result, cache: "private" };
1124
+ });
1125
+ route("POST", "/restock-subscriptions", "customer:write", ({ body }) => {
1126
+ if (typeof body.sku !== "string" || !body.sku) throw new HttpError(400, "validation", "sku is required", "sku");
1127
+ if (typeof body.locale !== "string" || !body.locale) throw new HttpError(400, "validation", "locale is required", "locale");
1128
+ if (body.consent !== true) throw new HttpError(422, "invalid_state", "consent must be true", "consent");
1129
+ const contact = typeof body.email === "string" && body.email ? body.email.toLowerCase() : typeof body.phone === "string" ? body.phone : "";
1130
+ if (!contact) throw new HttpError(400, "validation", "email or phone is required", "email");
1131
+ const known = state.products.some((p) => p.variants.some((v) => v.sku === body.sku));
1132
+ if (!known) throw new HttpError(404, "not_found", `SKU ${body.sku} not found`, "sku");
1133
+ state.restock.add(`${contact}|${body.sku}`);
1134
+ return { status: 202, body: { status: "pending" }, cache: "private" };
1135
+ });
920
1136
  route("POST", "/customer/otp", "customer:write", ({ body }) => {
921
1137
  state.otps.set(identifierOf(body), otpCode);
922
1138
  return { status: 204 };
@@ -1034,7 +1250,11 @@ function createMockStorefront(options = {}) {
1034
1250
  });
1035
1251
  }
1036
1252
  function errorResponse(err) {
1037
- return json(err.status, { error: { code: err.code, message: err.message, ...err.field ? { field: err.field } : {} } }, { "Cache-Control": PRIVATE_CACHE });
1253
+ return json(
1254
+ err.status,
1255
+ { error: { code: err.code, message: err.message, ...err.field ? { field: err.field } : {}, ...err.details !== void 0 ? { details: err.details } : {} } },
1256
+ { "Cache-Control": PRIVATE_CACHE }
1257
+ );
1038
1258
  }
1039
1259
  async function handle(request) {
1040
1260
  const url = new URL(request.url);
@@ -1098,6 +1318,7 @@ function createMockStorefront(options = {}) {
1098
1318
  /** Restores the seed data and clears carts, sessions and orders. */
1099
1319
  reset() {
1100
1320
  state = seed();
1321
+ syncAvailability(state.products);
1101
1322
  },
1102
1323
  /** OTP code accepted by `/customer/session`. */
1103
1324
  otpCode