@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/CHANGELOG.md +14 -0
- package/dist/chunk-JYFSFOIS.js +2795 -0
- package/dist/chunk-JYFSFOIS.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +305 -2370
- package/dist/index.js.map +1 -1
- package/dist/quote-body-NAFPZOGR.js +24 -0
- package/dist/quote-body-NAFPZOGR.js.map +1 -0
- package/dist/{quote-client-6XJJHITL.js → quote-client-3NC5KSBE.js} +78 -5
- package/dist/quote-client-3NC5KSBE.js.map +1 -0
- package/dist/{selected-menu-3GCAICBQ.js → selected-menu-5AIX7JHU.js} +2 -2
- package/dist/selected-menu-5AIX7JHU.js.map +1 -0
- package/dist/{store-KIO5RCZO.js → store-AOU2FRUX.js} +20 -9
- package/dist/store-AOU2FRUX.js.map +1 -0
- package/package.json +2 -2
- package/schema/verify-report.v1.json +3 -1
- package/dist/chunk-WUWUDCYO.js +0 -485
- package/dist/chunk-WUWUDCYO.js.map +0 -1
- package/dist/quote-client-6XJJHITL.js.map +0 -1
- package/dist/selected-menu-3GCAICBQ.js.map +0 -1
- package/dist/store-KIO5RCZO.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,23 +1,17 @@
|
|
|
1
1
|
import {
|
|
2
|
+
API_VERSION,
|
|
3
|
+
RestTransport,
|
|
2
4
|
ZappieError,
|
|
3
|
-
|
|
5
|
+
apiError,
|
|
6
|
+
createZappie,
|
|
4
7
|
format,
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
2096
|
-
|
|
2097
|
-
);
|
|
2098
|
-
|
|
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
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
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
|
-
|
|
2220
|
-
|
|
330
|
+
for (const path4 of [...paths].sort()) {
|
|
331
|
+
await open(page, path4);
|
|
2221
332
|
}
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
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
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
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
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
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(
|
|
2708
|
-
return
|
|
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
|
|
609
|
+
const text = await page.locator(`[data-zappie-item="${item.id}"]`).first().textContent();
|
|
2758
610
|
assertion(
|
|
2759
|
-
priceMatches(
|
|
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
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
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.
|
|
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
|
|
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
|
|
2808
|
-
|
|
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
|
|
3062
|
-
await
|
|
948
|
+
const issue = page.locator(`[data-zappie-issue="${code}"]`).first();
|
|
949
|
+
await issue.waitFor({ state: "visible" });
|
|
3063
950
|
assertion(
|
|
3064
|
-
await
|
|
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
|
|
3072
|
-
const fix =
|
|
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
|
|
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
|
|
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((
|
|
1430
|
+
new PerformanceObserver((list) => {
|
|
3621
1431
|
state.lcp = Math.max(
|
|
3622
1432
|
state.lcp,
|
|
3623
|
-
...
|
|
1433
|
+
...list.getEntries().map((entry) => entry.startTime)
|
|
3624
1434
|
);
|
|
3625
1435
|
}).observe({ type: "largest-contentful-paint", buffered: true });
|
|
3626
|
-
new PerformanceObserver((
|
|
3627
|
-
state.cls +=
|
|
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.
|
|
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;
|