@zappie/verify 0.1.3 → 0.1.5

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/index.js CHANGED
@@ -1,23 +1,17 @@
1
1
  import {
2
+ API_VERSION,
3
+ RestTransport,
2
4
  ZappieError,
3
- asError,
5
+ apiError,
6
+ createZappie,
4
7
  format,
5
- hours,
6
- isBoolean,
7
- isString,
8
- list,
9
- mapItem,
10
- mapMenu,
11
- mapMoney,
12
- money,
13
- multiplyMoney,
14
- opt,
15
- payloadKey,
16
- record,
17
- sumMoney,
18
- text,
19
- wireError
20
- } from "./chunk-WUWUDCYO.js";
8
+ previewItem,
9
+ requestWithRetry,
10
+ resolveSourceMenu,
11
+ stableErrorCode,
12
+ validateConfig,
13
+ validateLine
14
+ } from "./chunk-JYFSFOIS.js";
21
15
 
22
16
  // src/error.ts
23
17
  var VerifyError = class extends Error {
@@ -291,6 +285,20 @@ async function debugValue(page, expression) {
291
285
  }
292
286
  var mobile = { width: 390, height: 844 };
293
287
  var desktop = { width: 1440, height: 900 };
288
+ async function applyPhoneProfile(page) {
289
+ const cdp = await page.context().newCDPSession(page);
290
+ await cdp.send("Network.enable");
291
+ await cdp.send("Network.setCacheDisabled", { cacheDisabled: true });
292
+ await cdp.send("Network.emulateNetworkConditions", {
293
+ offline: false,
294
+ latency: 150,
295
+ downloadThroughput: 2e5,
296
+ uploadThroughput: 1e5,
297
+ connectionType: "cellular3g"
298
+ });
299
+ await cdp.send("Emulation.setCPUThrottlingRate", { rate: 4 });
300
+ return cdp;
301
+ }
294
302
 
295
303
  // src/checks/v01-routes.ts
296
304
  var v01 = {
@@ -300,2270 +308,107 @@ var v01 = {
300
308
  async run({ page, options }, viewport) {
301
309
  await page.setViewportSize(viewport);
302
310
  const errors2 = [];
303
- const onPageError = (error) => errors2.push(error.message);
304
- const onConsole = (message) => {
305
- if (message.type() === "error" && !/favicon.*404/i.test(message.text())) {
306
- errors2.push(message.text());
307
- }
308
- };
309
- page.on("pageerror", onPageError);
310
- page.on("console", onConsole);
311
- const paths = /* @__PURE__ */ new Set(["/"]);
312
- const sitemap = await page.request.get(
313
- new URL("/sitemap.xml", options.url).href
314
- );
315
- if (sitemap.ok()) {
316
- for (const match of (await sitemap.text()).matchAll(
317
- /<loc>([^<]+)<\/loc>/g
318
- )) {
319
- paths.add(new URL(match[1]).pathname);
320
- }
321
- }
322
- for (const path4 of [...paths].sort()) {
323
- await open(page, path4);
324
- }
325
- page.off("pageerror", onPageError);
326
- page.off("console", onConsole);
327
- assertion(
328
- errors2.length === 0,
329
- "page_error",
330
- errors2[0] ?? "A route logged an error",
331
- "Fix uncaught page errors and console errors on every public route."
332
- );
333
- }
334
- };
335
-
336
- // src/checks/v02-sdk.ts
337
- var v02 = {
338
- id: "V02",
339
- title: "SDK test mode and store",
340
- viewports: "mobile",
341
- async run({ page, options }, viewport) {
342
- await page.setViewportSize(viewport);
343
- await open(page);
344
- const debug = await debugValue(page, () => ({
345
- store: window.__zappie?.store,
346
- version: window.__zappie?.version
347
- }));
348
- assertion(
349
- debug.store === options.store,
350
- "sdk_store_mismatch",
351
- `SDK store is ${debug.store ?? "missing"}, expected ${options.store}`,
352
- "Create the SDK client with the same store slug passed to verify."
353
- );
354
- assertion(
355
- /^0\./.test(debug.version ?? ""),
356
- "sdk_version_unsupported",
357
- `Unsupported SDK version ${debug.version ?? "missing"}`,
358
- "Use a supported @zappie/sdk major version."
359
- );
360
- }
361
- };
362
-
363
- // ../sdk/src/cart/errors.ts
364
- var ZappieCartError = class extends ZappieError {
365
- constructor(issues) {
366
- super("cart_invalid", issues[0]?.message ?? "The cart needs attention");
367
- this.issues = issues;
368
- this.name = "ZappieCartError";
369
- }
370
- };
371
-
372
- // ../sdk/src/cart/price.ts
373
- function estimateUnitPrice(item, line) {
374
- const variant = line.variantId ? item.variants.find((v) => v.id === line.variantId) : void 0;
375
- if (line.variantId && !variant) {
376
- throw new ZappieError("invalid_request", "The selected variant is missing");
377
- }
378
- const extras = line.choices.flatMap((selected) => {
379
- const group = item.optionGroups.find((g) => g.id === selected.groupId);
380
- const choice = group?.choices.find((c) => c.id === selected.choiceId);
381
- if (!group || !choice) {
382
- throw new ZappieError(
383
- "invalid_request",
384
- "The selected option is missing"
385
- );
386
- }
387
- return group.isAttribute ? [] : [multiplyMoney(choice.price, Math.max(selected.quantity, 1))];
388
- });
389
- return sumMoney(extras, variant?.price ?? item.price);
390
- }
391
-
392
- // ../sdk/src/cart/source-menu.ts
393
- function resolveSourceMenu(menu, itemId, menuId) {
394
- const categories = menu.categories.filter((c) => c.itemIds.includes(itemId));
395
- const sources = menu.menus ?? [
396
- { id: menu.id, categoryIds: menu.categories.map((c) => c.id) }
397
- ];
398
- const placements = menu.items[itemId]?.placements;
399
- const candidates = sources.filter(
400
- (source) => (!placements || placements.some(
401
- (p) => p.menuId === source.id && source.categoryIds.includes(p.categoryId)
402
- )) && source.categoryIds.some((id) => categories.some((c) => c.id === id))
403
- );
404
- if (menuId !== void 0) {
405
- return typeof menuId === "string" && menuId.trim().length > 0 && menuId.length <= 256 && candidates.some((source) => source.id === menuId) ? menuId : void 0;
406
- }
407
- return candidates.length === 1 ? candidates[0].id : void 0;
408
- }
409
-
410
- // ../sdk/src/cart/validate.ts
411
- function issue(code, message, fixes = [], groupId) {
412
- return {
413
- code,
414
- severity: "blocking",
415
- message,
416
- fixes,
417
- source: "local",
418
- ...groupId ? { groupId } : {}
419
- };
420
- }
421
- function effectiveGroups(item, input) {
422
- const selected = /* @__PURE__ */ new Map();
423
- for (const choice of input.choices ?? []) {
424
- const ids = selected.get(choice.groupId) ?? /* @__PURE__ */ new Set();
425
- ids.add(choice.choiceId);
426
- selected.set(choice.groupId, ids);
427
- }
428
- return item.optionGroups.map((group) => {
429
- let required = group.required;
430
- let { min, max } = group.selection;
431
- for (const rule of group.conditions ?? []) {
432
- const chosen = selected.get(rule.groupId) ?? /* @__PURE__ */ new Set();
433
- const matches = rule.count ? rule.count.op === "eq" ? chosen.size === rule.count.value : rule.count.op === "gte" ? chosen.size >= rule.count.value : chosen.size <= rule.count.value : chosen.size > 0 && rule.choiceIds.length > 0 && (rule.match === "all" ? rule.choiceIds.every((id) => chosen.has(id)) : rule.choiceIds.some((id) => chosen.has(id)));
434
- if (!matches) {
435
- continue;
436
- }
437
- required = rule.setRequired ?? required;
438
- min = rule.setMin ?? min;
439
- if (rule.setMax != null) {
440
- max = rule.setMax > 0 ? rule.setMax : void 0;
441
- }
442
- }
443
- return {
444
- ...group,
445
- required,
446
- selection: { min, ...max !== void 0 ? { max } : {} }
447
- };
448
- });
449
- }
450
- function normalizeInput(item, input) {
451
- const inactive = new Set(
452
- item.optionGroups.filter((g) => g.isAvailable === false).map((g) => g.id)
453
- );
454
- return {
455
- ...input,
456
- quantity: input.quantity ?? item.quantity.min,
457
- choices: (input.choices ?? []).filter((c) => !inactive.has(c.groupId)).map((c) => ({ ...c, quantity: c.quantity ?? 1 })),
458
- inputs: Object.fromEntries(
459
- Object.entries(input.inputs ?? {}).filter(([id]) => !inactive.has(id))
460
- )
461
- };
462
- }
463
- function resolveVariant(item, input) {
464
- const attrs = item.optionGroups.filter((g) => g.isAttribute);
465
- const matches = item.variants.filter(
466
- (variant) => (!input.variantId || variant.id === input.variantId) && attrs.every((group) => {
467
- const selected = input.choices?.filter((c) => c.groupId === group.id) ?? [];
468
- return selected.length === 1 && variant.attributeChoices[group.id] === selected[0].choiceId;
469
- })
470
- );
471
- return matches.length === 1 ? matches[0] : void 0;
472
- }
473
- function validInput(kind, value) {
474
- switch (kind) {
475
- case "number":
476
- return Number.isFinite(Number(value));
477
- case "date":
478
- return /^\d{4}-\d{2}-\d{2}$/.test(value) && Number.isFinite(Date.parse(value)) && new Date(value).toISOString().slice(0, 10) === value;
479
- case "time":
480
- return /^([01]\d|2[0-3]):[0-5]\d(:[0-5]\d)?$/.test(value);
481
- case "toggle":
482
- return ["true", "false"].includes(value.toLowerCase());
483
- default:
484
- return true;
485
- }
486
- }
487
- function validateLine(item, raw, ctx) {
488
- if (!item || item.id !== raw.itemId || !Object.hasOwn(ctx.menu.items, raw.itemId)) {
489
- return [
490
- issue("item_unavailable", "This item is no longer on the menu", [
491
- { action: "remove_line" }
492
- ])
493
- ];
494
- }
495
- const issues = [];
496
- if (!resolveSourceMenu(ctx.menu, raw.itemId, raw.menuId)) {
497
- issues.push(
498
- issue(
499
- "item_unavailable",
500
- raw.menuId === void 0 ? `${item.name} is on more than one menu. Remove it and add it again from the menu you want.` : `${item.name} is no longer on the menu it was added from.`,
501
- [{ action: "remove_line" }]
502
- )
503
- );
504
- }
505
- if (!item.isAvailable) {
506
- issues.push(
507
- issue("item_unavailable", `${item.name} is unavailable`, [
508
- { action: "remove_line" }
509
- ])
510
- );
511
- }
512
- const input = normalizeInput(item, raw);
513
- const quantity = input.quantity;
514
- const { min, step } = item.quantity;
515
- const max = Math.min(item.quantity.max ?? 99, 99);
516
- if (!Number.isInteger(quantity) || quantity < min || quantity > max || quantity % step !== 0) {
517
- const lower = Math.ceil(min / step) * step;
518
- const upper = Math.floor(max / step) * step;
519
- const nearest = Math.max(
520
- lower,
521
- Math.min(
522
- upper,
523
- Math.round((Number.isFinite(quantity) ? quantity : min) / step) * step
524
- )
525
- );
526
- issues.push(
527
- issue(
528
- "quantity_limit",
529
- `Choose a valid quantity of ${item.name} (${min}\u2013${max}, step ${step})`,
530
- lower <= upper ? [{ action: "set_quantity", quantity: nearest }] : []
531
- )
532
- );
533
- }
534
- if ((input.note?.length ?? 0) > 280) {
535
- issues.push(issue("option_limit", "Notes must be at most 280 characters"));
536
- }
537
- for (const choice of input.choices ?? []) {
538
- if (!item.optionGroups.some((g) => g.id === choice.groupId)) {
539
- issues.push(
540
- issue(
541
- "option_unavailable",
542
- "This option group no longer exists",
543
- [{ action: "remove_line" }],
544
- choice.groupId
545
- )
546
- );
547
- }
548
- }
549
- for (const id of Object.keys(input.inputs ?? {})) {
550
- if (!item.optionGroups.some((g) => g.id === id)) {
551
- issues.push(
552
- issue(
553
- "option_unavailable",
554
- "This input group no longer exists",
555
- [{ action: "remove_line" }],
556
- id
557
- )
558
- );
559
- }
560
- }
561
- const groups = effectiveGroups(item, input);
562
- const missing = groups.filter(
563
- (g) => g.isAvailable !== false && g.required && !(input.choices ?? []).some((c) => c.groupId === g.id) && !(input.inputs?.[g.id] ?? "").trim()
564
- );
565
- if (missing.length) {
566
- return [
567
- ...issues,
568
- ...missing.map(
569
- (g) => issue(
570
- "option_required",
571
- `Choose ${g.name}`,
572
- [{ action: "choose_option", groupId: g.id }],
573
- g.id
574
- )
575
- )
576
- ];
577
- }
578
- for (const group of groups) {
579
- if (group.isAvailable === false) {
580
- continue;
581
- }
582
- const choices = input.choices?.filter((c) => c.groupId === group.id) ?? [];
583
- const text2 = input.inputs?.[group.id] ?? "";
584
- const value = text2.trim();
585
- const hybrid = group.isInformational && group.kind === "select";
586
- const isInput = group.kind !== "select" || hybrid && !group.choices.length;
587
- const fix = [{ action: "choose_option", groupId: group.id }];
588
- if (group.required && !choices.length && !value) {
589
- issues.push(
590
- issue("option_required", `Choose ${group.name}`, fix, group.id)
591
- );
592
- }
593
- if (value && (!isInput && !hybrid || choices.length > 0)) {
594
- issues.push(
595
- issue(
596
- "option_limit",
597
- `Choose either an option or a response for ${group.name}`,
598
- fix,
599
- group.id
600
- )
601
- );
602
- }
603
- if ((isInput || hybrid) && (text2.length > (group.input?.maxLength ?? 500) || value && !validInput(group.kind, value))) {
604
- issues.push(
605
- issue(
606
- "option_limit",
607
- `Enter a valid response for ${group.name}`,
608
- fix,
609
- group.id
610
- )
611
- );
612
- }
613
- const count = choices.reduce((sum, c) => sum + (c.quantity ?? 1), 0);
614
- if (choices.length && (isInput || count < group.selection.min || count > (group.selection.max ?? Infinity) || new Set(choices.map((c) => c.choiceId)).size !== choices.length)) {
615
- issues.push(
616
- issue(
617
- "option_limit",
618
- `Check the number of options for ${group.name}`,
619
- fix,
620
- group.id
621
- )
622
- );
623
- }
624
- for (const selected of choices) {
625
- const choice = group.choices.find((c) => c.id === selected.choiceId);
626
- if (!choice?.isAvailable) {
627
- issues.push(
628
- issue(
629
- "option_unavailable",
630
- `An option in ${group.name} is unavailable`,
631
- [
632
- { action: "choose_other_option", groupId: group.id },
633
- { action: "remove_line" }
634
- ],
635
- group.id
636
- )
637
- );
638
- }
639
- const n = selected.quantity ?? 1;
640
- if (!Number.isInteger(n) || n < (choice?.quantity?.min ?? 1) || n > (choice?.quantity?.max ?? 99)) {
641
- issues.push(
642
- issue(
643
- "option_limit",
644
- `Check the option quantity for ${group.name}`,
645
- fix,
646
- group.id
647
- )
648
- );
649
- }
650
- }
651
- }
652
- const variant = resolveVariant(item, input);
653
- if (!variant?.isAvailable) {
654
- const groupId = item.optionGroups.find((g) => g.isAttribute)?.id;
655
- issues.push(
656
- issue(
657
- "option_limit",
658
- "Choose an available variant",
659
- groupId ? [{ action: "choose_option", groupId }] : [{ action: "remove_line" }],
660
- groupId
661
- )
662
- );
663
- }
664
- return issues;
665
- }
666
-
667
- // ../sdk/src/cart/reconcile.ts
668
- function lineInput(line) {
669
- return {
670
- itemId: line.itemId,
671
- ...line.menuId !== void 0 ? { menuId: line.menuId } : {},
672
- quantity: line.quantity,
673
- choices: line.choices.map((c) => ({ ...c })),
674
- inputs: { ...line.inputs },
675
- ...line.variantId ? { variantId: line.variantId } : {},
676
- ...line.note !== void 0 ? { note: line.note } : {}
677
- };
678
- }
679
- function freezeLine(line) {
680
- return Object.freeze({
681
- ...line,
682
- choices: Object.freeze(line.choices.map((c) => Object.freeze({ ...c }))),
683
- inputs: Object.freeze({ ...line.inputs })
684
- });
685
- }
686
- function reconcile(lines, menu, branch, currency, previous = []) {
687
- const issues = [];
688
- let priced = true;
689
- const result = lines.map((old) => {
690
- const item = Object.hasOwn(menu.items, old.itemId) ? menu.items[old.itemId] : void 0;
691
- const raw = {
692
- ...old,
693
- choices: old.choices?.map((c) => ({ ...c })),
694
- inputs: { ...old.inputs }
695
- };
696
- const input = item ? normalizeInput(item, raw) : raw;
697
- issues.push(
698
- ...validateLine(item, input, { menu, branch }).map((i) => ({
699
- ...i,
700
- lineId: old.id
701
- }))
702
- );
703
- const variantId = input.variantId ?? (item ? resolveVariant(item, input)?.id : void 0);
704
- let unitPrice = money(0, currency);
705
- let linePriced = true;
706
- try {
707
- if (!item) {
708
- throw new Error("Missing item");
709
- }
710
- unitPrice = estimateUnitPrice(item, {
711
- choices: (input.choices ?? []).map((c) => ({
712
- ...c,
713
- quantity: c.quantity ?? 1
714
- })),
715
- ...variantId ? { variantId } : {}
716
- });
717
- } catch {
718
- priced = false;
719
- linePriced = false;
720
- }
721
- if (old.unitPrice && old.unitPrice.amountMinor !== unitPrice.amountMinor && item && linePriced) {
722
- issues.push({
723
- ...issue(
724
- "price_changed",
725
- `${item.name} is now ${format.money(unitPrice)}`,
726
- [{ action: "accept_price" }, { action: "remove_line" }]
727
- ),
728
- severity: "warning",
729
- lineId: old.id,
730
- data: { previous: old.unitPrice, current: unitPrice }
731
- });
732
- }
733
- return freezeLine({
734
- id: old.id,
735
- itemId: old.itemId,
736
- ...input.menuId !== void 0 ? { menuId: input.menuId } : {},
737
- name: item?.name ?? old.name,
738
- quantity: input.quantity ?? 1,
739
- choices: (input.choices ?? []).map((c) => ({
740
- ...c,
741
- quantity: c.quantity ?? 1
742
- })),
743
- inputs: input.inputs ?? {},
744
- ...variantId ? { variantId } : {},
745
- ...input.note !== void 0 ? { note: input.note } : {},
746
- unitPrice,
747
- lineSubtotal: multiplyMoney(unitPrice, input.quantity ?? 1),
748
- addedAt: old.addedAt
749
- });
750
- });
751
- for (const pending of previous) {
752
- if (pending.code === "price_changed" && result.some((l) => l.id === pending.lineId) && !issues.some(
753
- (i) => i.code === pending.code && i.lineId === pending.lineId
754
- )) {
755
- issues.push(pending);
756
- }
757
- }
758
- return { lines: Object.freeze(result), issues, priced };
759
- }
760
-
761
- // ../sdk/src/cart/storage.ts
762
- function storedMoney(value) {
763
- try {
764
- return value && money(value.amountMinor, value.currency);
765
- } catch {
766
- return void 0;
767
- }
768
- }
769
- function parseCart(raw) {
770
- const v = JSON.parse(raw);
771
- if (!v || v.v !== 1 || !Array.isArray(v.lines) || v.lines.length > 100 || !Number.isFinite(v.updatedAt) || typeof v.menuVersion !== "string" || v.promoCode !== null && typeof v.promoCode !== "string") {
772
- throw new Error("Invalid saved cart");
773
- }
774
- const ids = /* @__PURE__ */ new Set();
775
- for (const line of v.lines) {
776
- if (!line || typeof line.id !== "string" || ids.has(line.id) || typeof line.itemId !== "string" || typeof line.name !== "string" || !Number.isFinite(Date.parse(line.addedAt)) || !Number.isInteger(line.quantity) || line.quantity < 1 || line.quantity > 99 || line.note !== void 0 && (typeof line.note !== "string" || line.note.length > 280) || line.menuId !== void 0 && (typeof line.menuId !== "string" || !line.menuId.trim() || line.menuId.length > 256) || line.variantId !== void 0 && typeof line.variantId !== "string") {
777
- throw new Error("Invalid saved cart line");
778
- }
779
- ids.add(line.id);
780
- if (!Array.isArray(line.choices) || line.choices.length > 1e3 || line.choices.some(
781
- (c) => !c || typeof c.groupId !== "string" || typeof c.choiceId !== "string" || !Number.isInteger(c.quantity) || c.quantity < 1 || c.quantity > 99
782
- )) {
783
- throw new Error("Invalid saved choices");
784
- }
785
- if (!line.inputs || typeof line.inputs !== "object" || Array.isArray(line.inputs) || Object.values(line.inputs).some((t) => typeof t !== "string")) {
786
- throw new Error("Invalid saved inputs");
787
- }
788
- }
789
- if (v.fulfilment !== null && !validFulfilment(v.fulfilment)) {
790
- throw new Error("Invalid saved fulfilment");
791
- }
792
- return {
793
- v: 1,
794
- lines: v.lines.map((l) => {
795
- const unitPrice = storedMoney(l.unitPrice);
796
- return {
797
- id: l.id,
798
- itemId: l.itemId,
799
- ...l.menuId !== void 0 ? { menuId: l.menuId } : {},
800
- name: l.name,
801
- addedAt: l.addedAt,
802
- ...unitPrice ? { unitPrice } : {},
803
- quantity: l.quantity,
804
- choices: l.choices,
805
- inputs: l.inputs,
806
- ...l.variantId ? { variantId: l.variantId } : {},
807
- ...l.note !== void 0 ? { note: l.note } : {}
808
- };
809
- }),
810
- fulfilment: v.fulfilment,
811
- promoCode: v.promoCode,
812
- menuVersion: v.menuVersion,
813
- updatedAt: v.updatedAt
814
- };
815
- }
816
- function validFulfilment(f) {
817
- if (!f || !["pickup", "delivery", "dine_in"].includes(f.type) || typeof f.branchId !== "string") {
818
- return false;
819
- }
820
- if ("scheduledFor" in f && f.scheduledFor !== void 0 && !Number.isFinite(Date.parse(f.scheduledFor))) {
821
- return false;
822
- }
823
- if (f.type === "delivery") {
824
- const a = f.address;
825
- if (!a || typeof a.line1 !== "string" || !a.line1.trim()) {
826
- return false;
827
- }
828
- if ([a.line2, a.city, a.placeId, a.instructions].some(
829
- (s) => s !== void 0 && typeof s !== "string"
830
- )) {
831
- return false;
832
- }
833
- if (a.coordinates && (!Number.isFinite(a.coordinates.lat) || !Number.isFinite(a.coordinates.lng) || Math.abs(a.coordinates.lat) > 90 || Math.abs(a.coordinates.lng) > 180)) {
834
- return false;
835
- }
836
- }
837
- return f.type !== "dine_in" || f.table === void 0 || typeof f.table === "string";
838
- }
839
- function createCartStorage(provided, warn) {
840
- let storage;
841
- let initialized = false;
842
- let failed = false;
843
- let warned = false;
844
- const memory = /* @__PURE__ */ new Map();
845
- const warning = (message) => {
846
- if (!warned) {
847
- warned = true;
848
- warn(message);
849
- }
850
- };
851
- const access = (fn, fallback) => {
852
- try {
853
- if (!initialized) {
854
- initialized = true;
855
- storage = provided ?? (typeof window === "undefined" ? void 0 : window.localStorage);
856
- }
857
- if (storage && !failed) {
858
- return fn(storage);
859
- }
860
- } catch {
861
- failed = true;
862
- warning("Cart storage unavailable; using memory for this client");
863
- }
864
- return fallback();
865
- };
866
- return {
867
- warning,
868
- read(key) {
869
- return access(
870
- (s) => {
871
- const value = s.getItem(key);
872
- if (value !== null) {
873
- memory.set(key, value);
874
- } else {
875
- memory.delete(key);
876
- }
877
- return value;
878
- },
879
- () => memory.get(key) ?? null
880
- );
881
- },
882
- write(key, value) {
883
- memory.set(key, value);
884
- access(
885
- (s) => s.setItem(key, value),
886
- () => {
887
- }
888
- );
889
- },
890
- remove(key) {
891
- memory.delete(key);
892
- access(
893
- (s) => s.removeItem(key),
894
- () => {
895
- }
896
- );
897
- },
898
- accepts(area) {
899
- return !failed && (!area || area === storage);
900
- },
901
- sweep(prefix, current, ttl) {
902
- access(
903
- (s) => {
904
- const keys = Array.from(
905
- { length: s.length ?? 0 },
906
- (_, index) => s.key?.(index)
907
- );
908
- for (const key of keys) {
909
- if (!key || key === current || !key.startsWith(prefix)) {
910
- continue;
911
- }
912
- try {
913
- const raw = s.getItem(key);
914
- if (raw && Date.now() - parseCart(raw).updatedAt > ttl) {
915
- s.removeItem(key);
916
- }
917
- } catch {
918
- s.removeItem(key);
919
- }
920
- }
921
- },
922
- () => {
923
- }
924
- );
925
- }
926
- };
927
- }
928
-
929
- // ../sdk/src/cart/wire.ts
930
- function wireLine(line) {
931
- return {
932
- line_id: line.id,
933
- item_id: line.itemId,
934
- ...line.menuId !== void 0 ? { menu_id: line.menuId } : {},
935
- quantity: line.quantity,
936
- ...line.variantId ? { variant_id: line.variantId } : {},
937
- options: line.choices.map((c) => ({
938
- group_id: c.groupId,
939
- choice_id: c.choiceId,
940
- quantity: c.quantity
941
- })),
942
- inputs: { ...line.inputs },
943
- ...line.note !== void 0 ? { note: line.note } : {}
944
- };
945
- }
946
- function wireFulfilment(f) {
947
- return {
948
- type: f.type,
949
- ..."scheduledFor" in f && f.scheduledFor ? { scheduled_for: f.scheduledFor } : {},
950
- ...f.type === "dine_in" && f.table ? { table: f.table } : {},
951
- ...f.type === "delivery" ? {
952
- address: {
953
- line1: f.address.line1,
954
- ...f.address.line2 ? { line2: f.address.line2 } : {},
955
- ...f.address.city ? { city: f.address.city } : {},
956
- ...f.address.placeId ? { place_id: f.address.placeId } : {},
957
- ...f.address.instructions ? { instructions: f.address.instructions } : {},
958
- ...f.address.coordinates ? {
959
- lat: f.address.coordinates.lat,
960
- lng: f.address.coordinates.lng
961
- } : {}
962
- }
963
- } : {}
964
- };
965
- }
966
-
967
- // ../sdk/src/cart/index.ts
968
- var EMPTY_CART = Object.freeze({
969
- lines: Object.freeze([]),
970
- count: 0,
971
- subtotal: null,
972
- fulfilment: null,
973
- promoCode: null,
974
- state: { status: "ready" },
975
- issues: Object.freeze([]),
976
- isHydrated: false
977
- });
978
- function deepFreeze(value) {
979
- if (value && typeof value === "object" && !Object.isFrozen(value)) {
980
- Object.freeze(value);
981
- for (const child of Object.values(value)) {
982
- deepFreeze(child);
983
- }
984
- }
985
- return value;
986
- }
987
- function identity(input) {
988
- return JSON.stringify([
989
- input.itemId,
990
- input.menuId,
991
- input.variantId,
992
- input.note ?? "",
993
- [...input.choices ?? []].sort(
994
- (a, b) => a.groupId.localeCompare(b.groupId) || a.choiceId.localeCompare(b.choiceId)
995
- ),
996
- Object.entries(input.inputs ?? {}).sort(([a], [b]) => a.localeCompare(b))
997
- ]);
998
- }
999
- function createCart(deps) {
1000
- const { config, menu: menus, store, events } = deps;
1001
- const ttl = config.cart?.ttl ?? 7 * 24 * 60 * 60 * 1e3;
1002
- if (!Number.isFinite(ttl) || ttl <= 0) {
1003
- throw new ZappieError(
1004
- "invalid_request",
1005
- "Cart ttl must be positive milliseconds"
1006
- );
1007
- }
1008
- let snapshot = EMPTY_CART;
1009
- let menu;
1010
- let branch;
1011
- let selected = config.branch;
1012
- let updatedAt = 0;
1013
- let timer;
1014
- let revision = 0;
1015
- let quoteRevision = 0;
1016
- let moveSequence = 0;
1017
- let refreshSequence = 0;
1018
- let mounted = 0;
1019
- let detach;
1020
- let hydration;
1021
- let lifecycle = 0;
1022
- let legacy;
1023
- const listeners = /* @__PURE__ */ new Set();
1024
- const prefix = `zappie:cart:v1:${config.store}:`;
1025
- const scope = config.cart?.storageScope;
1026
- const keyFor = (id) => `${prefix}${id}${scope ? `:${scope}` : ""}`;
1027
- const key = () => keyFor(branch.id);
1028
- const storage = createCartStorage(
1029
- config.storage,
1030
- (message) => events.emit("warning", { message })
1031
- );
1032
- const readSaved = (id) => {
1033
- let raw = storage.read(keyFor(id));
1034
- if (raw === null && scope) {
1035
- raw = storage.read(prefix + id);
1036
- if (raw !== null) {
1037
- storage.remove(prefix + id);
1038
- storage.write(keyFor(id), raw);
1039
- }
1040
- }
1041
- return raw;
1042
- };
1043
- let quotes;
1044
- let loadingQuotes;
1045
- const quoteClient = () => loadingQuotes ??= import("./quote-client-6XJJHITL.js").then(
1046
- ({ createQuoteClient }) => quotes = createQuoteClient(deps.transport, deps.context),
1047
- (error) => {
1048
- loadingQuotes = void 0;
1049
- throw error;
1050
- }
1051
- );
1052
- const publish = (patch) => {
1053
- snapshot = deepFreeze({
1054
- ...snapshot,
1055
- ...patch,
1056
- branchId: branch?.id
1057
- });
1058
- for (const fn of [...listeners]) {
1059
- try {
1060
- fn();
1061
- } catch (error) {
1062
- events.emit("error", { error: asError(error) });
1063
- }
1064
- }
1065
- };
1066
- const attention = (issues, quote) => issues.some((i) => i.severity !== "info") ? { status: "needs_attention", issues, ...quote ? { quote } : {} } : quote ? { status: "quoted", quote } : { status: "ready" };
1067
- const cancel = () => {
1068
- revision++;
1069
- quoteRevision++;
1070
- clearTimeout(timer);
1071
- quotes?.cancel();
1072
- };
1073
- const persist = () => {
1074
- const value = {
1075
- v: 1,
1076
- lines: snapshot.lines.map((line) => ({
1077
- ...lineInput(line),
1078
- id: line.id,
1079
- name: line.name,
1080
- addedAt: line.addedAt,
1081
- unitPrice: line.unitPrice
1082
- })),
1083
- fulfilment: snapshot.fulfilment,
1084
- promoCode: snapshot.promoCode,
1085
- menuVersion: menu.version,
1086
- updatedAt
1087
- };
1088
- storage.write(key(), JSON.stringify(value));
1089
- };
1090
- const schedule = () => {
1091
- clearTimeout(timer);
1092
- if (snapshot.lines.length) {
1093
- timer = setTimeout(() => {
1094
- void cart.quote().catch(() => {
1095
- });
1096
- }, 300);
1097
- }
1098
- };
1099
- const recompute = (lines, pending = snapshot.issues.filter((i) => i.source === "local"), patch = {}) => {
1100
- const currency = store.peek().currency;
1101
- const result = reconcile(lines, menu, branch, currency, pending);
1102
- const subtotal = result.priced ? sumMoney(
1103
- result.lines.map((l) => l.lineSubtotal),
1104
- money(0, currency)
1105
- ) : null;
1106
- publish({
1107
- ...patch,
1108
- lines: result.lines,
1109
- count: result.lines.reduce((n, l) => n + l.quantity, 0),
1110
- subtotal,
1111
- issues: result.issues,
1112
- state: attention(result.issues)
1113
- });
1114
- for (const i of result.issues) {
1115
- events.emit("cart_issue", { code: i.code });
1116
- }
1117
- };
1118
- const changed = (lines = snapshot.lines, patch = {}) => {
1119
- cancel();
1120
- recompute(lines, void 0, patch);
1121
- updatedAt = Date.now();
1122
- persist();
1123
- schedule();
1124
- };
1125
- const choose = (value) => {
1126
- const found = store.peek()?.branches.find((b) => b.id === value.branchId);
1127
- if (!found) {
1128
- throw new ZappieError(
1129
- "branch_not_found",
1130
- "Load the storefront branch before using its cart"
1131
- );
1132
- }
1133
- menu = value;
1134
- branch = found;
1135
- selected = found.id;
1136
- };
1137
- const load = (raw, writeBack = true) => {
1138
- cancel();
1139
- let saved;
1140
- try {
1141
- saved = raw ? parseCart(raw) : void 0;
1142
- if (saved && Date.now() - saved.updatedAt > ttl) {
1143
- saved = void 0;
1144
- storage.remove(key());
1145
- events.emit("cart_expired", { branchId: branch.id });
1146
- }
1147
- } catch {
1148
- storage.remove(key());
1149
- storage.warning("Invalid saved cart discarded");
1150
- }
1151
- updatedAt = saved?.updatedAt ?? Date.now();
1152
- const idle = Date.now() - updatedAt;
1153
- let fulfilment = saved?.fulfilment ?? null;
1154
- if (fulfilment?.branchId !== branch.id || idle > 864e5 && fulfilment.type === "delivery") {
1155
- fulfilment = null;
1156
- }
1157
- if (fulfilment && "scheduledFor" in fulfilment && fulfilment.scheduledFor && Date.parse(fulfilment.scheduledFor) < Date.now()) {
1158
- fulfilment = { ...fulfilment };
1159
- delete fulfilment.scheduledFor;
1160
- }
1161
- const lines = (saved?.lines ?? []).map((line) => ({
1162
- ...line,
1163
- ...idle > 864e5 ? { inputs: {} } : {}
1164
- }));
1165
- const result = reconcile(lines, menu, branch, store.peek().currency);
1166
- recompute(result.lines, result.issues, {
1167
- fulfilment,
1168
- promoCode: saved?.promoCode ?? null,
1169
- isHydrated: true
1170
- });
1171
- if (raw !== null && writeBack) {
1172
- persist();
1173
- }
1174
- storage.sweep(prefix, key(), ttl);
1175
- };
1176
- const ensure = () => {
1177
- const id = branch?.id ?? store.peek()?.branches.find(
1178
- (b) => b.id === selected || b.slug === selected || !selected && b.isDefault
1179
- )?.id ?? (!selected ? store.peek()?.branches[0]?.id : void 0);
1180
- if (snapshot.isHydrated) {
1181
- return;
1182
- }
1183
- const loaded = menu ?? (id ? deps.workingSet.peek(id, []) : void 0);
1184
- if (!loaded || !store.peek()) {
1185
- throw new ZappieError(
1186
- "invalid_request",
1187
- "Load the store and menu before editing the cart"
1188
- );
1189
- }
1190
- choose(loaded);
1191
- const raw = readSaved(branch.id);
1192
- let migrated = [];
1193
- if (raw === null && legacy) {
1194
- try {
1195
- migrated = legacy(storage.read);
1196
- } catch {
1197
- storage.warning("Invalid legacy cart discarded");
1198
- }
1199
- }
1200
- load(raw);
1201
- if (raw === null && legacy) {
1202
- persist();
1203
- }
1204
- for (const input of migrated.slice(0, 100)) {
1205
- try {
1206
- cart.add(input.itemId, input);
1207
- } catch {
1208
- events.emit("cart_issue", { code: "item_unavailable" });
1209
- }
1210
- }
1211
- };
1212
- const refresh = async () => {
1213
- const id = branch?.id;
1214
- const seq = moveSequence;
1215
- const request = ++refreshSequence;
1216
- const rev = revision;
1217
- if (!id || !menu) {
1218
- return;
1219
- }
1220
- if (!snapshot.lines.length) {
1221
- return;
1222
- }
1223
- if (menu.completeness !== "partial") {
1224
- const version = await deps.transport.getMenuVersion({
1225
- ...deps.context(),
1226
- branchId: id
1227
- });
1228
- if (version.version === menu.version) {
1229
- return;
1230
- }
1231
- }
1232
- const value = await deps.workingSet.read(
1233
- id,
1234
- snapshot.lines.map((l) => l.itemId),
1235
- true
1236
- );
1237
- if (seq !== moveSequence || request !== refreshSequence || rev !== revision || JSON.stringify(value) === JSON.stringify(menu)) {
1238
- return;
1239
- }
1240
- menu = value;
1241
- cancel();
1242
- recompute(snapshot.lines);
1243
- persist();
1244
- schedule();
1245
- };
1246
- const current = (itemId) => {
1247
- const candidate = deps.workingSet.peek(branch.id, [
1248
- ...snapshot.lines.map((l) => l.itemId),
1249
- itemId
1250
- ]);
1251
- if (candidate.items[itemId]) {
1252
- menu = candidate;
1253
- }
1254
- };
1255
- const validated = (raw, id, addedAt) => {
1256
- current(raw.itemId);
1257
- const item = Object.hasOwn(menu.items, raw.itemId) ? menu.items[raw.itemId] : void 0;
1258
- const problems = validateLine(item, raw, { menu, branch });
1259
- if (problems.length || !item) {
1260
- throw new ZappieCartError(problems);
1261
- }
1262
- const input = normalizeInput(item, raw);
1263
- const variant = resolveVariant(item, input);
1264
- const choices = (input.choices ?? []).map((c) => ({
1265
- ...c,
1266
- quantity: c.quantity ?? 1
1267
- }));
1268
- const unitPrice = estimateUnitPrice(item, {
1269
- choices,
1270
- variantId: variant.id
1271
- });
1272
- return freezeLine({
1273
- id: id ?? `line_${Array.from(globalThis.crypto.getRandomValues(new Uint8Array(10)), (byte) => "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz-"[byte & 63]).join("")}`,
1274
- itemId: item.id,
1275
- ...input.menuId !== void 0 ? { menuId: input.menuId } : {},
1276
- name: item.name,
1277
- quantity: input.quantity,
1278
- choices,
1279
- inputs: input.inputs ?? {},
1280
- variantId: variant.id,
1281
- ...input.note !== void 0 ? { note: input.note } : {},
1282
- unitPrice,
1283
- lineSubtotal: multiplyMoney(unitPrice, input.quantity),
1284
- addedAt: addedAt ?? (/* @__PURE__ */ new Date()).toISOString()
1285
- });
1286
- };
1287
- const offMenu = events.on("menu:updated", ({ branchId, version }) => {
1288
- if (branch?.id === branchId) {
1289
- const value = menus.peek(branchId);
1290
- if (value && value.completeness !== "partial" && value.version === version && value.version !== menu?.version) {
1291
- refreshSequence++;
1292
- menu = value;
1293
- cancel();
1294
- recompute(snapshot.lines);
1295
- persist();
1296
- schedule();
1297
- }
1298
- }
1299
- });
1300
- const cart = {
1301
- get branchId() {
1302
- return snapshot.branchId;
1303
- },
1304
- get lines() {
1305
- return snapshot.lines;
1306
- },
1307
- get count() {
1308
- return snapshot.count;
1309
- },
1310
- get subtotal() {
1311
- return snapshot.subtotal;
1312
- },
1313
- get fulfilment() {
1314
- return snapshot.fulfilment;
1315
- },
1316
- get promoCode() {
1317
- return snapshot.promoCode;
1318
- },
1319
- get state() {
1320
- return snapshot.state;
1321
- },
1322
- get issues() {
1323
- return snapshot.issues;
1324
- },
1325
- get isHydrated() {
1326
- return snapshot.isHydrated;
1327
- },
1328
- getSnapshot: () => snapshot,
1329
- subscribe(fn) {
1330
- listeners.add(fn);
1331
- return () => {
1332
- listeners.delete(fn);
1333
- };
1334
- },
1335
- async hydrate() {
1336
- if (snapshot.isHydrated) {
1337
- return refresh();
1338
- }
1339
- const life = lifecycle;
1340
- hydration ??= (async () => {
1341
- const value = await store.get();
1342
- const target2 = value.branches.find(
1343
- (b) => b.id === selected || b.slug === selected
1344
- ) ?? (!selected ? value.branches.find((b) => b.isDefault) ?? value.branches[0] : void 0);
1345
- if (!target2) {
1346
- throw new ZappieError(
1347
- "branch_not_found",
1348
- "Cart branch is unavailable"
1349
- );
1350
- }
1351
- let inputs = snapshot.lines;
1352
- if (!snapshot.isHydrated) {
1353
- const raw = readSaved(target2.id);
1354
- try {
1355
- const saved = raw ? parseCart(raw) : void 0;
1356
- inputs = saved && Date.now() - saved.updatedAt <= ttl ? saved.lines : raw === null ? legacy?.(storage.read) ?? [] : [];
1357
- } catch {
1358
- inputs = [];
1359
- }
1360
- }
1361
- const loaded = await deps.workingSet.read(
1362
- target2.id,
1363
- inputs.map((l) => l.itemId)
1364
- );
1365
- if (life !== lifecycle) {
1366
- return;
1367
- }
1368
- choose(loaded);
1369
- ensure();
1370
- if (loaded.completeness !== "partial") {
1371
- await refresh();
1372
- }
1373
- })().finally(() => {
1374
- hydration = void 0;
1375
- });
1376
- return hydration;
1377
- },
1378
- mount() {
1379
- mounted++;
1380
- if (!detach && typeof window !== "undefined") {
1381
- const onStorage = (event) => {
1382
- if (branch && event.key === key() && storage.accepts(event.storageArea)) {
1383
- const raw = event.newValue;
1384
- let ids;
1385
- try {
1386
- ids = raw ? parseCart(raw).lines.map((l) => l.itemId) : [];
1387
- } catch {
1388
- load(raw, false);
1389
- return;
1390
- }
1391
- const cached = deps.workingSet.peek(branch.id, ids);
1392
- if (cached.completeness !== "partial" || ids.every((id) => cached.items[id])) {
1393
- menu = cached;
1394
- load(raw, false);
1395
- schedule();
1396
- return;
1397
- }
1398
- const rev = revision;
1399
- const request = ++refreshSequence;
1400
- const life = lifecycle;
1401
- void deps.workingSet.read(branch.id, ids).then((value) => {
1402
- if (life === lifecycle && rev === revision && request === refreshSequence) {
1403
- menu = value;
1404
- load(raw, false);
1405
- schedule();
1406
- }
1407
- }).catch(
1408
- (error) => events.emit("error", { error: asError(error) })
1409
- );
1410
- }
1411
- };
1412
- const onFocus = () => {
1413
- void refresh().catch(
1414
- (error) => events.emit("error", { error: asError(error) })
1415
- );
1416
- };
1417
- window.addEventListener("storage", onStorage);
1418
- window.addEventListener("focus", onFocus);
1419
- detach = () => {
1420
- window.removeEventListener("storage", onStorage);
1421
- window.removeEventListener("focus", onFocus);
1422
- };
1423
- }
1424
- if (mounted === 1) {
1425
- void cart.hydrate().catch(
1426
- (error) => events.emit("error", { error: asError(error) })
1427
- );
1428
- }
1429
- let active = true;
1430
- return () => {
1431
- if (active) {
1432
- active = false;
1433
- mounted--;
1434
- if (mounted === 0) {
1435
- detach?.();
1436
- detach = void 0;
1437
- cancel();
1438
- }
1439
- }
1440
- };
1441
- },
1442
- dispose() {
1443
- lifecycle++;
1444
- moveSequence++;
1445
- offMenu();
1446
- cancel();
1447
- detach?.();
1448
- detach = void 0;
1449
- listeners.clear();
1450
- },
1451
- validate(input) {
1452
- ensure();
1453
- current(input.itemId);
1454
- const problems = validateLine(
1455
- Object.hasOwn(menu.items, input.itemId) ? menu.items[input.itemId] : void 0,
1456
- input,
1457
- { menu, branch }
1458
- );
1459
- return problems.length ? { ok: false, issues: problems } : { ok: true };
1460
- },
1461
- add(item, input = {}) {
1462
- ensure();
1463
- let line = validated({
1464
- ...input,
1465
- itemId: typeof item === "string" ? item : item.id
1466
- });
1467
- const existing = snapshot.lines.find(
1468
- (l) => identity(lineInput(l)) === identity(lineInput(line))
1469
- );
1470
- if (existing) {
1471
- line = validated(
1472
- { ...lineInput(line), quantity: existing.quantity + line.quantity },
1473
- existing.id,
1474
- existing.addedAt
1475
- );
1476
- } else if (snapshot.lines.length >= 100) {
1477
- throw new ZappieCartError([
1478
- issue("quantity_limit", "A cart can contain at most 100 lines")
1479
- ]);
1480
- }
1481
- changed(
1482
- existing ? snapshot.lines.map((l) => l.id === existing.id ? line : l) : [...snapshot.lines, line]
1483
- );
1484
- events.emit("add_to_cart", {
1485
- itemId: line.itemId,
1486
- quantity: line.quantity - (existing?.quantity ?? 0),
1487
- value: multiplyMoney(
1488
- line.unitPrice,
1489
- line.quantity - (existing?.quantity ?? 0)
1490
- )
1491
- });
1492
- return line;
1493
- },
1494
- update(lineId, patch) {
1495
- ensure();
1496
- const old = snapshot.lines.find((l) => l.id === lineId);
1497
- if (!old) {
1498
- throw new ZappieCartError([
1499
- issue("item_not_found", "Cart line not found")
1500
- ]);
1501
- }
1502
- const input = { ...lineInput(old), ...patch };
1503
- if (patch.choices && !patch.variantId) {
1504
- delete input.variantId;
1505
- }
1506
- let line = validated(input, old.id, old.addedAt);
1507
- const duplicate = snapshot.lines.find(
1508
- (l) => l.id !== lineId && identity(lineInput(l)) === identity(lineInput(line))
1509
- );
1510
- if (duplicate) {
1511
- line = validated(
1512
- { ...lineInput(line), quantity: line.quantity + duplicate.quantity },
1513
- old.id,
1514
- old.addedAt
1515
- );
1516
- }
1517
- changed(
1518
- snapshot.lines.filter((l) => l.id !== duplicate?.id).map((l) => l.id === lineId ? line : l)
1519
- );
1520
- events.emit("update_cart_line", {
1521
- lineId,
1522
- itemId: line.itemId,
1523
- quantity: line.quantity
1524
- });
1525
- return line;
1526
- },
1527
- remove(lineId) {
1528
- ensure();
1529
- const line = snapshot.lines.find((l) => l.id === lineId);
1530
- changed(snapshot.lines.filter((l) => l.id !== lineId));
1531
- if (line) {
1532
- events.emit("remove_from_cart", {
1533
- itemId: line.itemId,
1534
- quantity: line.quantity,
1535
- value: line.lineSubtotal
1536
- });
1537
- }
1538
- },
1539
- clear() {
1540
- ensure();
1541
- changed([], { fulfilment: null, promoCode: null });
1542
- },
1543
- setFulfilment(value) {
1544
- ensure();
1545
- if (!validFulfilment(value) || value.branchId !== branch.id) {
1546
- throw new ZappieCartError([
1547
- issue(
1548
- "fulfilment_unavailable",
1549
- "Select fulfilment for the cart's branch"
1550
- )
1551
- ]);
1552
- }
1553
- changed(snapshot.lines, { fulfilment: structuredClone(value) });
1554
- },
1555
- setPromoCode(code) {
1556
- ensure();
1557
- changed(snapshot.lines, { promoCode: code?.trim() || null });
1558
- },
1559
- async moveTo(branchId) {
1560
- ensure();
1561
- const seq = ++moveSequence;
1562
- cancel();
1563
- publish({ state: attention(snapshot.issues) });
1564
- const value = await deps.workingSet.read(
1565
- branchId,
1566
- snapshot.lines.map((l) => l.itemId)
1567
- );
1568
- if (seq !== moveSequence) {
1569
- return;
1570
- }
1571
- const oldKey = key();
1572
- const oldLines = snapshot.lines;
1573
- choose(value);
1574
- const kept = oldLines.filter(
1575
- (line) => !validateLine(value.items[line.itemId], lineInput(line), {
1576
- menu: value,
1577
- branch
1578
- }).length
1579
- );
1580
- changed(kept, { fulfilment: null, promoCode: null });
1581
- const dropped = oldLines.filter((line) => !kept.includes(line)).map((line) => ({
1582
- ...issue(
1583
- "item_unavailable",
1584
- `${line.name} is unavailable at this branch`
1585
- ),
1586
- severity: "warning",
1587
- lineId: line.id
1588
- }));
1589
- const issues = [...snapshot.issues, ...dropped];
1590
- publish({ issues, state: attention(issues) });
1591
- if (oldKey !== key()) {
1592
- storage.remove(oldKey);
1593
- }
1594
- },
1595
- async quote(opts = {}) {
1596
- ensure();
1597
- clearTimeout(timer);
1598
- if (opts.force) {
1599
- events.emit("view_cart", {});
1600
- }
1601
- const body = {
1602
- branch_id: branch.id,
1603
- lines: snapshot.lines.map(wireLine),
1604
- ...snapshot.fulfilment ? { fulfilment: wireFulfilment(snapshot.fulfilment) } : {},
1605
- ...snapshot.promoCode ? { promo_code: snapshot.promoCode } : {},
1606
- menu_version: menu.version
1607
- };
1608
- const seq = revision;
1609
- const attempt = ++quoteRevision;
1610
- publish({ state: { status: "quoting" } });
1611
- try {
1612
- const client = await quoteClient();
1613
- return await client.get(body, opts.force ?? false, (quote) => {
1614
- if (seq !== revision) {
1615
- return;
1616
- }
1617
- const local = snapshot.issues.filter((i) => i.source === "local");
1618
- const remote = quote.issues.filter(
1619
- (q) => !local.some(
1620
- (l) => l.code === q.code && l.lineId === q.lineId && l.groupId === q.groupId
1621
- )
1622
- );
1623
- const issues = [...local, ...remote];
1624
- publish({ issues, state: attention(issues, quote) });
1625
- for (const i of remote) {
1626
- events.emit("cart_issue", { code: i.code });
1627
- }
1628
- if (quote.menuVersion !== menu.version || quote.issues.some((i) => i.code === "menu_version_stale")) {
1629
- void refresh().catch(
1630
- (error) => events.emit("error", { error: asError(error) })
1631
- );
1632
- }
1633
- });
1634
- } catch (error) {
1635
- if (seq === revision && attempt === quoteRevision) {
1636
- const local = snapshot.issues.filter((i) => i.source === "local");
1637
- publish({ issues: local, state: attention(local) });
1638
- events.emit("quote_failed", { code: asError(error).code });
1639
- }
1640
- throw error instanceof Error && error.name === "AbortError" ? error : asError(error);
1641
- }
1642
- },
1643
- applyFix(problem, fix = problem.fixes[0]) {
1644
- if (!snapshot.issues.includes(problem) || !fix || !problem.fixes.some((f) => JSON.stringify(f) === JSON.stringify(fix))) {
1645
- throw new ZappieError(
1646
- "invalid_request",
1647
- "Choose a current cart issue and one of its fixes"
1648
- );
1649
- }
1650
- switch (fix.action) {
1651
- case "remove_line": {
1652
- if (problem.lineId) {
1653
- cart.remove(problem.lineId);
1654
- }
1655
- return;
1656
- }
1657
- case "remove_promo":
1658
- cart.setPromoCode(null);
1659
- return;
1660
- case "set_quantity": {
1661
- if (problem.lineId) {
1662
- cart.update(problem.lineId, { quantity: fix.quantity });
1663
- }
1664
- return;
1665
- }
1666
- case "accept_price": {
1667
- quotes?.accept(problem);
1668
- const issues = snapshot.issues.filter((i) => i !== problem);
1669
- const quote = "quote" in snapshot.state ? snapshot.state.quote : void 0;
1670
- const accepted = quote ? {
1671
- ...quote,
1672
- issues: quote.issues.filter(
1673
- (i) => !(i.code === problem.code && i.lineId === problem.lineId)
1674
- )
1675
- } : void 0;
1676
- publish({ issues, state: attention(issues, accepted) });
1677
- return;
1678
- }
1679
- default:
1680
- throw new ZappieError(
1681
- "invalid_request",
1682
- `${fix.action} needs the shopper's selection; use update or setFulfilment`
1683
- );
1684
- }
1685
- },
1686
- toCheckoutPayload() {
1687
- ensure();
1688
- const blocking = snapshot.issues.filter((i) => i.severity === "blocking");
1689
- if (!snapshot.lines.length || !snapshot.fulfilment) {
1690
- blocking.push(
1691
- issue(
1692
- "fulfilment_unavailable",
1693
- "Add items and select fulfilment before checkout"
1694
- )
1695
- );
1696
- }
1697
- if (blocking.length) {
1698
- throw new ZappieCartError(blocking);
1699
- }
1700
- return {
1701
- lines: snapshot.lines.map(wireLine),
1702
- fulfilment: structuredClone(snapshot.fulfilment),
1703
- ...snapshot.promoCode ? { promo_code: snapshot.promoCode } : {},
1704
- menu_version: menu.version
1705
- };
1706
- }
1707
- };
1708
- return {
1709
- cart: Object.freeze(cart),
1710
- cartCatalogue: () => menu,
1711
- setLegacyReader(reader) {
1712
- legacy = reader;
1713
- }
1714
- };
1715
- }
1716
-
1717
- // ../sdk/src/cart/working-set.ts
1718
- function createWorkingSet(deps) {
1719
- const compose = (branchId, ids, metadata) => {
1720
- const source = metadata ?? deps.menu.peek(branchId);
1721
- const items = Object.fromEntries(
1722
- [...new Set(ids)].flatMap((id) => {
1723
- const item = deps.peekItem(id, branchId) ?? source?.items[id];
1724
- return item ? [[id, item]] : [];
1725
- })
1726
- );
1727
- return {
1728
- id: `branch:${branchId}`,
1729
- name: "Cart working set",
1730
- branchId,
1731
- version: "",
1732
- ...source,
1733
- kind: "working-set",
1734
- completeness: "partial",
1735
- items,
1736
- categories: (source?.categories ?? []).map((category) => ({
1737
- ...category,
1738
- itemIds: Object.values(items).filter(
1739
- (item) => item.placements ? item.placements.some(
1740
- (p) => p.categoryId === category.id && (source?.menus ?? [
1741
- { id: source?.id, categoryIds: [category.id] }
1742
- ]).some(
1743
- (m) => m.id === p.menuId && m.categoryIds.includes(category.id)
1744
- )
1745
- ) : category.itemIds.includes(item.id)
1746
- ).map((item) => item.id)
1747
- }))
1748
- };
1749
- };
1750
- const peek = (branchId, ids) => {
1751
- const value = deps.menu.peek(branchId);
1752
- return value && value.completeness !== "partial" ? value : compose(branchId, ids);
1753
- };
1754
- return {
1755
- peek,
1756
- async read(branchId, ids, fresh = false) {
1757
- const cached = peek(branchId, ids);
1758
- if (!ids.length || !fresh && cached.completeness !== "partial") {
1759
- return cached;
1760
- }
1761
- const response = fresh ? await deps.transport.getMenu({
1762
- ...deps.context(),
1763
- branchId,
1764
- metadataOnly: true
1765
- }) : void 0;
1766
- const metadata = response && "menu" in response ? response.menu : await deps.menu.get({ branch: branchId, metadataOnly: true });
1767
- if (metadata.branchId !== branchId) {
1768
- throw new ZappieError(
1769
- "server_error",
1770
- "Cart metadata belongs to another branch"
1771
- );
1772
- }
1773
- deps.menu.hydrate(metadata);
1774
- if (metadata.completeness !== "partial") {
1775
- return metadata;
1776
- }
1777
- await Promise.all(
1778
- [...new Set(ids)].map(async (id) => {
1779
- try {
1780
- await deps.readItem(id, branchId, fresh);
1781
- } catch (error) {
1782
- if (error.code !== "item_not_found") {
1783
- throw error;
1784
- }
1785
- }
1786
- })
1787
- );
1788
- return compose(branchId, ids, metadata);
1789
- }
1790
- };
1791
- }
1792
-
1793
- // ../sdk/src/config.ts
1794
- var API_VERSION = "2026-10-01";
1795
- function validateConfig(config, secret = false) {
1796
- if (!config.store?.trim()) {
1797
- throw new ZappieError("invalid_request", "A storefront slug is required");
1798
- }
1799
- if (secret && typeof window !== "undefined") {
1800
- throw new ZappieError(
1801
- "invalid_request",
1802
- "Secret keys cannot be used in a browser"
1803
- );
1804
- }
1805
- const pattern = secret ? /^sk_(test|live)_.+$/ : /^pk_(test|live)_.+$/;
1806
- const match = pattern.exec(config.key);
1807
- if (!match) {
1808
- throw new ZappieError(
1809
- "invalid_request",
1810
- secret ? "A secret sk_test_ or sk_live_ key is required" : "Use a publishable pk_test_ or pk_live_ key; secret keys are never accepted by createZappie"
1811
- );
1812
- }
1813
- return match[1];
1814
- }
1815
-
1816
- // ../sdk/src/internal/abort.ts
1817
- function abortReason(signal2) {
1818
- return signal2?.reason ?? new DOMException("The request was aborted", "AbortError");
1819
- }
1820
- function throwIfAborted(signal2) {
1821
- if (signal2?.aborted) {
1822
- throw abortReason(signal2);
1823
- }
1824
- }
1825
- function delay(ms, signal2) {
1826
- return new Promise((resolve, reject) => {
1827
- throwIfAborted(signal2);
1828
- const abort = () => {
1829
- clearTimeout(timer);
1830
- reject(abortReason(signal2));
1831
- };
1832
- const timer = setTimeout(() => {
1833
- signal2?.removeEventListener("abort", abort);
1834
- resolve();
1835
- }, ms);
1836
- signal2?.addEventListener("abort", abort, { once: true });
1837
- });
1838
- }
1839
-
1840
- // ../sdk/src/internal/cache.ts
1841
- var REFRESH_BACKOFF_MS = 1e4;
1842
- var EMPTY = Object.freeze({
1843
- fetchedAt: 0,
1844
- loading: false
1845
- });
1846
- var Cache = class {
1847
- constructor(onError) {
1848
- this.onError = onError;
1849
- this.entries = /* @__PURE__ */ new Map();
1850
- this.listeners = /* @__PURE__ */ new Set();
1851
- this.flights = /* @__PURE__ */ new Map();
1852
- this.subscribe = (fn) => {
1853
- this.listeners.add(fn);
1854
- return () => {
1855
- this.listeners.delete(fn);
1856
- };
1857
- };
1858
- }
1859
- snapshot(key) {
1860
- return this.entries.get(key) ?? EMPTY;
1861
- }
1862
- notify() {
1863
- for (const fn of [...this.listeners]) {
1864
- fn();
1865
- }
1866
- }
1867
- set(key, value, etag) {
1868
- this.entries.set(key, {
1869
- value,
1870
- fetchedAt: Date.now(),
1871
- loading: false,
1872
- ...etag !== void 0 ? { etag } : {}
1873
- });
1874
- this.notify();
1875
- }
1876
- stale(key) {
1877
- const entry = this.snapshot(key);
1878
- return entry.value !== void 0 && Date.now() - entry.fetchedAt >= 6e4;
1879
- }
1880
- async get(key, loader, signal2) {
1881
- throwIfAborted(signal2);
1882
- const entry = this.snapshot(key);
1883
- const age = Date.now() - entry.fetchedAt;
1884
- if (entry.value !== void 0 && age < 6e4) {
1885
- return entry.value;
1886
- }
1887
- const background = entry.value !== void 0 && age < 6e5;
1888
- if (background && entry.failedAt !== void 0 && Date.now() - entry.failedAt < REFRESH_BACKOFF_MS) {
1889
- return entry.value;
1890
- }
1891
- let flight = this.flights.get(key);
1892
- if (!flight) {
1893
- const controller = new AbortController();
1894
- flight = {
1895
- controller,
1896
- users: 0,
1897
- keepAlive: background,
1898
- promise: Promise.resolve()
1899
- };
1900
- const current2 = flight;
1901
- this.flights.set(key, current2);
1902
- const loading = { ...entry, loading: true };
1903
- this.entries.set(key, loading);
1904
- this.notify();
1905
- current2.promise = Promise.resolve().then(() => {
1906
- throwIfAborted(controller.signal);
1907
- return loader(controller.signal, entry);
1908
- }).then((result) => {
1909
- if (this.entries.get(key) === loading && !controller.signal.aborted) {
1910
- this.set(key, result.value, result.etag);
1911
- result.onStored?.();
1912
- }
1913
- return result.value;
1914
- }).catch((error) => {
1915
- if (this.entries.get(key) === loading) {
1916
- this.entries.set(
1917
- key,
1918
- controller.signal.aborted ? { ...entry, loading: false } : {
1919
- ...entry,
1920
- loading: false,
1921
- error: asError(error),
1922
- failedAt: Date.now()
1923
- }
1924
- );
1925
- this.notify();
1926
- if (!controller.signal.aborted) {
1927
- this.onError(asError(error));
1928
- }
1929
- }
1930
- throw controller.signal.aborted ? abortReason(controller.signal) : asError(error);
1931
- }).finally(() => {
1932
- if (this.flights.get(key) === current2) {
1933
- this.flights.delete(key);
1934
- }
1935
- });
1936
- }
1937
- if (background) {
1938
- flight.keepAlive = true;
1939
- void flight.promise.catch(() => {
1940
- });
1941
- return entry.value;
1942
- }
1943
- const current = flight;
1944
- current.users++;
1945
- return new Promise((resolve, reject) => {
1946
- let done = false;
1947
- const finish = (callback) => {
1948
- if (done) {
1949
- return;
1950
- }
1951
- done = true;
1952
- signal2?.removeEventListener("abort", abort);
1953
- current.users--;
1954
- callback();
1955
- };
1956
- const abort = () => {
1957
- finish(() => reject(abortReason(signal2)));
1958
- if (current.users === 0 && !current.keepAlive) {
1959
- current.controller.abort(abortReason(signal2));
1960
- if (this.flights.get(key) === current) {
1961
- this.flights.delete(key);
1962
- }
1963
- }
1964
- };
1965
- signal2?.addEventListener("abort", abort, { once: true });
1966
- current.promise.then(
1967
- (value) => finish(() => resolve(value)),
1968
- (error) => finish(() => reject(error))
1969
- );
1970
- if (signal2?.aborted) {
1971
- abort();
1972
- }
1973
- });
1974
- }
1975
- };
1976
-
1977
- // ../sdk/src/internal/emitter.ts
1978
- function createEmitter() {
1979
- const listeners = /* @__PURE__ */ new Map();
1980
- return {
1981
- on(event, fn) {
1982
- const set = listeners.get(event) ?? /* @__PURE__ */ new Set();
1983
- listeners.set(event, set);
1984
- set.add(fn);
1985
- return () => {
1986
- set.delete(fn);
1987
- };
1988
- },
1989
- emit(event, payload) {
1990
- for (const fn of [...listeners.get(event) ?? []]) {
1991
- try {
1992
- fn(payload);
1993
- } catch (error) {
1994
- console.error("Zappie event listener failed", error);
1995
- }
1996
- }
1997
- }
1998
- };
1999
- }
2000
-
2001
- // ../sdk/src/transport/rest.ts
2002
- var MAX_RETRY_WAIT_MS = 5e3;
2003
- var RestTransport = class {
2004
- constructor(opts = {}) {
2005
- const url = new URL(opts.baseUrl ?? "https://api.zappie.ai/storefront/v1");
2006
- if (url.username || url.password || url.search || url.hash || url.protocol !== "https:" && !(url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname))) {
2007
- throw new ZappieError(
2008
- "invalid_request",
2009
- "REST baseUrl requires HTTPS, or HTTP on localhost"
2010
- );
2011
- }
2012
- this.baseUrl = url.href.replace(/\/$/, "");
2013
- this.fetcher = opts.fetch ?? ((input, init) => globalThis.fetch(input, init));
2014
- }
2015
- async request(path4, ctx, etag, body) {
2016
- if (typeof window !== "undefined" && ctx.key.startsWith("sk_")) {
2017
- throw new ZappieError(
2018
- "invalid_request",
2019
- "Secret keys cannot be sent from a browser"
2020
- );
2021
- }
2022
- for (let attempt = 0; ; attempt++) {
2023
- throwIfAborted(ctx.signal);
2024
- let response;
2025
- try {
2026
- response = await this.fetcher(this.baseUrl + path4, {
2027
- method: body ? "POST" : "GET",
2028
- ...body ? { body: JSON.stringify(body) } : {},
2029
- // Workers reject redirect: "error"; a redirect is still refused below
2030
- // as a non-OK response (a 3xx, or opaqueredirect in browsers).
2031
- redirect: "manual",
2032
- headers: {
2033
- ...body ? { "Content-Type": "application/json" } : {},
2034
- ...ctx.idempotencyKey ? { "Idempotency-Key": ctx.idempotencyKey } : {},
2035
- Authorization: `Bearer ${ctx.key}`,
2036
- "Zappie-Version": ctx.version,
2037
- ...etag ? { "If-None-Match": etag } : {}
2038
- },
2039
- ...ctx.signal ? { signal: ctx.signal } : {}
2040
- });
2041
- } catch (error) {
2042
- throwIfAborted(ctx.signal);
2043
- if (attempt >= 2) {
2044
- throw new ZappieError(
2045
- "network_error",
2046
- "Could not reach the storefront service",
2047
- { cause: error }
2048
- );
2049
- }
2050
- await delay(100 * 2 ** attempt + Math.random() * 100, ctx.signal);
2051
- continue;
2052
- }
2053
- if (response.ok || response.status === 304) {
2054
- return response;
2055
- }
2056
- const retry = response.headers.get("Retry-After");
2057
- const seconds = retry !== null ? Number(retry) : NaN;
2058
- const wait2 = retry !== null ? Number.isFinite(seconds) ? Math.max(0, seconds * 1e3) : Math.max(0, Date.parse(retry) - Date.now()) : NaN;
2059
- const tooLong = Number.isFinite(wait2) && wait2 > MAX_RETRY_WAIT_MS;
2060
- if (attempt < 2 && !tooLong && (response.status === 429 || response.status >= 500)) {
2061
- await response.body?.cancel();
2062
- await delay(
2063
- Number.isFinite(wait2) ? wait2 : 100 * 2 ** attempt + Math.random() * 100,
2064
- ctx.signal
2065
- );
2066
- continue;
2067
- }
2068
- const errorBody = await response.json().catch(() => ({}));
2069
- throw wireError(
2070
- errorBody,
2071
- response.status,
2072
- response.headers.get("Zappie-Request-Id") ?? void 0
2073
- );
2074
- }
2075
- }
2076
- async json(response) {
2077
- try {
2078
- return await response.json();
2079
- } catch (cause) {
2080
- if (cause instanceof Error && cause.name === "AbortError") {
2081
- throw cause;
311
+ const onPageError = (error) => errors2.push(error.message);
312
+ const onConsole = (message) => {
313
+ if (message.type() === "error" && !/favicon.*404/i.test(message.text())) {
314
+ errors2.push(message.text());
2082
315
  }
2083
- throw new ZappieError(
2084
- "server_error",
2085
- "The storefront service returned invalid JSON",
2086
- { status: response.status, cause }
2087
- );
2088
- }
2089
- }
2090
- async quote(ctx, body) {
2091
- const request = {
2092
- ...ctx,
2093
- idempotencyKey: ctx.idempotencyKey ?? await payloadKey(JSON.stringify(body))
2094
316
  };
2095
- const raw = await this.json(
2096
- await this.request("/quote", request, void 0, body)
2097
- );
2098
- return raw;
2099
- }
2100
- async getStore(ctx) {
2101
- const mapper = import("./store-KIO5RCZO.js").catch((cause) => {
2102
- throw new ZappieError(
2103
- "server_error",
2104
- "Could not load the storefront store mapper",
2105
- { cause }
2106
- );
2107
- });
2108
- const response = this.request("/store", ctx).then(
2109
- (value) => this.json(value)
2110
- );
2111
- const [{ mapStore }, raw] = await Promise.all([mapper, response]);
2112
- return mapStore(
2113
- raw,
2114
- record,
2115
- text,
2116
- list,
2117
- hours,
2118
- mapMoney,
2119
- money,
2120
- opt,
2121
- isString,
2122
- isBoolean
317
+ page.on("pageerror", onPageError);
318
+ page.on("console", onConsole);
319
+ const paths = /* @__PURE__ */ new Set(["/"]);
320
+ const sitemap = await page.request.get(
321
+ new URL("/sitemap.xml", options.url).href
2123
322
  );
2124
- }
2125
- async getMenu(ctx) {
2126
- let cursor;
2127
- const seen = /* @__PURE__ */ new Set();
2128
- let output;
2129
- let etag;
2130
- do {
2131
- const query = new URLSearchParams({
2132
- branch: ctx.branchId,
2133
- ...ctx.metadataOnly ? { metadata_only: "true" } : {},
2134
- ...cursor ? { cursor } : {}
2135
- });
2136
- const response = await this.request(
2137
- `/menus?${query}`,
2138
- ctx,
2139
- cursor ? void 0 : ctx.ifNoneMatch
2140
- );
2141
- if (response.status === 304) {
2142
- if (cursor) {
2143
- throw new ZappieError(
2144
- "server_error",
2145
- "Unexpected 304 on a continuation page"
2146
- );
2147
- }
2148
- return {
2149
- notModified: true,
2150
- ...response.headers.get("ETag") ? { etag: response.headers.get("ETag") } : {}
2151
- };
2152
- }
2153
- const raw = record(await this.json(response));
2154
- const page = mapMenu(raw, ctx.branchId);
2155
- if (ctx.metadataOnly) {
2156
- if (Object.keys(page.items).length || raw.next_cursor) {
2157
- throw new ZappieError("server_error", "Invalid metadata response");
2158
- }
2159
- return { menu: { ...page, completeness: "partial" } };
2160
- }
2161
- if (!output) {
2162
- output = page;
2163
- etag = response.headers.get("ETag") ?? void 0;
2164
- } else {
2165
- if (page.version !== output.version) {
2166
- throw new ZappieError(
2167
- "server_error",
2168
- "Menu changed while fetching pages; retry the read"
2169
- );
2170
- }
2171
- Object.assign(output.items, page.items);
2172
- for (const category of page.categories) {
2173
- const existing = output.categories.find((c) => c.id === category.id);
2174
- if (existing) {
2175
- existing.itemIds = [
2176
- .../* @__PURE__ */ new Set([...existing.itemIds, ...category.itemIds])
2177
- ];
2178
- } else {
2179
- output.categories.push(category);
2180
- }
2181
- }
2182
- for (const section of page.menus ?? []) {
2183
- const existing = output.menus?.find((m) => m.id === section.id);
2184
- if (existing) {
2185
- existing.categoryIds = [
2186
- .../* @__PURE__ */ new Set([...existing.categoryIds, ...section.categoryIds])
2187
- ];
2188
- } else {
2189
- output.menus?.push(section);
2190
- }
2191
- }
2192
- }
2193
- cursor = typeof raw.next_cursor === "string" && raw.next_cursor ? raw.next_cursor : void 0;
2194
- if (cursor) {
2195
- if (seen.has(cursor)) {
2196
- throw new ZappieError(
2197
- "server_error",
2198
- "Repeated menu pagination cursor"
2199
- );
2200
- }
2201
- seen.add(cursor);
2202
- }
2203
- } while (cursor);
2204
- if (output.menus && output.menus.length > 1) {
2205
- output.id = `branch:${ctx.branchId}`;
2206
- output.name = "Catalogue";
2207
- delete output.schedule;
2208
- }
2209
- for (const category of output.categories) {
2210
- for (const id of category.itemIds) {
2211
- if (!Object.prototype.hasOwnProperty.call(output.items, id)) {
2212
- throw new ZappieError(
2213
- "server_error",
2214
- "Category refers to a missing item"
2215
- );
2216
- }
323
+ if (sitemap.ok()) {
324
+ for (const match of (await sitemap.text()).matchAll(
325
+ /<loc>([^<]+)<\/loc>/g
326
+ )) {
327
+ paths.add(new URL(match[1]).pathname);
2217
328
  }
2218
329
  }
2219
- if (Object.keys(output.items).length > 2e3) {
2220
- console.warn("Zappie menu contains more than 2,000 items");
330
+ for (const path4 of [...paths].sort()) {
331
+ await open(page, path4);
2221
332
  }
2222
- return { menu: output, ...etag ? { etag } : {} };
2223
- }
2224
- async getSelectedMenu(ctx) {
2225
- return (await import("./selected-menu-3GCAICBQ.js")).selectedMenu(
2226
- ctx,
2227
- (path4) => this.request(path4, ctx, ctx.ifNoneMatch),
2228
- (response) => this.json(response),
2229
- mapMenu,
2230
- () => new ZappieError("server_error", "Invalid selected menu")
2231
- );
2232
- }
2233
- async search(ctx) {
2234
- return (await import("./search-YIHT6CXN.js")).searchPage(
2235
- ctx,
2236
- async (path4) => this.json(await this.request(path4, ctx)),
2237
- mapMenu,
2238
- () => new ZappieError("server_error", "Invalid search page")
2239
- );
2240
- }
2241
- async getCategoryPage(ctx) {
2242
- const query = new URLSearchParams({
2243
- branch: ctx.branchId,
2244
- ...ctx.cursor ? { cursor: ctx.cursor } : {}
2245
- });
2246
- const raw = await this.json(
2247
- await this.request(
2248
- `/menus/${encodeURIComponent(ctx.menuId)}/categories/${encodeURIComponent(ctx.categoryId)}/items?${query}`,
2249
- ctx
2250
- )
2251
- );
2252
- return (await import("./category-page-UBHOB5IN.js")).categoryPage(
2253
- record(raw),
2254
- ctx,
2255
- mapMenu,
2256
- () => new ZappieError("server_error", "Invalid category page")
333
+ page.off("pageerror", onPageError);
334
+ page.off("console", onConsole);
335
+ assertion(
336
+ errors2.length === 0,
337
+ "page_error",
338
+ errors2[0] ?? "A route logged an error",
339
+ "Fix uncaught page errors and console errors on every public route."
2257
340
  );
2258
341
  }
2259
- async getMenuVersion(ctx) {
2260
- const value = record(
2261
- await this.json(
2262
- await this.request(
2263
- `/menus/version?${new URLSearchParams({ branch: ctx.branchId })}`,
2264
- ctx
2265
- )
2266
- )
342
+ };
343
+
344
+ // src/checks/v02-sdk.ts
345
+ var v02 = {
346
+ id: "V02",
347
+ title: "SDK test mode and store",
348
+ viewports: "mobile",
349
+ async run({ page, options }, viewport) {
350
+ await page.setViewportSize(viewport);
351
+ await open(page);
352
+ const debug = await debugValue(page, () => ({
353
+ store: window.__zappie?.store,
354
+ version: window.__zappie?.version
355
+ }));
356
+ assertion(
357
+ debug.store === options.store,
358
+ "sdk_store_mismatch",
359
+ `SDK store is ${debug.store ?? "missing"}, expected ${options.store}`,
360
+ "Create the SDK client with the same store slug passed to verify."
2267
361
  );
2268
- if (typeof value.menu_version !== "string" || typeof value.availability_hash !== "string") {
2269
- throw new ZappieError("server_error", "Invalid menu version response");
2270
- }
2271
- return {
2272
- version: value.menu_version,
2273
- availabilityHash: value.availability_hash
2274
- };
2275
- }
2276
- async getItem(ctx) {
2277
- const item = mapItem(
2278
- await this.json(
2279
- await this.request(
2280
- `/items/${encodeURIComponent(ctx.itemId)}?${new URLSearchParams({ branch: ctx.branchId })}`,
2281
- ctx
2282
- )
2283
- )
362
+ assertion(
363
+ /^0\./.test(debug.version ?? ""),
364
+ "sdk_version_unsupported",
365
+ `Unsupported SDK version ${debug.version ?? "missing"}`,
366
+ "Use a supported @zappie/sdk major version."
2284
367
  );
2285
- if (item.id !== ctx.itemId) {
2286
- throw new ZappieError(
2287
- "server_error",
2288
- "Item response identity does not match the request"
2289
- );
2290
- }
2291
- return item;
2292
368
  }
2293
369
  };
2294
370
 
2295
- // ../sdk/src/client.ts
2296
- function buildClient(input, secret = false) {
2297
- const mode = validateConfig(input, secret);
2298
- const config = Object.freeze({
2299
- ...input,
2300
- version: input.version ?? API_VERSION,
2301
- telemetry: input.telemetry ?? true
2302
- });
2303
- const transport = input.transport ?? new RestTransport({
2304
- ...input.baseUrl ? { baseUrl: input.baseUrl } : {},
2305
- ...input.fetch ? { fetch: input.fetch } : {}
2306
- });
2307
- const events = createEmitter();
2308
- const cache = new Cache((error) => events.emit("error", { error }));
2309
- const ctx = (signal2) => ({
2310
- key: config.key,
2311
- version: config.version,
2312
- ...signal2 ? { signal: signal2 } : {}
2313
- });
2314
- const store = {
2315
- get: (opts = {}) => cache.get(
2316
- "store",
2317
- async (signal2) => ({ value: await transport.getStore(ctx(signal2)) }),
2318
- opts.signal
2319
- ),
2320
- peek: () => cache.snapshot("store").value
2321
- };
2322
- const resolveBranch = (value, requested) => {
2323
- const selected = requested ?? config.branch;
2324
- const branch = selected ? value.branches.find((b) => b.id === selected || b.slug === selected) : value.branches.find((b) => b.isDefault) ?? value.branches[0];
2325
- if (!branch) {
2326
- throw new ZappieError(
2327
- "branch_not_found",
2328
- "No matching storefront branch",
2329
- { param: "branch" }
2330
- );
2331
- }
2332
- return branch;
2333
- };
2334
- let hydratedBranch;
2335
- const branchKey = (requested) => {
2336
- const value = store.peek();
2337
- if (value) {
2338
- return resolveBranch(value, requested).id;
2339
- }
2340
- const selected = requested ?? config.branch;
2341
- if (hydratedBranch && (selected === void 0 || selected === config.branch || selected === hydratedBranch)) {
2342
- return hydratedBranch;
2343
- }
2344
- return selected ?? "default";
2345
- };
2346
- const menuKey = (requested) => `menu:${branchKey(requested)}`;
2347
- const menu = {
2348
- async search(query, opts = {}) {
2349
- if (!transport.search) {
2350
- throw new ZappieError(
2351
- "invalid_request",
2352
- "Transport does not support search"
2353
- );
2354
- }
2355
- const branch = resolveBranch(await store.get(opts), opts.branch);
2356
- return transport.search({
2357
- ...ctx(opts.signal),
2358
- branchId: branch.id,
2359
- query,
2360
- ...opts.cursor ? { cursor: opts.cursor } : {}
2361
- });
2362
- },
2363
- async get(opts = {}) {
2364
- const branch = resolveBranch(await store.get(opts), opts.branch);
2365
- return cache.get(
2366
- `menu:${branch.id}${opts.metadataOnly ? ":partial" : ""}`,
2367
- async (signal2, entry) => {
2368
- const response = await transport.getMenu({
2369
- ...ctx(signal2),
2370
- branchId: branch.id,
2371
- ...opts.metadataOnly ? { metadataOnly: true } : {},
2372
- ...entry.etag ? { ifNoneMatch: entry.etag } : {}
2373
- });
2374
- if ("notModified" in response) {
2375
- if (!entry.value) {
2376
- throw new ZappieError(
2377
- "server_error",
2378
- "Received 304 without a cached menu"
2379
- );
2380
- }
2381
- return {
2382
- value: entry.value,
2383
- ...response.etag ?? entry.etag ? { etag: response.etag ?? entry.etag } : {}
2384
- };
2385
- }
2386
- if (!opts.metadataOnly && response.menu.completeness === "partial") {
2387
- throw new ZappieError(
2388
- "server_error",
2389
- "Partial catalogue cannot satisfy a complete read"
2390
- );
2391
- }
2392
- if (response.menu.branchId !== branch.id) {
2393
- throw new ZappieError(
2394
- "server_error",
2395
- "Menu belongs to a different branch"
2396
- );
2397
- }
2398
- return {
2399
- value: response.menu,
2400
- etag: response.etag ?? response.menu.version,
2401
- onStored: () => {
2402
- if (entry.value && entry.value.version !== response.menu.version) {
2403
- events.emit("menu:updated", {
2404
- branchId: branch.id,
2405
- version: response.menu.version
2406
- });
2407
- }
2408
- }
2409
- };
2410
- },
2411
- opts.signal
2412
- );
2413
- },
2414
- peek: (branch) => cache.snapshot(menuKey(branch)).value ?? cache.snapshot(`${menuKey(branch)}:partial`).value,
2415
- async page(menuId, categoryId, opts = {}) {
2416
- if (!transport.getCategoryPage) {
2417
- throw new ZappieError(
2418
- "invalid_request",
2419
- "Transport does not support category pages"
2420
- );
2421
- }
2422
- const branch = resolveBranch(await store.get(opts), opts.branch);
2423
- return transport.getCategoryPage({
2424
- ...ctx(opts.signal),
2425
- branchId: branch.id,
2426
- menuId,
2427
- categoryId,
2428
- ...opts.cursor ? { cursor: opts.cursor } : {}
2429
- });
2430
- },
2431
- async version(opts = {}) {
2432
- const branch = resolveBranch(await store.get(opts), opts.branch);
2433
- return transport.getMenuVersion({
2434
- ...ctx(opts.signal),
2435
- branchId: branch.id
2436
- });
2437
- },
2438
- async item(id, opts = {}) {
2439
- const branch = resolveBranch(await store.get(opts), opts.branch);
2440
- if (!menu.peek(branch.id) || menu.peek(branch.id)?.completeness === "partial") {
2441
- await menu.get({ ...opts, metadataOnly: true });
2442
- }
2443
- return readItem(id, branch.id, false, opts.signal);
2444
- },
2445
- hydrate(value) {
2446
- hydratedBranch ??= value.branchId;
2447
- const previous = cache.snapshot(`menu:${value.branchId}`).value;
2448
- cache.set(
2449
- `menu:${value.branchId}${value.completeness === "partial" ? ":partial" : ""}`,
2450
- value,
2451
- value.version
2452
- );
2453
- if (previous && previous.version !== value.version) {
2454
- events.emit("menu:updated", {
2455
- branchId: value.branchId,
2456
- version: value.version
2457
- });
2458
- }
2459
- }
2460
- };
2461
- const itemValues = /* @__PURE__ */ new Map();
2462
- const peekItem = (id, branch) => itemValues.get(`${branch}:${id}`);
2463
- const readItem = async (id, branch, fresh = false, signal2) => {
2464
- const complete = cache.snapshot(`menu:${branch}`).value;
2465
- if (!fresh && complete && Object.hasOwn(complete.items, id)) {
2466
- return complete.items[id];
2467
- }
2468
- const load = (requestSignal = signal2) => transport.getItem({
2469
- ...ctx(requestSignal),
2470
- branchId: branch,
2471
- itemId: id
2472
- });
2473
- try {
2474
- const value = fresh ? await load() : await cache.get(
2475
- `item:${branch}:${id}`,
2476
- async (requestSignal) => ({ value: await load(requestSignal) }),
2477
- signal2
2478
- );
2479
- if (fresh) {
2480
- cache.set(`item:${branch}:${id}`, value);
2481
- }
2482
- itemValues.set(`${branch}:${id}`, value);
2483
- return value;
2484
- } catch (error) {
2485
- if (error.code === "item_not_found") {
2486
- itemValues.delete(`${branch}:${id}`);
2487
- cache.set(`item:${branch}:${id}`, void 0);
2488
- }
2489
- throw error;
2490
- }
2491
- };
2492
- const workingSet = createWorkingSet({
2493
- menu,
2494
- store,
2495
- transport,
2496
- context: ctx,
2497
- peekItem,
2498
- readItem
2499
- });
2500
- const { cart, setLegacyReader, cartCatalogue } = createCart({
2501
- workingSet,
2502
- config,
2503
- menu,
2504
- store,
2505
- transport,
2506
- context: ctx,
2507
- events
2508
- });
2509
- return Object.freeze({
2510
- cart,
2511
- config,
2512
- mode,
2513
- store,
2514
- menu,
2515
- format,
2516
- on: events.on,
2517
- _internal: {
2518
- cartCatalogue,
2519
- setLegacyReader,
2520
- transport,
2521
- cache,
2522
- emit: events.emit,
2523
- menuKey,
2524
- hydrateStore: (value) => cache.set("store", value)
2525
- }
2526
- });
2527
- }
2528
- function createZappie(config) {
2529
- const client = buildClient(config);
2530
- if (client.mode === "test" && typeof window !== "undefined") {
2531
- void import("./debug-C3UDGUED.js").then(({ installDebug }) => installDebug(client));
2532
- }
2533
- return client;
2534
- }
2535
-
2536
- // ../sdk/src/cart/preview.ts
2537
- function previewQuantity(value) {
2538
- return Number.isInteger(value) && value >= 0 && value <= 99 ? value : 0;
2539
- }
2540
- function previewItem(item, raw) {
2541
- const input = normalizeInput(item, raw);
2542
- const groups = effectiveGroups(item, input).filter(
2543
- (group) => group.isAvailable !== false
2544
- );
2545
- const variantId = resolveVariant(item, input)?.id;
2546
- const choices = (input.choices ?? []).filter(
2547
- ({ groupId, choiceId }) => groups.some(
2548
- (group) => group.id === groupId && group.choices.some((choice) => choice.id === choiceId)
2549
- )
2550
- ).map((selection) => ({
2551
- ...selection,
2552
- quantity: previewQuantity(selection.quantity)
2553
- }));
2554
- const unitPrice = estimateUnitPrice(item, {
2555
- choices,
2556
- ...variantId ? { variantId } : {}
2557
- });
2558
- const total = multiplyMoney(unitPrice, previewQuantity(input.quantity));
2559
- return { groups, unitPrice, total };
2560
- }
2561
-
2562
371
  // src/fixtures.ts
2563
372
  async function installFault(page, fault) {
2564
373
  if (!fault) {
2565
374
  return;
2566
375
  }
376
+ if (fault === "hydration-delay") {
377
+ await page.route(/\.(?:js|mjs)(?:\?|$)/, async (route) => {
378
+ await new Promise((resolve) => setTimeout(resolve, 3500));
379
+ await route.continue();
380
+ });
381
+ }
382
+ if (fault === "no-route-data" || fault === "no-prefetch") {
383
+ await page.route(
384
+ /\/item\/[^/]+\.data(?:\?|$)/,
385
+ (route) => route.request().headers()["sec-purpose"] === "prefetch" ? route.abort() : route.continue()
386
+ );
387
+ }
388
+ if (fault === "no-prefetch") {
389
+ await page.route(/\/assets\/[^/]*item[^/]*\.js(?:\?|$)/, async (route) => {
390
+ await new Promise((resolve) => setTimeout(resolve, 1e4));
391
+ await route.continue();
392
+ });
393
+ }
394
+ if (fault === "quick-add-via-item") {
395
+ await page.addInitScript(() => {
396
+ const removeQuickAdds = () => {
397
+ if (location.pathname !== "/") {
398
+ return;
399
+ }
400
+ for (const node of document.querySelectorAll(
401
+ '[data-zappie-item] [data-zappie-action="add-to-cart"]'
402
+ )) {
403
+ node.remove();
404
+ }
405
+ };
406
+ new MutationObserver(removeQuickAdds).observe(document, {
407
+ childList: true,
408
+ subtree: true
409
+ });
410
+ });
411
+ }
2567
412
  if (fault === "no-sold-out") {
2568
413
  await page.addInitScript(() => {
2569
414
  let debug;
@@ -2632,6 +477,13 @@ async function installFault(page, fault) {
2632
477
  }
2633
478
  }
2634
479
  async function applyPageFault(page, fault) {
480
+ if (fault === "quick-add-via-item") {
481
+ await page.locator('[data-zappie-item] [data-zappie-action="add-to-cart"]').evaluateAll((nodes) => {
482
+ for (const node of nodes) {
483
+ node.remove();
484
+ }
485
+ });
486
+ }
2635
487
  if (fault === "missing-marker") {
2636
488
  await page.locator("[data-zappie-item]").evaluateAll((nodes) => {
2637
489
  for (const node of nodes) {
@@ -2704,8 +556,8 @@ function menuCoverage(items, shownIds) {
2704
556
  passes: available.length === 0 || shown / available.length >= 0.95
2705
557
  };
2706
558
  }
2707
- function priceMatches(text2, formatted) {
2708
- return text2?.includes(formatted) ?? false;
559
+ function priceMatches(text, formatted) {
560
+ return text?.includes(formatted) ?? false;
2709
561
  }
2710
562
 
2711
563
  // src/checks/v03-menu.ts
@@ -2754,9 +606,9 @@ var v03 = {
2754
606
  }
2755
607
  }
2756
608
  for (const item of available.slice(0, 2)) {
2757
- const text2 = await page.locator(`[data-zappie-item="${item.id}"]`).first().textContent();
609
+ const text = await page.locator(`[data-zappie-item="${item.id}"]`).first().textContent();
2758
610
  assertion(
2759
- priceMatches(text2, format.money(item.price)),
611
+ priceMatches(text, format.money(item.price)),
2760
612
  "price_mismatch",
2761
613
  `${item.name} does not show ${format.money(item.price)}`,
2762
614
  "Render prices with format.money from @zappie/sdk."
@@ -2775,6 +627,14 @@ var v04 = {
2775
627
  await open(page);
2776
628
  await applyPageFault(page, fault);
2777
629
  const before = await debugValue(page, () => window.__zappie.cart().count);
630
+ await page.evaluate(() => {
631
+ window.__zappieV04Events = [];
632
+ window.addEventListener("zappie:event", (event2) => {
633
+ window.__zappieV04Events.push(
634
+ event2.detail
635
+ );
636
+ });
637
+ });
2778
638
  const item = simpleItem(await readMenu(options));
2779
639
  assertion(
2780
640
  item,
@@ -2782,15 +642,43 @@ var v04 = {
2782
642
  "The test menu has no available item without required options",
2783
643
  "Keep one available item without required options in the test menu."
2784
644
  );
2785
- const simple = page.locator(`[data-zappie-item="${item.id}"]`).locator('[data-zappie-action="add-to-cart"]');
2786
- assertion(
2787
- await simple.count(),
2788
- "simple_item_missing",
2789
- "The item has no quick-add button",
2790
- "Render an available item without required options and mark its add button."
2791
- );
645
+ let openedItem = false;
646
+ let simple = page.locator(`[data-zappie-item="${item.id}"]`).locator('[data-zappie-action="add-to-cart"]');
647
+ if (!await simple.count()) {
648
+ const itemLink = page.locator(
649
+ `[data-zappie-item="${item.id}"] a[href*="/item/"], a[data-zappie-item="${item.id}"], a[href*="/item/${item.id}"]`
650
+ ).first();
651
+ assertion(
652
+ await itemLink.count(),
653
+ "simple_item_missing",
654
+ "The item has no quick-add button or item link",
655
+ "Render a marked quick-add button, or link the product card to an item page with one."
656
+ );
657
+ await itemLink.click();
658
+ openedItem = true;
659
+ simple = page.locator(
660
+ '[role="dialog"] [data-zappie-item] [data-zappie-action="add-to-cart"]:visible, main [data-zappie-item] [data-zappie-action="add-to-cart"]:visible'
661
+ ).first();
662
+ await simple.waitFor({ state: "visible", timeout: 5e3 }).catch(() => {
663
+ });
664
+ assertion(
665
+ await simple.count(),
666
+ "simple_item_missing",
667
+ "The item page has no add button",
668
+ 'Mark the item page add button with data-zappie-action="add-to-cart".'
669
+ );
670
+ }
2792
671
  await simple.click();
2793
- const updated = await page.evaluate(
672
+ const updated = openedItem ? await page.waitForFunction(
673
+ () => window.__zappieV04Events.some(
674
+ (entry) => entry.name === "add_to_cart"
675
+ ),
676
+ void 0,
677
+ { timeout: 3e3 }
678
+ ).then(
679
+ () => true,
680
+ () => false
681
+ ) : await page.evaluate(
2794
682
  (count) => new Promise((resolve) => {
2795
683
  requestAnimationFrame(() => {
2796
684
  resolve(window.__zappie.cart().count > count);
@@ -2801,12 +689,11 @@ var v04 = {
2801
689
  assertion(
2802
690
  updated,
2803
691
  "cart_not_updated",
2804
- "The cart count did not change within one frame of Add",
2805
- "Connect the marked Add control to client.cart.add and update its count immediately."
692
+ openedItem ? "The item page did not add the item to the cart" : "The cart count did not change within one frame of Add",
693
+ "Connect the marked Add control to client.cart.add and update the cart immediately."
2806
694
  );
2807
- const event = await debugValue(
2808
- page,
2809
- () => window.__zappie.events.some((entry) => entry.name === "add_to_cart")
695
+ const event = await page.evaluate(
696
+ () => window.__zappieV04Events.some((entry) => entry.name === "add_to_cart")
2810
697
  );
2811
698
  assertion(
2812
699
  event,
@@ -3058,18 +945,18 @@ var v07 = {
3058
945
  await page.locator("[data-zappie-cart-trigger]").first().click();
3059
946
  const codes = unavailable ? ["price_changed", "item_unavailable"] : ["price_changed"];
3060
947
  for (const code of codes) {
3061
- const issue2 = page.locator(`[data-zappie-issue="${code}"]`).first();
3062
- await issue2.waitFor({ state: "visible" });
948
+ const issue = page.locator(`[data-zappie-issue="${code}"]`).first();
949
+ await issue.waitFor({ state: "visible" });
3063
950
  assertion(
3064
- await issue2.isVisible(),
951
+ await issue.isVisible(),
3065
952
  "quote_issue_missing",
3066
953
  `The cart does not show ${code}`,
3067
954
  "Render each quote issue with data-zappie-issue and its fixes with data-zappie-issue-fix."
3068
955
  );
3069
956
  }
3070
957
  for (const code of codes) {
3071
- const issue2 = page.locator(`[data-zappie-issue="${code}"]`).first();
3072
- const fix = issue2.locator("[data-zappie-issue-fix]").first();
958
+ const issue = page.locator(`[data-zappie-issue="${code}"]`).first();
959
+ const fix = issue.locator("[data-zappie-issue-fix]").first();
3073
960
  assertion(
3074
961
  await fix.count(),
3075
962
  "quote_fix_missing",
@@ -3077,7 +964,7 @@ var v07 = {
3077
964
  "Connect each marked issue fix to the SDK cart applyFix action."
3078
965
  );
3079
966
  await fix.click();
3080
- await issue2.waitFor({ state: "hidden" });
967
+ await issue.waitFor({ state: "hidden" });
3081
968
  }
3082
969
  await open(page, "/checkout");
3083
970
  await page.waitForFunction(() => {
@@ -3142,73 +1029,6 @@ var v09 = {
3142
1029
  }
3143
1030
  };
3144
1031
 
3145
- // ../sdk/src/internal/customer-http.ts
3146
- function stableErrorCode(status) {
3147
- if (status === 400 || status === 422) {
3148
- return "invalid_request";
3149
- }
3150
- if (status === 401) {
3151
- return "unauthorized";
3152
- }
3153
- if (status === 404) {
3154
- return "not_found";
3155
- }
3156
- if (status === 429) {
3157
- return "rate_limited";
3158
- }
3159
- return "server_error";
3160
- }
3161
- async function apiError(response, fallback) {
3162
- const body = await response.json().catch(() => ({}));
3163
- const envelope = body && typeof body === "object" ? body : {};
3164
- const value = "error" in envelope && envelope.error && typeof envelope.error === "object" ? envelope.error : {};
3165
- return {
3166
- apiCode: "code" in value && typeof value.code === "string" ? value.code : "server_error",
3167
- message: "message" in value && typeof value.message === "string" ? value.message : fallback,
3168
- ..."param" in value && typeof value.param === "string" ? { param: value.param } : {},
3169
- ..."issues" in value ? { issues: value.issues } : {}
3170
- };
3171
- }
3172
- function wait(ms, signal2) {
3173
- signal2?.throwIfAborted();
3174
- return new Promise((resolve, reject) => {
3175
- const done = () => {
3176
- signal2?.removeEventListener("abort", aborted2);
3177
- resolve();
3178
- };
3179
- const timer = setTimeout(done, ms);
3180
- const aborted2 = () => {
3181
- clearTimeout(timer);
3182
- signal2?.removeEventListener("abort", aborted2);
3183
- reject(signal2?.reason ?? new DOMException("Aborted", "AbortError"));
3184
- };
3185
- signal2?.addEventListener("abort", aborted2, { once: true });
3186
- });
3187
- }
3188
- async function requestWithRetry(fetcher, input, init, message) {
3189
- for (let attempt = 0; attempt < 2; attempt++) {
3190
- try {
3191
- return await fetcher(input, {
3192
- ...init,
3193
- credentials: "same-origin",
3194
- headers: { Accept: "application/json", ...init.headers }
3195
- });
3196
- } catch (cause) {
3197
- if (init.signal?.aborted) {
3198
- throw init.signal.reason ?? cause;
3199
- }
3200
- if (attempt === 1) {
3201
- throw new ZappieError("network_error", message, {
3202
- cause,
3203
- retryable: true
3204
- });
3205
- }
3206
- await wait(400, init.signal ?? void 0);
3207
- }
3208
- }
3209
- throw new ZappieError("network_error", message, { retryable: true });
3210
- }
3211
-
3212
1032
  // ../sdk/src/places/error.ts
3213
1033
  var PlacesError = class extends ZappieError {
3214
1034
  constructor(status, apiCode, message, param, issues) {
@@ -3603,28 +1423,18 @@ var v14 = {
3603
1423
  viewports: "mobile",
3604
1424
  async run({ page, metrics, fault }, viewport) {
3605
1425
  await page.setViewportSize(viewport);
3606
- const cdp = await page.context().newCDPSession(page);
3607
- await cdp.send("Network.enable");
3608
- await cdp.send("Network.setCacheDisabled", { cacheDisabled: true });
3609
- await cdp.send("Network.emulateNetworkConditions", {
3610
- offline: false,
3611
- latency: 150,
3612
- downloadThroughput: 2e5,
3613
- uploadThroughput: 1e5,
3614
- connectionType: "cellular3g"
3615
- });
3616
- await cdp.send("Emulation.setCPUThrottlingRate", { rate: 4 });
1426
+ const cdp = await applyPhoneProfile(page);
3617
1427
  await page.addInitScript(() => {
3618
1428
  const state = { lcp: 0, cls: 0 };
3619
1429
  Object.assign(window, { __zappiePerformance: state });
3620
- new PerformanceObserver((list2) => {
1430
+ new PerformanceObserver((list) => {
3621
1431
  state.lcp = Math.max(
3622
1432
  state.lcp,
3623
- ...list2.getEntries().map((entry) => entry.startTime)
1433
+ ...list.getEntries().map((entry) => entry.startTime)
3624
1434
  );
3625
1435
  }).observe({ type: "largest-contentful-paint", buffered: true });
3626
- new PerformanceObserver((list2) => {
3627
- state.cls += list2.getEntries().filter(
1436
+ new PerformanceObserver((list) => {
1437
+ state.cls += list.getEntries().filter(
3628
1438
  (entry) => !entry.hadRecentInput
3629
1439
  ).reduce(
3630
1440
  (sum, entry) => sum + (entry.value ?? 0),
@@ -3683,6 +1493,130 @@ var v14 = {
3683
1493
  }
3684
1494
  };
3685
1495
 
1496
+ // src/checks/v15-tappable-product-cards.ts
1497
+ var TAPPABLE_BUDGET_MS = 3e3;
1498
+ var OPEN_BUDGET_MS = 300;
1499
+ var v15 = {
1500
+ id: "V15",
1501
+ title: "Tappable product cards",
1502
+ viewports: "mobile",
1503
+ async run({ page, metrics }, viewport) {
1504
+ await page.setViewportSize(viewport);
1505
+ const cdp = await applyPhoneProfile(page);
1506
+ try {
1507
+ await page.goto("/", { waitUntil: "domcontentloaded" });
1508
+ await cdp.send("Network.setCacheDisabled", { cacheDisabled: false });
1509
+ const link = page.locator('a[href*="/item/"]').first();
1510
+ await link.waitFor();
1511
+ const href = await link.getAttribute("href");
1512
+ assertion(
1513
+ href !== null,
1514
+ "product_card_link_missing",
1515
+ "The first product card has no destination",
1516
+ "Link the product card to its item page."
1517
+ );
1518
+ const itemPath = new URL(href, page.url()).pathname;
1519
+ await page.waitForFunction(() => "__reactRouterManifest" in window, null, {
1520
+ timeout: 8e3
1521
+ }).catch(() => void 0);
1522
+ const itemModule = await page.evaluate(itemRouteModule, itemPath);
1523
+ const prefetched = page.waitForResponse(
1524
+ (response) => {
1525
+ const path4 = new URL(response.url()).pathname;
1526
+ if (path4 === itemModule) {
1527
+ return true;
1528
+ }
1529
+ return path4 === `${itemPath}.data` && response.request().headers()["sec-purpose"] === "prefetch";
1530
+ },
1531
+ { timeout: 8e3 }
1532
+ ).then(async (response) => {
1533
+ await response.finished();
1534
+ return true;
1535
+ }).catch(() => false);
1536
+ await link.evaluate(
1537
+ (node) => node.scrollIntoView({ block: "center", inline: "nearest" })
1538
+ );
1539
+ await page.waitForFunction(() => {
1540
+ const candidate = document.querySelector('a[href*="/item/"]');
1541
+ return candidate !== null && Object.keys(candidate).some(
1542
+ (key) => key.startsWith("__reactProps$") || key.startsWith("__reactFiber$")
1543
+ );
1544
+ });
1545
+ const tappableMs = await page.evaluate(() => performance.now());
1546
+ metrics.productCardTappableMs = Math.round(tappableMs);
1547
+ assertion(
1548
+ tappableMs <= TAPPABLE_BUDGET_MS,
1549
+ "product_card_tappable_too_late",
1550
+ `The first product card became tappable in ${Math.round(tappableMs)} ms`,
1551
+ "Reduce critical scripts, fonts and high-priority images until product cards become tappable within 3 seconds."
1552
+ );
1553
+ assertion(
1554
+ await prefetched,
1555
+ "product_card_prefetch_missing",
1556
+ "The first product card did not prefetch its item page",
1557
+ 'Prefetch item links with prefetch="viewport".'
1558
+ );
1559
+ await page.waitForLoadState("networkidle");
1560
+ await page.evaluate(() => {
1561
+ document.addEventListener(
1562
+ "click",
1563
+ (event) => {
1564
+ const target2 = event.target;
1565
+ if (target2?.closest('a[href*="/item/"]')) {
1566
+ window.__zappieTapAt = performance.now();
1567
+ }
1568
+ },
1569
+ { capture: true, once: true }
1570
+ );
1571
+ });
1572
+ await link.click({ noWaitAfter: true });
1573
+ await Promise.race([
1574
+ page.waitForURL((url) => /\/item\//.test(url.pathname)),
1575
+ page.locator('[role="dialog"] [data-zappie-item]').waitFor({ state: "visible" })
1576
+ ]);
1577
+ const openMs = await page.evaluate(
1578
+ () => performance.now() - (window.__zappieTapAt ?? 0)
1579
+ );
1580
+ metrics.productCardOpenMs = Math.round(openMs);
1581
+ assertion(
1582
+ openMs <= OPEN_BUDGET_MS,
1583
+ "product_card_open_too_slow",
1584
+ `The first product card opened in ${Math.round(openMs)} ms`,
1585
+ 'Set routeDiscovery to initial and prefetch item links with prefetch="viewport" so the item surface opens within 300 ms.'
1586
+ );
1587
+ } finally {
1588
+ await cdp.detach();
1589
+ }
1590
+ }
1591
+ };
1592
+ function itemRouteModule(itemPath) {
1593
+ const manifest = window.__reactRouterManifest;
1594
+ if (!manifest) {
1595
+ return void 0;
1596
+ }
1597
+ const segments = (path4) => path4.split("/").filter(Boolean);
1598
+ const fullPath = (route2) => {
1599
+ const parts = [];
1600
+ for (let current = route2; current; current = current.parentId ? manifest.routes[current.parentId] : void 0) {
1601
+ if (current.path) {
1602
+ parts.unshift(current.path);
1603
+ }
1604
+ }
1605
+ return segments(parts.join("/"));
1606
+ };
1607
+ const wanted = segments(itemPath);
1608
+ const route = Object.values(manifest.routes).find((entry) => {
1609
+ if (!entry.path) {
1610
+ return false;
1611
+ }
1612
+ const pattern = fullPath(entry);
1613
+ return pattern.length === wanted.length && pattern.every(
1614
+ (part, index) => part.startsWith(":") || part === wanted[index]
1615
+ );
1616
+ });
1617
+ return route ? new URL(route.module, location.href).pathname : void 0;
1618
+ }
1619
+
3686
1620
  // src/checks/index.ts
3687
1621
  var checks = [
3688
1622
  v01,
@@ -3695,7 +1629,8 @@ var checks = [
3695
1629
  v09,
3696
1630
  v10,
3697
1631
  v13,
3698
- v14
1632
+ v14,
1633
+ v15
3699
1634
  ];
3700
1635
  var blocked = [
3701
1636
  [
@@ -3827,7 +1762,7 @@ async function reset(options) {
3827
1762
  }
3828
1763
 
3829
1764
  // src/version.ts
3830
- var PACKAGE_VERSION = true ? "0.1.3" : "0.0.0-development";
1765
+ var PACKAGE_VERSION = true ? "0.1.5" : "0.0.0-development";
3831
1766
 
3832
1767
  // src/runner.ts
3833
1768
  var VERSION = PACKAGE_VERSION;