@zappie/verify 0.0.0-stage → 0.1.1
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 +11 -0
- package/LICENSE +21 -0
- package/README.md +55 -2
- package/dist/category-page-UBHOB5IN.js +21 -0
- package/dist/category-page-UBHOB5IN.js.map +1 -0
- package/dist/chunk-WUWUDCYO.js +485 -0
- package/dist/chunk-WUWUDCYO.js.map +1 -0
- package/dist/debug-C3UDGUED.js +78 -0
- package/dist/debug-C3UDGUED.js.map +1 -0
- package/dist/index.d.ts +100 -0
- package/dist/index.js +4049 -0
- package/dist/index.js.map +1 -0
- package/dist/quote-client-6XJJHITL.js +253 -0
- package/dist/quote-client-6XJJHITL.js.map +1 -0
- package/dist/search-YIHT6CXN.js +32 -0
- package/dist/search-YIHT6CXN.js.map +1 -0
- package/dist/selected-menu-3GCAICBQ.js +79 -0
- package/dist/selected-menu-3GCAICBQ.js.map +1 -0
- package/dist/store-KIO5RCZO.js +120 -0
- package/dist/store-KIO5RCZO.js.map +1 -0
- package/package.json +49 -3
- package/schema/verify-report.v1.json +104 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,4049 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ZappieError,
|
|
3
|
+
asError,
|
|
4
|
+
format,
|
|
5
|
+
hours,
|
|
6
|
+
isBoolean,
|
|
7
|
+
isString,
|
|
8
|
+
list,
|
|
9
|
+
mapItem,
|
|
10
|
+
mapMenu,
|
|
11
|
+
mapMoney,
|
|
12
|
+
money,
|
|
13
|
+
multiplyMoney,
|
|
14
|
+
opt,
|
|
15
|
+
payloadKey,
|
|
16
|
+
record,
|
|
17
|
+
sumMoney,
|
|
18
|
+
text,
|
|
19
|
+
wireError
|
|
20
|
+
} from "./chunk-WUWUDCYO.js";
|
|
21
|
+
|
|
22
|
+
// src/error.ts
|
|
23
|
+
var VerifyError = class extends Error {
|
|
24
|
+
constructor(code, message, hint) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.code = code;
|
|
27
|
+
this.hint = hint;
|
|
28
|
+
this.name = "VerifyError";
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
function assertion(condition, code, message, hint) {
|
|
32
|
+
if (!condition) {
|
|
33
|
+
throw new VerifyError(code, message, hint);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// src/local-preview.ts
|
|
38
|
+
import { spawn } from "child_process";
|
|
39
|
+
import { readFile } from "fs/promises";
|
|
40
|
+
import path from "path";
|
|
41
|
+
|
|
42
|
+
// src/project-command.ts
|
|
43
|
+
import { createServer } from "net";
|
|
44
|
+
function execArgs(manager, binary, args) {
|
|
45
|
+
if (manager === "npm") {
|
|
46
|
+
return { executable: "npm", args: ["exec", "--no", "--", binary, ...args] };
|
|
47
|
+
}
|
|
48
|
+
if (manager === "yarn") {
|
|
49
|
+
return { executable: "yarn", args: ["exec", binary, ...args] };
|
|
50
|
+
}
|
|
51
|
+
if (manager === "bun") {
|
|
52
|
+
return { executable: "bun", args: ["x", "--no-install", binary, ...args] };
|
|
53
|
+
}
|
|
54
|
+
return { executable: "pnpm", args: ["exec", binary, ...args] };
|
|
55
|
+
}
|
|
56
|
+
function freePort() {
|
|
57
|
+
return new Promise((resolve, reject) => {
|
|
58
|
+
const server = createServer();
|
|
59
|
+
server.once("error", reject);
|
|
60
|
+
server.listen(0, "127.0.0.1", () => {
|
|
61
|
+
const address = server.address();
|
|
62
|
+
server.close(
|
|
63
|
+
() => typeof address === "object" && address ? resolve(address.port) : reject(new Error("No free port was assigned."))
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
var outputTail = (value) => value.trim().split("\n").slice(-12).join("\n");
|
|
69
|
+
|
|
70
|
+
// src/local-preview.ts
|
|
71
|
+
async function startLocalPreview(options) {
|
|
72
|
+
const manifest = JSON.parse(
|
|
73
|
+
await readFile(path.join(options.cwd, "package.json"), "utf8")
|
|
74
|
+
);
|
|
75
|
+
const packages = { ...manifest.dependencies, ...manifest.devDependencies };
|
|
76
|
+
const framework = packages["react-router"] ? "react-router" : packages.vite ? "vite" : void 0;
|
|
77
|
+
if (!framework || framework === "react-router" && !packages.wrangler) {
|
|
78
|
+
throw new VerifyError(
|
|
79
|
+
"preview_unsupported",
|
|
80
|
+
"Local verify supports React Router on Cloudflare and Vite. Pass a URL for this project.",
|
|
81
|
+
"Pass the deployed or externally started preview URL to zappie verify."
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
await run(
|
|
85
|
+
options,
|
|
86
|
+
execArgs(options.packageManager, framework, ["build"]),
|
|
87
|
+
"build"
|
|
88
|
+
);
|
|
89
|
+
const port = await freePort();
|
|
90
|
+
const command = previewCommand(options, framework, port);
|
|
91
|
+
const child = spawn(command.executable, command.args, {
|
|
92
|
+
cwd: options.cwd,
|
|
93
|
+
env: options.environment ?? process.env,
|
|
94
|
+
detached: process.platform !== "win32",
|
|
95
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
96
|
+
});
|
|
97
|
+
let output = "";
|
|
98
|
+
child.stdout?.on("data", (value) => output += String(value));
|
|
99
|
+
child.stderr?.on("data", (value) => output += String(value));
|
|
100
|
+
const abort = () => void stop(child);
|
|
101
|
+
options.signal?.addEventListener("abort", abort, { once: true });
|
|
102
|
+
const url = `http://127.0.0.1:${port}`;
|
|
103
|
+
try {
|
|
104
|
+
await ready(url, child, options.signal, () => output);
|
|
105
|
+
} catch (error) {
|
|
106
|
+
await stop(child);
|
|
107
|
+
options.signal?.removeEventListener("abort", abort);
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
url,
|
|
112
|
+
close: async () => {
|
|
113
|
+
options.signal?.removeEventListener("abort", abort);
|
|
114
|
+
await stop(child);
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
function previewCommand(options, framework, port) {
|
|
119
|
+
if (framework === "vite") {
|
|
120
|
+
return execArgs(options.packageManager, "vite", [
|
|
121
|
+
"preview",
|
|
122
|
+
"--host",
|
|
123
|
+
"127.0.0.1",
|
|
124
|
+
"--port",
|
|
125
|
+
String(port),
|
|
126
|
+
"--strictPort"
|
|
127
|
+
]);
|
|
128
|
+
}
|
|
129
|
+
const args = [
|
|
130
|
+
"dev",
|
|
131
|
+
"--port",
|
|
132
|
+
String(port),
|
|
133
|
+
"--ip",
|
|
134
|
+
"127.0.0.1",
|
|
135
|
+
"--inspector-port",
|
|
136
|
+
"0",
|
|
137
|
+
"--no-show-interactive-dev-session"
|
|
138
|
+
];
|
|
139
|
+
for (const [name, value] of Object.entries({
|
|
140
|
+
ZAPPIE_KEY: options.key,
|
|
141
|
+
ZAPPIE_STORE: options.store,
|
|
142
|
+
ZAPPIE_API: options.environment?.ZAPPIE_API ?? process.env.ZAPPIE_API,
|
|
143
|
+
ZAPPIE_BRANCH: options.environment?.ZAPPIE_BRANCH ?? process.env.ZAPPIE_BRANCH,
|
|
144
|
+
ZAPPIE_IMAGES: options.environment?.ZAPPIE_IMAGES ?? process.env.ZAPPIE_IMAGES
|
|
145
|
+
})) {
|
|
146
|
+
if (value) {
|
|
147
|
+
args.push("--var", `${name}:${value}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return execArgs(options.packageManager, "wrangler", args);
|
|
151
|
+
}
|
|
152
|
+
async function run(options, command, label2) {
|
|
153
|
+
const child = spawn(command.executable, command.args, {
|
|
154
|
+
cwd: options.cwd,
|
|
155
|
+
env: options.environment ?? process.env,
|
|
156
|
+
signal: options.signal,
|
|
157
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
158
|
+
});
|
|
159
|
+
let output = "";
|
|
160
|
+
child.stdout.on("data", (value) => output += String(value));
|
|
161
|
+
child.stderr.on("data", (value) => output += String(value));
|
|
162
|
+
const code = await new Promise((resolve, reject) => {
|
|
163
|
+
child.once("error", reject);
|
|
164
|
+
child.once("close", (value) => resolve(value ?? 1));
|
|
165
|
+
});
|
|
166
|
+
if (code !== 0) {
|
|
167
|
+
throw new VerifyError(
|
|
168
|
+
`${label2}_failed`,
|
|
169
|
+
`${label2} failed:
|
|
170
|
+
${outputTail(output)}`,
|
|
171
|
+
`Fix the project ${label2} and rerun verify.`
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
async function ready(url, child, signal2, output) {
|
|
176
|
+
const expires = Date.now() + 6e4;
|
|
177
|
+
while (Date.now() < expires) {
|
|
178
|
+
signal2?.throwIfAborted();
|
|
179
|
+
if (child.exitCode !== null) {
|
|
180
|
+
throw new VerifyError(
|
|
181
|
+
"preview_exited",
|
|
182
|
+
`Preview exited before it was ready:
|
|
183
|
+
${outputTail(output())}`,
|
|
184
|
+
"Run the project preview directly and fix its startup error."
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
try {
|
|
188
|
+
const response = await fetch(url, signal2 ? { signal: signal2 } : {});
|
|
189
|
+
if (response.status < 500) {
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
} catch (error) {
|
|
193
|
+
if (signal2?.aborted) {
|
|
194
|
+
throw error;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
198
|
+
}
|
|
199
|
+
throw new VerifyError(
|
|
200
|
+
"preview_timeout",
|
|
201
|
+
`Preview did not become ready:
|
|
202
|
+
${outputTail(output())}`,
|
|
203
|
+
"Make the preview answer on its assigned port within 60 seconds."
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
async function stop(child) {
|
|
207
|
+
if (child.exitCode !== null || !child.pid) {
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
signal(child, "SIGTERM");
|
|
211
|
+
await Promise.race([
|
|
212
|
+
new Promise((resolve) => child.once("close", resolve)),
|
|
213
|
+
new Promise((resolve) => setTimeout(resolve, 2e3))
|
|
214
|
+
]);
|
|
215
|
+
if (child.exitCode === null) {
|
|
216
|
+
signal(child, "SIGKILL");
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
function signal(child, value) {
|
|
220
|
+
try {
|
|
221
|
+
if (process.platform === "win32") {
|
|
222
|
+
child.kill(value);
|
|
223
|
+
} else if (child.pid) {
|
|
224
|
+
process.kill(-child.pid, value);
|
|
225
|
+
}
|
|
226
|
+
} catch (error) {
|
|
227
|
+
if (!(error instanceof Error && "code" in error && error.code === "ESRCH")) {
|
|
228
|
+
throw error;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// src/project-config.ts
|
|
234
|
+
import { readFile as readFile2, stat } from "fs/promises";
|
|
235
|
+
import path2 from "path";
|
|
236
|
+
import { pathToFileURL } from "url";
|
|
237
|
+
function supportsTypeScriptImport(version = process.versions.node) {
|
|
238
|
+
const [major = 0, minor = 0] = version.split(".").map(Number);
|
|
239
|
+
return major > 22 || major === 22 && minor >= 18;
|
|
240
|
+
}
|
|
241
|
+
async function readStore(cwd) {
|
|
242
|
+
if (!supportsTypeScriptImport()) {
|
|
243
|
+
throw new VerifyError(
|
|
244
|
+
"unsupported_node",
|
|
245
|
+
"zappie verify needs Node 22.18 or later to read zappie.config.ts",
|
|
246
|
+
"Use Node 22.18 or later, or pass --store."
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
const file = path2.join(cwd, "zappie.config.ts");
|
|
250
|
+
try {
|
|
251
|
+
const version = (await stat(file)).mtimeMs;
|
|
252
|
+
await readFile2(file, "utf8");
|
|
253
|
+
const loaded = await import(`${pathToFileURL(file).href}?verify=${version}`);
|
|
254
|
+
const value = loaded.default;
|
|
255
|
+
if (value && typeof value === "object" && "store" in value && typeof value.store === "string") {
|
|
256
|
+
return value.store;
|
|
257
|
+
}
|
|
258
|
+
} catch (error) {
|
|
259
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
260
|
+
throw new VerifyError(
|
|
261
|
+
"config_unreadable",
|
|
262
|
+
`Could not read zappie.config.ts: ${message}`,
|
|
263
|
+
"Create a readable zappie.config.ts or pass --store."
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
throw new VerifyError(
|
|
267
|
+
"store_missing",
|
|
268
|
+
"zappie.config.ts must define a store",
|
|
269
|
+
"Define store in zappie.config.ts or pass --store."
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// src/runner.ts
|
|
274
|
+
import { chromium, errors } from "@playwright/test";
|
|
275
|
+
import { mkdir } from "fs/promises";
|
|
276
|
+
import path3 from "path";
|
|
277
|
+
|
|
278
|
+
// src/checks/common.ts
|
|
279
|
+
async function open(page, path4 = "/") {
|
|
280
|
+
const response = await page.goto(path4, { waitUntil: "domcontentloaded" });
|
|
281
|
+
assertion(
|
|
282
|
+
response?.status() === 200,
|
|
283
|
+
"route_not_rendered",
|
|
284
|
+
`${path4} answered ${response?.status() ?? "without a response"}`,
|
|
285
|
+
"Make the route return a 200 response and render its page shell."
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
async function debugValue(page, expression) {
|
|
289
|
+
await page.waitForFunction(() => Boolean(window.__zappie));
|
|
290
|
+
return page.evaluate(expression);
|
|
291
|
+
}
|
|
292
|
+
var mobile = { width: 390, height: 844 };
|
|
293
|
+
var desktop = { width: 1440, height: 900 };
|
|
294
|
+
|
|
295
|
+
// src/checks/v01-routes.ts
|
|
296
|
+
var v01 = {
|
|
297
|
+
id: "V01",
|
|
298
|
+
title: "Routes render",
|
|
299
|
+
viewports: "both",
|
|
300
|
+
async run({ page, options }, viewport) {
|
|
301
|
+
await page.setViewportSize(viewport);
|
|
302
|
+
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;
|
|
2082
|
+
}
|
|
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
|
+
};
|
|
2095
|
+
const raw = await this.json(
|
|
2096
|
+
await this.request("/quote", request, void 0, body)
|
|
2097
|
+
);
|
|
2098
|
+
return raw;
|
|
2099
|
+
}
|
|
2100
|
+
async getStore(ctx) {
|
|
2101
|
+
const mapper = import("./store-KIO5RCZO.js").catch((cause) => {
|
|
2102
|
+
throw new ZappieError(
|
|
2103
|
+
"server_error",
|
|
2104
|
+
"Could not load the storefront store mapper",
|
|
2105
|
+
{ cause }
|
|
2106
|
+
);
|
|
2107
|
+
});
|
|
2108
|
+
const response = this.request("/store", ctx).then(
|
|
2109
|
+
(value) => this.json(value)
|
|
2110
|
+
);
|
|
2111
|
+
const [{ mapStore }, raw] = await Promise.all([mapper, response]);
|
|
2112
|
+
return mapStore(
|
|
2113
|
+
raw,
|
|
2114
|
+
record,
|
|
2115
|
+
text,
|
|
2116
|
+
list,
|
|
2117
|
+
hours,
|
|
2118
|
+
mapMoney,
|
|
2119
|
+
money,
|
|
2120
|
+
opt,
|
|
2121
|
+
isString,
|
|
2122
|
+
isBoolean
|
|
2123
|
+
);
|
|
2124
|
+
}
|
|
2125
|
+
async getMenu(ctx) {
|
|
2126
|
+
let cursor;
|
|
2127
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2128
|
+
let output;
|
|
2129
|
+
let etag;
|
|
2130
|
+
do {
|
|
2131
|
+
const query = new URLSearchParams({
|
|
2132
|
+
branch: ctx.branchId,
|
|
2133
|
+
...ctx.metadataOnly ? { metadata_only: "true" } : {},
|
|
2134
|
+
...cursor ? { cursor } : {}
|
|
2135
|
+
});
|
|
2136
|
+
const response = await this.request(
|
|
2137
|
+
`/menus?${query}`,
|
|
2138
|
+
ctx,
|
|
2139
|
+
cursor ? void 0 : ctx.ifNoneMatch
|
|
2140
|
+
);
|
|
2141
|
+
if (response.status === 304) {
|
|
2142
|
+
if (cursor) {
|
|
2143
|
+
throw new ZappieError(
|
|
2144
|
+
"server_error",
|
|
2145
|
+
"Unexpected 304 on a continuation page"
|
|
2146
|
+
);
|
|
2147
|
+
}
|
|
2148
|
+
return {
|
|
2149
|
+
notModified: true,
|
|
2150
|
+
...response.headers.get("ETag") ? { etag: response.headers.get("ETag") } : {}
|
|
2151
|
+
};
|
|
2152
|
+
}
|
|
2153
|
+
const raw = record(await this.json(response));
|
|
2154
|
+
const page = mapMenu(raw, ctx.branchId);
|
|
2155
|
+
if (ctx.metadataOnly) {
|
|
2156
|
+
if (Object.keys(page.items).length || raw.next_cursor) {
|
|
2157
|
+
throw new ZappieError("server_error", "Invalid metadata response");
|
|
2158
|
+
}
|
|
2159
|
+
return { menu: { ...page, completeness: "partial" } };
|
|
2160
|
+
}
|
|
2161
|
+
if (!output) {
|
|
2162
|
+
output = page;
|
|
2163
|
+
etag = response.headers.get("ETag") ?? void 0;
|
|
2164
|
+
} else {
|
|
2165
|
+
if (page.version !== output.version) {
|
|
2166
|
+
throw new ZappieError(
|
|
2167
|
+
"server_error",
|
|
2168
|
+
"Menu changed while fetching pages; retry the read"
|
|
2169
|
+
);
|
|
2170
|
+
}
|
|
2171
|
+
Object.assign(output.items, page.items);
|
|
2172
|
+
for (const category of page.categories) {
|
|
2173
|
+
const existing = output.categories.find((c) => c.id === category.id);
|
|
2174
|
+
if (existing) {
|
|
2175
|
+
existing.itemIds = [
|
|
2176
|
+
.../* @__PURE__ */ new Set([...existing.itemIds, ...category.itemIds])
|
|
2177
|
+
];
|
|
2178
|
+
} else {
|
|
2179
|
+
output.categories.push(category);
|
|
2180
|
+
}
|
|
2181
|
+
}
|
|
2182
|
+
for (const section of page.menus ?? []) {
|
|
2183
|
+
const existing = output.menus?.find((m) => m.id === section.id);
|
|
2184
|
+
if (existing) {
|
|
2185
|
+
existing.categoryIds = [
|
|
2186
|
+
.../* @__PURE__ */ new Set([...existing.categoryIds, ...section.categoryIds])
|
|
2187
|
+
];
|
|
2188
|
+
} else {
|
|
2189
|
+
output.menus?.push(section);
|
|
2190
|
+
}
|
|
2191
|
+
}
|
|
2192
|
+
}
|
|
2193
|
+
cursor = typeof raw.next_cursor === "string" && raw.next_cursor ? raw.next_cursor : void 0;
|
|
2194
|
+
if (cursor) {
|
|
2195
|
+
if (seen.has(cursor)) {
|
|
2196
|
+
throw new ZappieError(
|
|
2197
|
+
"server_error",
|
|
2198
|
+
"Repeated menu pagination cursor"
|
|
2199
|
+
);
|
|
2200
|
+
}
|
|
2201
|
+
seen.add(cursor);
|
|
2202
|
+
}
|
|
2203
|
+
} while (cursor);
|
|
2204
|
+
if (output.menus && output.menus.length > 1) {
|
|
2205
|
+
output.id = `branch:${ctx.branchId}`;
|
|
2206
|
+
output.name = "Catalogue";
|
|
2207
|
+
delete output.schedule;
|
|
2208
|
+
}
|
|
2209
|
+
for (const category of output.categories) {
|
|
2210
|
+
for (const id of category.itemIds) {
|
|
2211
|
+
if (!Object.prototype.hasOwnProperty.call(output.items, id)) {
|
|
2212
|
+
throw new ZappieError(
|
|
2213
|
+
"server_error",
|
|
2214
|
+
"Category refers to a missing item"
|
|
2215
|
+
);
|
|
2216
|
+
}
|
|
2217
|
+
}
|
|
2218
|
+
}
|
|
2219
|
+
if (Object.keys(output.items).length > 2e3) {
|
|
2220
|
+
console.warn("Zappie menu contains more than 2,000 items");
|
|
2221
|
+
}
|
|
2222
|
+
return { menu: output, ...etag ? { etag } : {} };
|
|
2223
|
+
}
|
|
2224
|
+
async getSelectedMenu(ctx) {
|
|
2225
|
+
return (await import("./selected-menu-3GCAICBQ.js")).selectedMenu(
|
|
2226
|
+
ctx,
|
|
2227
|
+
(path4) => this.request(path4, ctx, ctx.ifNoneMatch),
|
|
2228
|
+
(response) => this.json(response),
|
|
2229
|
+
mapMenu,
|
|
2230
|
+
() => new ZappieError("server_error", "Invalid selected menu")
|
|
2231
|
+
);
|
|
2232
|
+
}
|
|
2233
|
+
async search(ctx) {
|
|
2234
|
+
return (await import("./search-YIHT6CXN.js")).searchPage(
|
|
2235
|
+
ctx,
|
|
2236
|
+
async (path4) => this.json(await this.request(path4, ctx)),
|
|
2237
|
+
mapMenu,
|
|
2238
|
+
() => new ZappieError("server_error", "Invalid search page")
|
|
2239
|
+
);
|
|
2240
|
+
}
|
|
2241
|
+
async getCategoryPage(ctx) {
|
|
2242
|
+
const query = new URLSearchParams({
|
|
2243
|
+
branch: ctx.branchId,
|
|
2244
|
+
...ctx.cursor ? { cursor: ctx.cursor } : {}
|
|
2245
|
+
});
|
|
2246
|
+
const raw = await this.json(
|
|
2247
|
+
await this.request(
|
|
2248
|
+
`/menus/${encodeURIComponent(ctx.menuId)}/categories/${encodeURIComponent(ctx.categoryId)}/items?${query}`,
|
|
2249
|
+
ctx
|
|
2250
|
+
)
|
|
2251
|
+
);
|
|
2252
|
+
return (await import("./category-page-UBHOB5IN.js")).categoryPage(
|
|
2253
|
+
record(raw),
|
|
2254
|
+
ctx,
|
|
2255
|
+
mapMenu,
|
|
2256
|
+
() => new ZappieError("server_error", "Invalid category page")
|
|
2257
|
+
);
|
|
2258
|
+
}
|
|
2259
|
+
async getMenuVersion(ctx) {
|
|
2260
|
+
const value = record(
|
|
2261
|
+
await this.json(
|
|
2262
|
+
await this.request(
|
|
2263
|
+
`/menus/version?${new URLSearchParams({ branch: ctx.branchId })}`,
|
|
2264
|
+
ctx
|
|
2265
|
+
)
|
|
2266
|
+
)
|
|
2267
|
+
);
|
|
2268
|
+
if (typeof value.menu_version !== "string" || typeof value.availability_hash !== "string") {
|
|
2269
|
+
throw new ZappieError("server_error", "Invalid menu version response");
|
|
2270
|
+
}
|
|
2271
|
+
return {
|
|
2272
|
+
version: value.menu_version,
|
|
2273
|
+
availabilityHash: value.availability_hash
|
|
2274
|
+
};
|
|
2275
|
+
}
|
|
2276
|
+
async getItem(ctx) {
|
|
2277
|
+
const item = mapItem(
|
|
2278
|
+
await this.json(
|
|
2279
|
+
await this.request(
|
|
2280
|
+
`/items/${encodeURIComponent(ctx.itemId)}?${new URLSearchParams({ branch: ctx.branchId })}`,
|
|
2281
|
+
ctx
|
|
2282
|
+
)
|
|
2283
|
+
)
|
|
2284
|
+
);
|
|
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
|
+
}
|
|
2293
|
+
};
|
|
2294
|
+
|
|
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
|
+
// src/fixtures.ts
|
|
2563
|
+
async function installFault(page, fault) {
|
|
2564
|
+
if (!fault) {
|
|
2565
|
+
return;
|
|
2566
|
+
}
|
|
2567
|
+
if (fault === "no-sold-out") {
|
|
2568
|
+
await page.addInitScript(() => {
|
|
2569
|
+
let debug;
|
|
2570
|
+
Object.defineProperty(window, "__zappie", {
|
|
2571
|
+
configurable: true,
|
|
2572
|
+
get: () => debug,
|
|
2573
|
+
set(value) {
|
|
2574
|
+
debug = value;
|
|
2575
|
+
if (value) {
|
|
2576
|
+
const catalogue = value.catalogue;
|
|
2577
|
+
value.catalogue = async () => {
|
|
2578
|
+
const result = await catalogue();
|
|
2579
|
+
return {
|
|
2580
|
+
...result,
|
|
2581
|
+
menu: {
|
|
2582
|
+
...result.menu,
|
|
2583
|
+
items: Object.fromEntries(
|
|
2584
|
+
Object.entries(result.menu.items).filter(
|
|
2585
|
+
([, item]) => item.isAvailable
|
|
2586
|
+
)
|
|
2587
|
+
)
|
|
2588
|
+
}
|
|
2589
|
+
};
|
|
2590
|
+
};
|
|
2591
|
+
}
|
|
2592
|
+
}
|
|
2593
|
+
});
|
|
2594
|
+
});
|
|
2595
|
+
}
|
|
2596
|
+
if (fault === "add-stuck") {
|
|
2597
|
+
await page.addInitScript(() => {
|
|
2598
|
+
document.addEventListener(
|
|
2599
|
+
"click",
|
|
2600
|
+
(event) => {
|
|
2601
|
+
const target2 = event.target;
|
|
2602
|
+
if (target2?.closest('[data-zappie-action="add-to-cart"]')) {
|
|
2603
|
+
event.stopImmediatePropagation();
|
|
2604
|
+
event.preventDefault();
|
|
2605
|
+
}
|
|
2606
|
+
},
|
|
2607
|
+
true
|
|
2608
|
+
);
|
|
2609
|
+
});
|
|
2610
|
+
}
|
|
2611
|
+
if (fault === "missing-issue-fix") {
|
|
2612
|
+
await page.addInitScript(() => {
|
|
2613
|
+
new MutationObserver(() => {
|
|
2614
|
+
for (const node of document.querySelectorAll(
|
|
2615
|
+
"[data-zappie-issue-fix]"
|
|
2616
|
+
)) {
|
|
2617
|
+
node.removeAttribute("data-zappie-issue-fix");
|
|
2618
|
+
}
|
|
2619
|
+
}).observe(document, { childList: true, subtree: true });
|
|
2620
|
+
});
|
|
2621
|
+
}
|
|
2622
|
+
if (fault === "wrong-total") {
|
|
2623
|
+
await page.addInitScript(() => {
|
|
2624
|
+
new MutationObserver(() => {
|
|
2625
|
+
for (const node of document.querySelectorAll("[data-zappie-total]")) {
|
|
2626
|
+
if (node.textContent !== "Total \u20A61") {
|
|
2627
|
+
node.textContent = "Total \u20A61";
|
|
2628
|
+
}
|
|
2629
|
+
}
|
|
2630
|
+
}).observe(document, { childList: true, subtree: true });
|
|
2631
|
+
});
|
|
2632
|
+
}
|
|
2633
|
+
}
|
|
2634
|
+
async function applyPageFault(page, fault) {
|
|
2635
|
+
if (fault === "missing-marker") {
|
|
2636
|
+
await page.locator("[data-zappie-item]").evaluateAll((nodes) => {
|
|
2637
|
+
for (const node of nodes) {
|
|
2638
|
+
node.removeAttribute("data-zappie-item");
|
|
2639
|
+
}
|
|
2640
|
+
});
|
|
2641
|
+
}
|
|
2642
|
+
if (fault === "axe") {
|
|
2643
|
+
await page.evaluate(() => {
|
|
2644
|
+
const button = document.createElement("button");
|
|
2645
|
+
button.style.color = "#fff";
|
|
2646
|
+
button.style.background = "#fff";
|
|
2647
|
+
document.body.append(button);
|
|
2648
|
+
});
|
|
2649
|
+
}
|
|
2650
|
+
if (fault === "oversized") {
|
|
2651
|
+
let state = 1;
|
|
2652
|
+
const payload = Array.from({ length: 26e4 }, () => {
|
|
2653
|
+
state = state * 48271 % 2147483647;
|
|
2654
|
+
return String.fromCharCode(33 + state % 90);
|
|
2655
|
+
}).join("");
|
|
2656
|
+
await page.route(
|
|
2657
|
+
"**/__verify-large.js",
|
|
2658
|
+
(route) => route.fulfill({
|
|
2659
|
+
contentType: "text/javascript",
|
|
2660
|
+
body: `window.__verifyLarge=${JSON.stringify(payload)}`
|
|
2661
|
+
})
|
|
2662
|
+
);
|
|
2663
|
+
}
|
|
2664
|
+
}
|
|
2665
|
+
|
|
2666
|
+
// src/checks/catalogue.ts
|
|
2667
|
+
async function readMenu(options) {
|
|
2668
|
+
const client = createZappie({
|
|
2669
|
+
store: options.store,
|
|
2670
|
+
key: options.key,
|
|
2671
|
+
...process.env.ZAPPIE_VERIFY_API_URL ? { baseUrl: process.env.ZAPPIE_VERIFY_API_URL } : {}
|
|
2672
|
+
});
|
|
2673
|
+
const store = await client.store.get();
|
|
2674
|
+
const branch = store.branches.find((entry) => entry.isDefault) ?? store.branches[0];
|
|
2675
|
+
assertion(
|
|
2676
|
+
branch,
|
|
2677
|
+
"default_branch_missing",
|
|
2678
|
+
"The store has no branch",
|
|
2679
|
+
"Mark an orderable branch as the default."
|
|
2680
|
+
);
|
|
2681
|
+
return client.menu.get({ branch: branch.id });
|
|
2682
|
+
}
|
|
2683
|
+
function sortedItems(menu) {
|
|
2684
|
+
return Object.values(menu.items).sort((a, b) => a.id.localeCompare(b.id));
|
|
2685
|
+
}
|
|
2686
|
+
function simpleItem(menu) {
|
|
2687
|
+
return sortedItems(menu).find(
|
|
2688
|
+
(item) => item.isAvailable && !item.optionGroups.some((group) => group.required)
|
|
2689
|
+
);
|
|
2690
|
+
}
|
|
2691
|
+
function configurableItem(menu) {
|
|
2692
|
+
return sortedItems(menu).find(
|
|
2693
|
+
(item) => item.isAvailable && item.optionGroups.some((group) => group.required)
|
|
2694
|
+
);
|
|
2695
|
+
}
|
|
2696
|
+
|
|
2697
|
+
// src/checks/menu-match.ts
|
|
2698
|
+
function menuCoverage(items, shownIds) {
|
|
2699
|
+
const available = items.filter((item) => item.isAvailable);
|
|
2700
|
+
const shown = available.filter((item) => shownIds.has(item.id)).length;
|
|
2701
|
+
return {
|
|
2702
|
+
available: available.length,
|
|
2703
|
+
shown,
|
|
2704
|
+
passes: available.length === 0 || shown / available.length >= 0.95
|
|
2705
|
+
};
|
|
2706
|
+
}
|
|
2707
|
+
function priceMatches(text2, formatted) {
|
|
2708
|
+
return text2?.includes(formatted) ?? false;
|
|
2709
|
+
}
|
|
2710
|
+
|
|
2711
|
+
// src/checks/v03-menu.ts
|
|
2712
|
+
var v03 = {
|
|
2713
|
+
id: "V03",
|
|
2714
|
+
title: "Menu matches store",
|
|
2715
|
+
viewports: "mobile",
|
|
2716
|
+
async run({ page, options, fault }, viewport) {
|
|
2717
|
+
const menu = await readMenu(options);
|
|
2718
|
+
await page.setViewportSize(viewport);
|
|
2719
|
+
await open(page);
|
|
2720
|
+
await applyPageFault(page, fault);
|
|
2721
|
+
const items = sortedItems(menu);
|
|
2722
|
+
const available = items.filter((item) => item.isAvailable);
|
|
2723
|
+
const shownIds = /* @__PURE__ */ new Set();
|
|
2724
|
+
for (const item of available) {
|
|
2725
|
+
const marker = page.locator(`[data-zappie-item="${item.id}"]`);
|
|
2726
|
+
if (await marker.count() > 0 || await page.getByText(item.name, { exact: true }).count() > 0) {
|
|
2727
|
+
shownIds.add(item.id);
|
|
2728
|
+
}
|
|
2729
|
+
}
|
|
2730
|
+
const coverage = menuCoverage(items, shownIds);
|
|
2731
|
+
assertion(
|
|
2732
|
+
coverage.passes,
|
|
2733
|
+
"menu_mismatch",
|
|
2734
|
+
`The menu shows ${coverage.shown} of ${coverage.available} available items`,
|
|
2735
|
+
"Render at least 95% of available menu items with data-zappie-item markers or visible names."
|
|
2736
|
+
);
|
|
2737
|
+
assertion(
|
|
2738
|
+
await page.locator("[data-zappie-item]").count(),
|
|
2739
|
+
"item_marker_missing",
|
|
2740
|
+
"The menu has no item marker",
|
|
2741
|
+
"Add data-zappie-item to each rendered menu item."
|
|
2742
|
+
);
|
|
2743
|
+
for (const item of items.filter((entry) => !entry.isAvailable)) {
|
|
2744
|
+
const add = page.locator(
|
|
2745
|
+
`[data-zappie-item="${item.id}"] [data-zappie-action="add-to-cart"]`
|
|
2746
|
+
);
|
|
2747
|
+
if (await add.count()) {
|
|
2748
|
+
assertion(
|
|
2749
|
+
await add.isDisabled(),
|
|
2750
|
+
"unavailable_item_addable",
|
|
2751
|
+
`Shoppers can add the unavailable item ${item.name}`,
|
|
2752
|
+
"Disable add-to-cart for unavailable items."
|
|
2753
|
+
);
|
|
2754
|
+
}
|
|
2755
|
+
}
|
|
2756
|
+
for (const item of available.slice(0, 2)) {
|
|
2757
|
+
const text2 = await page.locator(`[data-zappie-item="${item.id}"]`).first().textContent();
|
|
2758
|
+
assertion(
|
|
2759
|
+
priceMatches(text2, format.money(item.price)),
|
|
2760
|
+
"price_mismatch",
|
|
2761
|
+
`${item.name} does not show ${format.money(item.price)}`,
|
|
2762
|
+
"Render prices with format.money from @zappie/sdk."
|
|
2763
|
+
);
|
|
2764
|
+
}
|
|
2765
|
+
}
|
|
2766
|
+
};
|
|
2767
|
+
|
|
2768
|
+
// src/checks/v04-add-simple.ts
|
|
2769
|
+
var v04 = {
|
|
2770
|
+
id: "V04",
|
|
2771
|
+
title: "Add an item without required options",
|
|
2772
|
+
viewports: "both",
|
|
2773
|
+
async run({ page, options, fault }, viewport) {
|
|
2774
|
+
await page.setViewportSize(viewport);
|
|
2775
|
+
await open(page);
|
|
2776
|
+
await applyPageFault(page, fault);
|
|
2777
|
+
const before = await debugValue(page, () => window.__zappie.cart().count);
|
|
2778
|
+
const item = simpleItem(await readMenu(options));
|
|
2779
|
+
assertion(
|
|
2780
|
+
item,
|
|
2781
|
+
"simple_item_missing",
|
|
2782
|
+
"The test menu has no available item without required options",
|
|
2783
|
+
"Keep one available item without required options in the test menu."
|
|
2784
|
+
);
|
|
2785
|
+
const simple = page.locator(`[data-zappie-item="${item.id}"]`).locator('[data-zappie-action="add-to-cart"]');
|
|
2786
|
+
assertion(
|
|
2787
|
+
await simple.count(),
|
|
2788
|
+
"simple_item_missing",
|
|
2789
|
+
"The item has no quick-add button",
|
|
2790
|
+
"Render an available item without required options and mark its add button."
|
|
2791
|
+
);
|
|
2792
|
+
await simple.click();
|
|
2793
|
+
const updated = await page.evaluate(
|
|
2794
|
+
(count) => new Promise((resolve) => {
|
|
2795
|
+
requestAnimationFrame(() => {
|
|
2796
|
+
resolve(window.__zappie.cart().count > count);
|
|
2797
|
+
});
|
|
2798
|
+
}),
|
|
2799
|
+
before
|
|
2800
|
+
);
|
|
2801
|
+
assertion(
|
|
2802
|
+
updated,
|
|
2803
|
+
"cart_not_updated",
|
|
2804
|
+
"The cart count did not change within one frame of Add",
|
|
2805
|
+
"Connect the marked Add control to client.cart.add and update its count immediately."
|
|
2806
|
+
);
|
|
2807
|
+
const event = await debugValue(
|
|
2808
|
+
page,
|
|
2809
|
+
() => window.__zappie.events.some((entry) => entry.name === "add_to_cart")
|
|
2810
|
+
);
|
|
2811
|
+
assertion(
|
|
2812
|
+
event,
|
|
2813
|
+
"add_event_missing",
|
|
2814
|
+
"The SDK did not record add_to_cart",
|
|
2815
|
+
"Use the SDK cart add action to record the test-mode event."
|
|
2816
|
+
);
|
|
2817
|
+
}
|
|
2818
|
+
};
|
|
2819
|
+
|
|
2820
|
+
// src/checks/v05-add-options.ts
|
|
2821
|
+
var v05 = {
|
|
2822
|
+
id: "V05",
|
|
2823
|
+
title: "Add an item with required options",
|
|
2824
|
+
viewports: "both",
|
|
2825
|
+
async run({ page, options }, viewport) {
|
|
2826
|
+
await page.setViewportSize(viewport);
|
|
2827
|
+
await open(page);
|
|
2828
|
+
const selected = configurableItem(await readMenu(options));
|
|
2829
|
+
assertion(
|
|
2830
|
+
selected,
|
|
2831
|
+
"required_item_missing",
|
|
2832
|
+
"The test menu has no available item with required options",
|
|
2833
|
+
"Keep one available configurable item in the test menu."
|
|
2834
|
+
);
|
|
2835
|
+
const item = page.locator(`[data-zappie-item="${selected.id}"]`).first();
|
|
2836
|
+
await item.locator("xpath=..").getByRole("link", { name: "View details" }).click();
|
|
2837
|
+
const dialog = page.getByRole("dialog");
|
|
2838
|
+
await dialog.waitFor();
|
|
2839
|
+
const required = dialog.locator('[data-zappie-option-group][aria-required="true"]').first();
|
|
2840
|
+
assertion(
|
|
2841
|
+
await required.count(),
|
|
2842
|
+
"required_group_missing",
|
|
2843
|
+
"The item dialog has no marked required option group",
|
|
2844
|
+
"Expose required groups through data-zappie-option-group and aria-required."
|
|
2845
|
+
);
|
|
2846
|
+
const before = await debugValue(page, () => window.__zappie.cart().count);
|
|
2847
|
+
const add = dialog.locator('[data-zappie-action="add-to-cart"]');
|
|
2848
|
+
assertion(
|
|
2849
|
+
await add.isDisabled(),
|
|
2850
|
+
"required_option_bypassed",
|
|
2851
|
+
"Add is enabled before the shopper chooses a required option",
|
|
2852
|
+
"Disable Add until the shopper completes every required option group."
|
|
2853
|
+
);
|
|
2854
|
+
for (let index = 0; index < 5 && await add.isDisabled(); index++) {
|
|
2855
|
+
const unsatisfied = dialog.locator(
|
|
2856
|
+
'[data-zappie-option-group][aria-required="true"]:not(:has([data-state="checked"]))'
|
|
2857
|
+
).first();
|
|
2858
|
+
assertion(
|
|
2859
|
+
await unsatisfied.count(),
|
|
2860
|
+
"required_option_unselectable",
|
|
2861
|
+
"A required option group has no selectable choice",
|
|
2862
|
+
"Keep an available choice in every required option group."
|
|
2863
|
+
);
|
|
2864
|
+
await unsatisfied.locator('[data-zappie-option]:not([aria-disabled="true"])').first().click();
|
|
2865
|
+
}
|
|
2866
|
+
assertion(
|
|
2867
|
+
await add.isEnabled(),
|
|
2868
|
+
"required_option_unselectable",
|
|
2869
|
+
"Valid choices did not enable Add",
|
|
2870
|
+
"Recheck the item selection when required choices change."
|
|
2871
|
+
);
|
|
2872
|
+
await add.click();
|
|
2873
|
+
await dialog.waitFor({ state: "hidden" });
|
|
2874
|
+
await page.waitForFunction(
|
|
2875
|
+
(count) => window.__zappie.cart().count > count,
|
|
2876
|
+
before
|
|
2877
|
+
);
|
|
2878
|
+
const lineHasChoices = await page.evaluate(
|
|
2879
|
+
(itemId) => window.__zappie.cart().lines.some(
|
|
2880
|
+
(line) => line.itemId === itemId && line.choices.length > 0
|
|
2881
|
+
),
|
|
2882
|
+
selected.id
|
|
2883
|
+
);
|
|
2884
|
+
assertion(
|
|
2885
|
+
lineHasChoices,
|
|
2886
|
+
"selected_options_missing",
|
|
2887
|
+
"The added line has no selected options",
|
|
2888
|
+
"Pass ItemForm choices to the SDK cart add action."
|
|
2889
|
+
);
|
|
2890
|
+
}
|
|
2891
|
+
};
|
|
2892
|
+
|
|
2893
|
+
// src/checks/v06-quote.ts
|
|
2894
|
+
var v06 = {
|
|
2895
|
+
id: "V06",
|
|
2896
|
+
title: "Cart quote and total",
|
|
2897
|
+
viewports: "both",
|
|
2898
|
+
async run({ page }, viewport) {
|
|
2899
|
+
await page.setViewportSize(viewport);
|
|
2900
|
+
if (new URL(page.url()).pathname !== "/") {
|
|
2901
|
+
await open(page);
|
|
2902
|
+
}
|
|
2903
|
+
const total = page.locator("[data-zappie-total]");
|
|
2904
|
+
if (!await total.isVisible()) {
|
|
2905
|
+
await page.locator("[data-zappie-cart-trigger]").first().click();
|
|
2906
|
+
}
|
|
2907
|
+
await page.waitForFunction(
|
|
2908
|
+
() => window.__zappie.events.some((entry) => entry.name === "quote"),
|
|
2909
|
+
void 0,
|
|
2910
|
+
{ timeout: 2e3 }
|
|
2911
|
+
);
|
|
2912
|
+
const quote = await page.evaluate(() => window.__zappie.quote());
|
|
2913
|
+
assertion(
|
|
2914
|
+
quote,
|
|
2915
|
+
"quote_missing",
|
|
2916
|
+
"The cart did not produce a quote within 2 seconds",
|
|
2917
|
+
"Open the cart through the SDK and render Totals so it requests a quote."
|
|
2918
|
+
);
|
|
2919
|
+
const rendered = await total.textContent();
|
|
2920
|
+
assertion(
|
|
2921
|
+
rendered?.includes(format.money(quote.total)),
|
|
2922
|
+
"total_mismatch",
|
|
2923
|
+
`Rendered total does not match ${format.money(quote.total)}`,
|
|
2924
|
+
"Render quote.total with the SDK Money formatter."
|
|
2925
|
+
);
|
|
2926
|
+
}
|
|
2927
|
+
};
|
|
2928
|
+
|
|
2929
|
+
// src/checks/stale-cart.ts
|
|
2930
|
+
function orderableLine(menu, branch, item) {
|
|
2931
|
+
for (const source of menu.menus ?? [{ id: menu.id }]) {
|
|
2932
|
+
const menuId = resolveSourceMenu(menu, item.id, source.id);
|
|
2933
|
+
if (!menuId) {
|
|
2934
|
+
continue;
|
|
2935
|
+
}
|
|
2936
|
+
for (const variant of item.variants.filter((value) => value.isAvailable)) {
|
|
2937
|
+
const line = {
|
|
2938
|
+
itemId: item.id,
|
|
2939
|
+
menuId,
|
|
2940
|
+
variantId: variant.id,
|
|
2941
|
+
quantity: Math.ceil(item.quantity.min / item.quantity.step) * item.quantity.step,
|
|
2942
|
+
choices: Object.entries(variant.attributeChoices).map(
|
|
2943
|
+
([groupId, choiceId]) => ({ groupId, choiceId, quantity: 1 })
|
|
2944
|
+
),
|
|
2945
|
+
inputs: {}
|
|
2946
|
+
};
|
|
2947
|
+
for (let round = 0; round <= item.optionGroups.length; round++) {
|
|
2948
|
+
const { groups } = previewItem(item, line);
|
|
2949
|
+
for (const group of groups) {
|
|
2950
|
+
if (group.isAttribute || group.isInformational) {
|
|
2951
|
+
continue;
|
|
2952
|
+
}
|
|
2953
|
+
const minimum = Math.max(group.selection.min, group.required ? 1 : 0);
|
|
2954
|
+
if (group.kind === "select") {
|
|
2955
|
+
const selected = line.choices.filter(
|
|
2956
|
+
(choice) => choice.groupId === group.id
|
|
2957
|
+
);
|
|
2958
|
+
for (const choice of group.choices.filter(
|
|
2959
|
+
(value) => value.isAvailable
|
|
2960
|
+
)) {
|
|
2961
|
+
if (selected.length >= minimum) {
|
|
2962
|
+
break;
|
|
2963
|
+
}
|
|
2964
|
+
if (!selected.some((value) => value.choiceId === choice.id)) {
|
|
2965
|
+
const value = {
|
|
2966
|
+
groupId: group.id,
|
|
2967
|
+
choiceId: choice.id,
|
|
2968
|
+
quantity: choice.quantity?.min ?? 1
|
|
2969
|
+
};
|
|
2970
|
+
line.choices.push(value);
|
|
2971
|
+
selected.push(value);
|
|
2972
|
+
}
|
|
2973
|
+
}
|
|
2974
|
+
} else if (group.required) {
|
|
2975
|
+
const values = {
|
|
2976
|
+
number: "1",
|
|
2977
|
+
date: "2026-01-01",
|
|
2978
|
+
time: "12:00",
|
|
2979
|
+
toggle: "true",
|
|
2980
|
+
text: "Verify"
|
|
2981
|
+
};
|
|
2982
|
+
line.inputs[group.id] = values[group.kind].slice(
|
|
2983
|
+
0,
|
|
2984
|
+
group.input?.maxLength
|
|
2985
|
+
);
|
|
2986
|
+
}
|
|
2987
|
+
}
|
|
2988
|
+
if (validateLine(item, line, { menu, branch }).length === 0) {
|
|
2989
|
+
return {
|
|
2990
|
+
...line,
|
|
2991
|
+
unitPrice: previewItem(item, line).unitPrice
|
|
2992
|
+
};
|
|
2993
|
+
}
|
|
2994
|
+
}
|
|
2995
|
+
}
|
|
2996
|
+
}
|
|
2997
|
+
return void 0;
|
|
2998
|
+
}
|
|
2999
|
+
|
|
3000
|
+
// src/checks/v07-quote-issues.ts
|
|
3001
|
+
var v07 = {
|
|
3002
|
+
id: "V07",
|
|
3003
|
+
title: "Handle quote issues",
|
|
3004
|
+
viewports: "mobile",
|
|
3005
|
+
async run({ page }, viewport) {
|
|
3006
|
+
await page.setViewportSize(viewport);
|
|
3007
|
+
await open(page);
|
|
3008
|
+
await page.waitForFunction(() => window.__zappie?.cart().isHydrated);
|
|
3009
|
+
const debug = await page.evaluate(() => ({
|
|
3010
|
+
inject: typeof window.__zappie?.cart.inject === "function",
|
|
3011
|
+
branch: window.__zappie?.cart().branchId
|
|
3012
|
+
}));
|
|
3013
|
+
assertion(
|
|
3014
|
+
debug.inject,
|
|
3015
|
+
"cart_inject_missing",
|
|
3016
|
+
"The test cart has no inject method",
|
|
3017
|
+
"Use an SDK version that exposes cart.inject with a public test key."
|
|
3018
|
+
);
|
|
3019
|
+
const { menu, branch } = await page.evaluate(
|
|
3020
|
+
() => window.__zappie.catalogue()
|
|
3021
|
+
);
|
|
3022
|
+
const items = Object.values(menu.items).sort(
|
|
3023
|
+
(a, b) => a.id.localeCompare(b.id)
|
|
3024
|
+
);
|
|
3025
|
+
const available = items.filter((item) => item.isAvailable).map((item) => orderableLine(menu, branch, item)).find((line) => line !== void 0);
|
|
3026
|
+
assertion(
|
|
3027
|
+
available,
|
|
3028
|
+
"orderable_item_missing",
|
|
3029
|
+
"The branch has no item that verify can order",
|
|
3030
|
+
"Keep an available item with valid choices in this branch's menu."
|
|
3031
|
+
);
|
|
3032
|
+
const unavailable = items.find((item) => !item.isAvailable);
|
|
3033
|
+
const lines = [
|
|
3034
|
+
{
|
|
3035
|
+
...available,
|
|
3036
|
+
unitPrice: {
|
|
3037
|
+
...available.unitPrice,
|
|
3038
|
+
amountMinor: available.unitPrice.amountMinor === 0 ? 1 : 0
|
|
3039
|
+
}
|
|
3040
|
+
}
|
|
3041
|
+
];
|
|
3042
|
+
if (unavailable) {
|
|
3043
|
+
const menuId = (menu.menus ?? [{ id: menu.id }]).map((source) => resolveSourceMenu(menu, unavailable.id, source.id)).find(Boolean);
|
|
3044
|
+
lines.push({
|
|
3045
|
+
quantity: 1,
|
|
3046
|
+
itemId: unavailable.id,
|
|
3047
|
+
...menuId ? { menuId } : {},
|
|
3048
|
+
variantId: unavailable.variants[0].id,
|
|
3049
|
+
choices: [],
|
|
3050
|
+
inputs: {},
|
|
3051
|
+
unitPrice: unavailable.variants[0].price
|
|
3052
|
+
});
|
|
3053
|
+
}
|
|
3054
|
+
await page.evaluate(async (lines2) => {
|
|
3055
|
+
await window.__zappie.cart.inject({ lines: lines2 });
|
|
3056
|
+
}, lines);
|
|
3057
|
+
await page.reload({ waitUntil: "domcontentloaded" });
|
|
3058
|
+
await page.locator("[data-zappie-cart-trigger]").first().click();
|
|
3059
|
+
const codes = unavailable ? ["price_changed", "item_unavailable"] : ["price_changed"];
|
|
3060
|
+
for (const code of codes) {
|
|
3061
|
+
const issue2 = page.locator(`[data-zappie-issue="${code}"]`).first();
|
|
3062
|
+
await issue2.waitFor({ state: "visible" });
|
|
3063
|
+
assertion(
|
|
3064
|
+
await issue2.isVisible(),
|
|
3065
|
+
"quote_issue_missing",
|
|
3066
|
+
`The cart does not show ${code}`,
|
|
3067
|
+
"Render each quote issue with data-zappie-issue and its fixes with data-zappie-issue-fix."
|
|
3068
|
+
);
|
|
3069
|
+
}
|
|
3070
|
+
for (const code of codes) {
|
|
3071
|
+
const issue2 = page.locator(`[data-zappie-issue="${code}"]`).first();
|
|
3072
|
+
const fix = issue2.locator("[data-zappie-issue-fix]").first();
|
|
3073
|
+
assertion(
|
|
3074
|
+
await fix.count(),
|
|
3075
|
+
"quote_fix_missing",
|
|
3076
|
+
`The cart has no fix for ${code}`,
|
|
3077
|
+
"Connect each marked issue fix to the SDK cart applyFix action."
|
|
3078
|
+
);
|
|
3079
|
+
await fix.click();
|
|
3080
|
+
await issue2.waitFor({ state: "hidden" });
|
|
3081
|
+
}
|
|
3082
|
+
await open(page, "/checkout");
|
|
3083
|
+
await page.waitForFunction(() => {
|
|
3084
|
+
const cart = window.__zappie?.cart();
|
|
3085
|
+
return cart && cart.issues.length === 0 && "quote" in cart.state && cart.state.quote?.checkout.ready;
|
|
3086
|
+
});
|
|
3087
|
+
assertion(
|
|
3088
|
+
await page.locator("[data-zappie-issue]").count() === 0,
|
|
3089
|
+
"quote_issues_remain",
|
|
3090
|
+
"The cart still shows issues after both fixes",
|
|
3091
|
+
"Remove resolved issue messages when the SDK cart updates."
|
|
3092
|
+
);
|
|
3093
|
+
await page.waitForFunction(() => {
|
|
3094
|
+
const control = document.querySelector(
|
|
3095
|
+
"[data-zappie-checkout]"
|
|
3096
|
+
);
|
|
3097
|
+
return control && !control.disabled;
|
|
3098
|
+
});
|
|
3099
|
+
assertion(
|
|
3100
|
+
await page.locator("[data-zappie-checkout]").isEnabled(),
|
|
3101
|
+
"checkout_not_ready",
|
|
3102
|
+
"Checkout is disabled after the quote issues clear",
|
|
3103
|
+
"Enable checkout when the SDK quote is ready and the cart has no blocking issue."
|
|
3104
|
+
);
|
|
3105
|
+
assertion(
|
|
3106
|
+
unavailable,
|
|
3107
|
+
"dependency_blocked",
|
|
3108
|
+
"The price-change checks passed, but the unavailable-item checks need a sold-out item",
|
|
3109
|
+
"Keep one sold-out item in this branch's menu to check its issue and removal fix."
|
|
3110
|
+
);
|
|
3111
|
+
}
|
|
3112
|
+
};
|
|
3113
|
+
|
|
3114
|
+
// src/checks/v09-pickup.ts
|
|
3115
|
+
var v09 = {
|
|
3116
|
+
id: "V09",
|
|
3117
|
+
title: "Pickup",
|
|
3118
|
+
viewports: "mobile",
|
|
3119
|
+
async run({ page }, viewport) {
|
|
3120
|
+
await page.setViewportSize(viewport);
|
|
3121
|
+
await open(page, "/checkout");
|
|
3122
|
+
const pickup = page.locator('[data-zappie-fulfilment="pickup"]');
|
|
3123
|
+
await pickup.waitFor();
|
|
3124
|
+
assertion(
|
|
3125
|
+
await pickup.count(),
|
|
3126
|
+
"pickup_missing",
|
|
3127
|
+
"Pickup control is missing",
|
|
3128
|
+
"Render a pickup control with data-zappie-fulfilment=pickup."
|
|
3129
|
+
);
|
|
3130
|
+
await pickup.click();
|
|
3131
|
+
await page.waitForFunction(
|
|
3132
|
+
() => window.__zappie.cart().fulfilment?.type === "pickup"
|
|
3133
|
+
);
|
|
3134
|
+
await page.waitForFunction(() => Boolean(window.__zappie.quote()));
|
|
3135
|
+
const quote = await debugValue(page, () => window.__zappie.quote());
|
|
3136
|
+
assertion(
|
|
3137
|
+
!quote?.delivery || quote.delivery.amountMinor === 0,
|
|
3138
|
+
"pickup_delivery_fee",
|
|
3139
|
+
"Pickup quote includes a delivery fee",
|
|
3140
|
+
"Send pickup fulfilment to the SDK cart before quoting."
|
|
3141
|
+
);
|
|
3142
|
+
}
|
|
3143
|
+
};
|
|
3144
|
+
|
|
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
|
+
// ../sdk/src/places/error.ts
|
|
3213
|
+
var PlacesError = class extends ZappieError {
|
|
3214
|
+
constructor(status, apiCode, message, param, issues) {
|
|
3215
|
+
super(stableErrorCode(status), message, {
|
|
3216
|
+
status,
|
|
3217
|
+
retryable: status === 429 || status >= 500,
|
|
3218
|
+
...param !== void 0 ? { param } : {}
|
|
3219
|
+
});
|
|
3220
|
+
this.apiCode = apiCode;
|
|
3221
|
+
if (issues !== void 0) {
|
|
3222
|
+
this.issues = issues;
|
|
3223
|
+
}
|
|
3224
|
+
}
|
|
3225
|
+
};
|
|
3226
|
+
|
|
3227
|
+
// ../sdk/src/places/request.ts
|
|
3228
|
+
function createPlacesRequest(base, key, version, fetcher) {
|
|
3229
|
+
return async (path4, init = {}) => {
|
|
3230
|
+
const response = await requestWithRetry(
|
|
3231
|
+
fetcher,
|
|
3232
|
+
base + path4,
|
|
3233
|
+
{
|
|
3234
|
+
...init,
|
|
3235
|
+
// Workers reject redirect: "error"; !response.ok below refuses a redirect.
|
|
3236
|
+
redirect: "manual",
|
|
3237
|
+
headers: {
|
|
3238
|
+
Authorization: `Bearer ${key}`,
|
|
3239
|
+
"Zappie-Version": version,
|
|
3240
|
+
...init.headers
|
|
3241
|
+
}
|
|
3242
|
+
},
|
|
3243
|
+
"Could not reach the places service"
|
|
3244
|
+
);
|
|
3245
|
+
if (!response.ok) {
|
|
3246
|
+
const value = await apiError(
|
|
3247
|
+
response,
|
|
3248
|
+
`The places service answered ${response.status}`
|
|
3249
|
+
);
|
|
3250
|
+
throw new PlacesError(
|
|
3251
|
+
response.status,
|
|
3252
|
+
value.apiCode,
|
|
3253
|
+
value.message,
|
|
3254
|
+
value.param,
|
|
3255
|
+
value.issues
|
|
3256
|
+
);
|
|
3257
|
+
}
|
|
3258
|
+
return await response.json();
|
|
3259
|
+
};
|
|
3260
|
+
}
|
|
3261
|
+
|
|
3262
|
+
// ../sdk/src/places/search.ts
|
|
3263
|
+
var aborted = () => new DOMException("Superseded", "AbortError");
|
|
3264
|
+
function createAddressSearch(client, options = {}) {
|
|
3265
|
+
const debounceMs = options.debounceMs ?? 250;
|
|
3266
|
+
let token = crypto.randomUUID();
|
|
3267
|
+
let controller;
|
|
3268
|
+
let timer;
|
|
3269
|
+
let rejectPending;
|
|
3270
|
+
let revision = 0;
|
|
3271
|
+
const cancel = () => {
|
|
3272
|
+
revision++;
|
|
3273
|
+
if (timer !== void 0) {
|
|
3274
|
+
clearTimeout(timer);
|
|
3275
|
+
timer = void 0;
|
|
3276
|
+
}
|
|
3277
|
+
controller?.abort(aborted());
|
|
3278
|
+
controller = void 0;
|
|
3279
|
+
rejectPending?.(aborted());
|
|
3280
|
+
rejectPending = void 0;
|
|
3281
|
+
};
|
|
3282
|
+
return {
|
|
3283
|
+
suggest(query) {
|
|
3284
|
+
cancel();
|
|
3285
|
+
const current = revision;
|
|
3286
|
+
if (!query.trim()) {
|
|
3287
|
+
return Promise.resolve([]);
|
|
3288
|
+
}
|
|
3289
|
+
return new Promise((resolve, reject) => {
|
|
3290
|
+
rejectPending = reject;
|
|
3291
|
+
timer = setTimeout(async () => {
|
|
3292
|
+
timer = void 0;
|
|
3293
|
+
controller = new AbortController();
|
|
3294
|
+
try {
|
|
3295
|
+
const value = await client.autocomplete(query.trim(), token, {
|
|
3296
|
+
signal: controller.signal
|
|
3297
|
+
});
|
|
3298
|
+
if (current === revision) {
|
|
3299
|
+
resolve(value);
|
|
3300
|
+
}
|
|
3301
|
+
} catch (failure) {
|
|
3302
|
+
if (current === revision) {
|
|
3303
|
+
reject(failure);
|
|
3304
|
+
}
|
|
3305
|
+
} finally {
|
|
3306
|
+
if (current === revision) {
|
|
3307
|
+
rejectPending = void 0;
|
|
3308
|
+
controller = void 0;
|
|
3309
|
+
}
|
|
3310
|
+
}
|
|
3311
|
+
}, debounceMs);
|
|
3312
|
+
});
|
|
3313
|
+
},
|
|
3314
|
+
async select(placeId) {
|
|
3315
|
+
cancel();
|
|
3316
|
+
const current = revision;
|
|
3317
|
+
controller = new AbortController();
|
|
3318
|
+
try {
|
|
3319
|
+
const value = await client.place(placeId, {
|
|
3320
|
+
...options.branchId !== void 0 ? { branchId: options.branchId } : {},
|
|
3321
|
+
sessionToken: token,
|
|
3322
|
+
signal: controller.signal
|
|
3323
|
+
});
|
|
3324
|
+
if (current !== revision) {
|
|
3325
|
+
throw aborted();
|
|
3326
|
+
}
|
|
3327
|
+
token = crypto.randomUUID();
|
|
3328
|
+
return value;
|
|
3329
|
+
} finally {
|
|
3330
|
+
if (current === revision) {
|
|
3331
|
+
controller = void 0;
|
|
3332
|
+
}
|
|
3333
|
+
}
|
|
3334
|
+
},
|
|
3335
|
+
dispose: cancel
|
|
3336
|
+
};
|
|
3337
|
+
}
|
|
3338
|
+
|
|
3339
|
+
// ../sdk/src/places/client.ts
|
|
3340
|
+
var DEFAULT_CUSTOMER_BASE = "/_zappie/api";
|
|
3341
|
+
function createPlaces(options) {
|
|
3342
|
+
validateConfig({ store: "places", key: options.key });
|
|
3343
|
+
const base = new RestTransport({
|
|
3344
|
+
...options.baseUrl ? { baseUrl: options.baseUrl } : {},
|
|
3345
|
+
...options.fetch ? { fetch: options.fetch } : {}
|
|
3346
|
+
}).baseUrl;
|
|
3347
|
+
const customerBase = (options.customerBase ?? DEFAULT_CUSTOMER_BASE).replace(
|
|
3348
|
+
/\/+$/,
|
|
3349
|
+
""
|
|
3350
|
+
);
|
|
3351
|
+
const request = createPlacesRequest(
|
|
3352
|
+
base,
|
|
3353
|
+
options.key,
|
|
3354
|
+
options.version ?? API_VERSION,
|
|
3355
|
+
options.fetch ?? globalThis.fetch
|
|
3356
|
+
);
|
|
3357
|
+
const readPlace = (id, read) => {
|
|
3358
|
+
const params = new URLSearchParams();
|
|
3359
|
+
if (read.branchId !== void 0) {
|
|
3360
|
+
params.set("branch_id", read.branchId);
|
|
3361
|
+
}
|
|
3362
|
+
if (read.sessionToken !== void 0) {
|
|
3363
|
+
params.set("session", read.sessionToken);
|
|
3364
|
+
}
|
|
3365
|
+
const suffix = params.size > 0 ? `?${params}` : "";
|
|
3366
|
+
return request(`/places/${encodeURIComponent(id)}${suffix}`, {
|
|
3367
|
+
method: "GET",
|
|
3368
|
+
...read.signal ? { signal: read.signal } : {}
|
|
3369
|
+
});
|
|
3370
|
+
};
|
|
3371
|
+
const client = {
|
|
3372
|
+
async autocomplete(query, sessionToken, read = {}) {
|
|
3373
|
+
if (!query.trim()) {
|
|
3374
|
+
return [];
|
|
3375
|
+
}
|
|
3376
|
+
const params = new URLSearchParams({
|
|
3377
|
+
q: query.trim(),
|
|
3378
|
+
session: sessionToken
|
|
3379
|
+
});
|
|
3380
|
+
const result = await request(
|
|
3381
|
+
`/places/autocomplete?${params}`,
|
|
3382
|
+
{ method: "GET", ...read.signal ? { signal: read.signal } : {} }
|
|
3383
|
+
);
|
|
3384
|
+
return result.suggestions;
|
|
3385
|
+
},
|
|
3386
|
+
place(id, read) {
|
|
3387
|
+
return readPlace(id, read);
|
|
3388
|
+
},
|
|
3389
|
+
reverse(lat, lng, read = {}) {
|
|
3390
|
+
const params = new URLSearchParams({
|
|
3391
|
+
lat: String(lat),
|
|
3392
|
+
lng: String(lng)
|
|
3393
|
+
});
|
|
3394
|
+
return request(`/places/reverse?${params}`, {
|
|
3395
|
+
method: "GET",
|
|
3396
|
+
...read.signal ? { signal: read.signal } : {}
|
|
3397
|
+
});
|
|
3398
|
+
},
|
|
3399
|
+
staticMapUrl(lat, lng, branchId) {
|
|
3400
|
+
const params = new URLSearchParams({
|
|
3401
|
+
lat: String(lat),
|
|
3402
|
+
lng: String(lng)
|
|
3403
|
+
});
|
|
3404
|
+
if (branchId !== void 0) {
|
|
3405
|
+
params.set("branch_id", branchId);
|
|
3406
|
+
}
|
|
3407
|
+
return `${customerBase}/places/static-map?${params}`;
|
|
3408
|
+
},
|
|
3409
|
+
zoneCheck(body, read = {}) {
|
|
3410
|
+
return request("/delivery/zones/check", {
|
|
3411
|
+
method: "POST",
|
|
3412
|
+
headers: { "Content-Type": "application/json" },
|
|
3413
|
+
body: JSON.stringify(body),
|
|
3414
|
+
...read.signal ? { signal: read.signal } : {}
|
|
3415
|
+
});
|
|
3416
|
+
},
|
|
3417
|
+
createAddressSearch(searchOptions = {}) {
|
|
3418
|
+
return createAddressSearch(
|
|
3419
|
+
{
|
|
3420
|
+
autocomplete: client.autocomplete,
|
|
3421
|
+
place: readPlace
|
|
3422
|
+
},
|
|
3423
|
+
searchOptions
|
|
3424
|
+
);
|
|
3425
|
+
}
|
|
3426
|
+
};
|
|
3427
|
+
return client;
|
|
3428
|
+
}
|
|
3429
|
+
|
|
3430
|
+
// src/checks/v10-delivery.ts
|
|
3431
|
+
var ADDRESS = "12 Admiralty Way, Lekki";
|
|
3432
|
+
var v10 = {
|
|
3433
|
+
id: "V10",
|
|
3434
|
+
title: "Delivery",
|
|
3435
|
+
viewports: "mobile",
|
|
3436
|
+
async run({ page, options }, viewport) {
|
|
3437
|
+
await page.setViewportSize(viewport);
|
|
3438
|
+
await open(page, "/checkout");
|
|
3439
|
+
const delivery = page.locator('[data-zappie-fulfilment="delivery"]');
|
|
3440
|
+
await delivery.waitFor();
|
|
3441
|
+
assertion(
|
|
3442
|
+
await delivery.count(),
|
|
3443
|
+
"delivery_missing",
|
|
3444
|
+
"Delivery control is missing",
|
|
3445
|
+
"Render a delivery control with data-zappie-fulfilment=delivery."
|
|
3446
|
+
);
|
|
3447
|
+
await delivery.click();
|
|
3448
|
+
const input = page.locator("[data-zappie-address-input]");
|
|
3449
|
+
await input.waitFor();
|
|
3450
|
+
await input.fill(ADDRESS);
|
|
3451
|
+
await page.getByText(ADDRESS, { exact: true }).last().click();
|
|
3452
|
+
await page.waitForFunction(() => {
|
|
3453
|
+
const fulfilment2 = window.__zappie.cart().fulfilment;
|
|
3454
|
+
return fulfilment2?.type === "delivery" && Boolean(fulfilment2.address.placeId);
|
|
3455
|
+
});
|
|
3456
|
+
const fulfilment = await debugValue(
|
|
3457
|
+
page,
|
|
3458
|
+
() => window.__zappie.cart().fulfilment
|
|
3459
|
+
);
|
|
3460
|
+
assertion(
|
|
3461
|
+
fulfilment?.type === "delivery",
|
|
3462
|
+
"delivery_address_missing",
|
|
3463
|
+
"The selected address was not stored",
|
|
3464
|
+
"Store the resolved address on delivery fulfilment."
|
|
3465
|
+
);
|
|
3466
|
+
await page.waitForFunction(
|
|
3467
|
+
() => (window.__zappie.quote()?.delivery?.amountMinor ?? 0) > 0
|
|
3468
|
+
);
|
|
3469
|
+
const quote = await debugValue(page, () => window.__zappie.quote());
|
|
3470
|
+
assertion(
|
|
3471
|
+
(quote?.delivery?.amountMinor ?? 0) > 0,
|
|
3472
|
+
"delivery_fee_missing",
|
|
3473
|
+
"The delivery quote has no delivery fee",
|
|
3474
|
+
"Quote the selected delivery zone and include its fee."
|
|
3475
|
+
);
|
|
3476
|
+
const places = createPlaces({
|
|
3477
|
+
key: options.key,
|
|
3478
|
+
...process.env.ZAPPIE_VERIFY_API_URL ? { baseUrl: process.env.ZAPPIE_VERIFY_API_URL } : {}
|
|
3479
|
+
});
|
|
3480
|
+
const zone = await places.zoneCheck({
|
|
3481
|
+
branch_id: fulfilment.branchId,
|
|
3482
|
+
address: {
|
|
3483
|
+
line1: fulfilment.address.line1,
|
|
3484
|
+
...fulfilment.address.placeId ? { place_id: fulfilment.address.placeId } : {},
|
|
3485
|
+
...fulfilment.address.coordinates ? {
|
|
3486
|
+
lat: fulfilment.address.coordinates.lat,
|
|
3487
|
+
lng: fulfilment.address.coordinates.lng
|
|
3488
|
+
} : {}
|
|
3489
|
+
}
|
|
3490
|
+
});
|
|
3491
|
+
assertion(
|
|
3492
|
+
zone.deliverable && zone.fee,
|
|
3493
|
+
"delivery_zone_failed",
|
|
3494
|
+
"The test address is not deliverable",
|
|
3495
|
+
"Make the test-mode zone check accept 12 Admiralty Way, Lekki and return its fee."
|
|
3496
|
+
);
|
|
3497
|
+
}
|
|
3498
|
+
};
|
|
3499
|
+
|
|
3500
|
+
// src/checks/v13-accessibility.ts
|
|
3501
|
+
import AxeBuilder from "@axe-core/playwright";
|
|
3502
|
+
async function severeViolations(page) {
|
|
3503
|
+
const analysis = await new AxeBuilder({ page }).analyze();
|
|
3504
|
+
return analysis.violations.filter(
|
|
3505
|
+
(entry) => entry.impact === "critical" || entry.impact === "serious"
|
|
3506
|
+
);
|
|
3507
|
+
}
|
|
3508
|
+
async function controlsAreReachable(page, selectors) {
|
|
3509
|
+
const reached = /* @__PURE__ */ new Set();
|
|
3510
|
+
for (let index = 0; index < 60; index++) {
|
|
3511
|
+
await page.keyboard.press("Tab");
|
|
3512
|
+
const match = await page.evaluate((values) => {
|
|
3513
|
+
return values.findIndex(
|
|
3514
|
+
(value) => document.activeElement?.closest(value)
|
|
3515
|
+
);
|
|
3516
|
+
}, selectors);
|
|
3517
|
+
if (match >= 0) {
|
|
3518
|
+
reached.add(match);
|
|
3519
|
+
}
|
|
3520
|
+
if (reached.size === selectors.length) {
|
|
3521
|
+
return true;
|
|
3522
|
+
}
|
|
3523
|
+
}
|
|
3524
|
+
return false;
|
|
3525
|
+
}
|
|
3526
|
+
var v13 = {
|
|
3527
|
+
id: "V13",
|
|
3528
|
+
title: "Accessibility",
|
|
3529
|
+
viewports: "both",
|
|
3530
|
+
async run({ page, context, options, fault }, viewport) {
|
|
3531
|
+
await page.setViewportSize(viewport);
|
|
3532
|
+
await open(page);
|
|
3533
|
+
await applyPageFault(page, fault);
|
|
3534
|
+
const violations = await severeViolations(page);
|
|
3535
|
+
const item = sortedItems(await readMenu(options)).find(
|
|
3536
|
+
(entry) => entry.isAvailable
|
|
3537
|
+
);
|
|
3538
|
+
assertion(
|
|
3539
|
+
item,
|
|
3540
|
+
"available_item_missing",
|
|
3541
|
+
"No available item can be opened for accessibility checks",
|
|
3542
|
+
"Keep one available item in the test menu."
|
|
3543
|
+
);
|
|
3544
|
+
await page.locator(`[data-zappie-item="${item.id}"]`).first().locator("xpath=..").getByRole("link", { name: "View details" }).click();
|
|
3545
|
+
violations.push(...await severeViolations(page));
|
|
3546
|
+
await page.getByRole("button", { name: "Close item" }).click();
|
|
3547
|
+
await page.locator("[data-zappie-cart-trigger]").first().click();
|
|
3548
|
+
violations.push(...await severeViolations(page));
|
|
3549
|
+
const message = violations.flatMap(
|
|
3550
|
+
(entry) => entry.nodes.map((node) => `${entry.id}: ${node.target.join(" ")}`)
|
|
3551
|
+
).join(", ");
|
|
3552
|
+
assertion(
|
|
3553
|
+
violations.length === 0,
|
|
3554
|
+
"accessibility_violation",
|
|
3555
|
+
message || "Accessibility violation",
|
|
3556
|
+
"Fix every serious or critical axe violation in the menu, item dialog, cart and order page."
|
|
3557
|
+
);
|
|
3558
|
+
await page.keyboard.press("Escape");
|
|
3559
|
+
await context.addCookies([
|
|
3560
|
+
{
|
|
3561
|
+
name: "zappie_session",
|
|
3562
|
+
value: "signed",
|
|
3563
|
+
domain: new URL(page.url()).hostname,
|
|
3564
|
+
path: "/"
|
|
3565
|
+
}
|
|
3566
|
+
]);
|
|
3567
|
+
await open(page, "/orders/order_fixture");
|
|
3568
|
+
const order = await severeViolations(page);
|
|
3569
|
+
assertion(
|
|
3570
|
+
order.length === 0,
|
|
3571
|
+
"order_accessibility_violation",
|
|
3572
|
+
order.map((entry) => entry.id).join(", ") || "Order page has a serious axe violation",
|
|
3573
|
+
"Fix serious and critical axe findings on the order page."
|
|
3574
|
+
);
|
|
3575
|
+
await open(page);
|
|
3576
|
+
assertion(
|
|
3577
|
+
await controlsAreReachable(page, [
|
|
3578
|
+
'[data-zappie-action="add-to-cart"]',
|
|
3579
|
+
"[data-zappie-cart-trigger]"
|
|
3580
|
+
]),
|
|
3581
|
+
"keyboard_marker_unreachable",
|
|
3582
|
+
"Keyboard navigation did not reach the Add and cart controls",
|
|
3583
|
+
"Keep the marked Add and cart controls in the tab order."
|
|
3584
|
+
);
|
|
3585
|
+
await open(page, "/checkout");
|
|
3586
|
+
await page.locator('input[type="tel"]').fill("+2348000000001");
|
|
3587
|
+
await page.waitForFunction(
|
|
3588
|
+
() => !document.querySelector("[data-zappie-checkout]")?.disabled
|
|
3589
|
+
);
|
|
3590
|
+
assertion(
|
|
3591
|
+
await controlsAreReachable(page, ["[data-zappie-checkout]"]),
|
|
3592
|
+
"checkout_keyboard_unreachable",
|
|
3593
|
+
"Keyboard navigation did not reach the checkout control",
|
|
3594
|
+
"Keep the enabled checkout control in the tab order and add its verify marker."
|
|
3595
|
+
);
|
|
3596
|
+
}
|
|
3597
|
+
};
|
|
3598
|
+
|
|
3599
|
+
// src/checks/v14-performance.ts
|
|
3600
|
+
var v14 = {
|
|
3601
|
+
id: "V14",
|
|
3602
|
+
title: "Performance budget",
|
|
3603
|
+
viewports: "mobile",
|
|
3604
|
+
async run({ page, metrics, fault }, viewport) {
|
|
3605
|
+
await page.setViewportSize(viewport);
|
|
3606
|
+
const cdp = await page.context().newCDPSession(page);
|
|
3607
|
+
await cdp.send("Network.enable");
|
|
3608
|
+
await cdp.send("Network.setCacheDisabled", { cacheDisabled: true });
|
|
3609
|
+
await cdp.send("Network.emulateNetworkConditions", {
|
|
3610
|
+
offline: false,
|
|
3611
|
+
latency: 150,
|
|
3612
|
+
downloadThroughput: 2e5,
|
|
3613
|
+
uploadThroughput: 1e5,
|
|
3614
|
+
connectionType: "cellular3g"
|
|
3615
|
+
});
|
|
3616
|
+
await cdp.send("Emulation.setCPUThrottlingRate", { rate: 4 });
|
|
3617
|
+
await page.addInitScript(() => {
|
|
3618
|
+
const state = { lcp: 0, cls: 0 };
|
|
3619
|
+
Object.assign(window, { __zappiePerformance: state });
|
|
3620
|
+
new PerformanceObserver((list2) => {
|
|
3621
|
+
state.lcp = Math.max(
|
|
3622
|
+
state.lcp,
|
|
3623
|
+
...list2.getEntries().map((entry) => entry.startTime)
|
|
3624
|
+
);
|
|
3625
|
+
}).observe({ type: "largest-contentful-paint", buffered: true });
|
|
3626
|
+
new PerformanceObserver((list2) => {
|
|
3627
|
+
state.cls += list2.getEntries().filter(
|
|
3628
|
+
(entry) => !entry.hadRecentInput
|
|
3629
|
+
).reduce(
|
|
3630
|
+
(sum, entry) => sum + (entry.value ?? 0),
|
|
3631
|
+
0
|
|
3632
|
+
);
|
|
3633
|
+
}).observe({ type: "layout-shift", buffered: true });
|
|
3634
|
+
});
|
|
3635
|
+
await page.goto("/", { waitUntil: "load" });
|
|
3636
|
+
await applyPageFault(page, fault);
|
|
3637
|
+
if (fault === "oversized") {
|
|
3638
|
+
await page.addScriptTag({ url: "/__verify-large.js" });
|
|
3639
|
+
}
|
|
3640
|
+
await page.locator("[data-zappie-item]").first().waitFor();
|
|
3641
|
+
await page.waitForTimeout(500);
|
|
3642
|
+
const values = await page.evaluate(async () => {
|
|
3643
|
+
const observed = window.__zappiePerformance ?? { lcp: 0, cls: 0 };
|
|
3644
|
+
const scripts = performance.getEntriesByType("resource").filter((entry) => entry.name.endsWith(".js"));
|
|
3645
|
+
const sizes = await Promise.all(
|
|
3646
|
+
scripts.map(async (entry) => {
|
|
3647
|
+
if (typeof CompressionStream === "undefined") {
|
|
3648
|
+
const timing = entry;
|
|
3649
|
+
return timing.encodedBodySize || timing.transferSize;
|
|
3650
|
+
}
|
|
3651
|
+
const response = await fetch(entry.name);
|
|
3652
|
+
const compressed = response.body?.pipeThrough(
|
|
3653
|
+
new CompressionStream("gzip")
|
|
3654
|
+
);
|
|
3655
|
+
return compressed ? (await new Response(compressed).arrayBuffer()).byteLength : 0;
|
|
3656
|
+
})
|
|
3657
|
+
);
|
|
3658
|
+
const js = sizes.reduce((sum, size) => sum + size, 0);
|
|
3659
|
+
return { ...observed, js };
|
|
3660
|
+
});
|
|
3661
|
+
metrics.menuJsKbGz = Math.round(values.js / 1024 * 10) / 10;
|
|
3662
|
+
metrics.lcpMs = Math.round(values.lcp);
|
|
3663
|
+
metrics.cls = Math.round(values.cls * 1e3) / 1e3;
|
|
3664
|
+
assertion(
|
|
3665
|
+
values.js <= 200 * 1024,
|
|
3666
|
+
"bundle_too_large",
|
|
3667
|
+
`Menu uses ${values.js} B of gzipped JavaScript`,
|
|
3668
|
+
"Split route code until the menu transfers at most 200 KiB of JavaScript."
|
|
3669
|
+
);
|
|
3670
|
+
assertion(
|
|
3671
|
+
values.lcp <= 2500,
|
|
3672
|
+
"lcp_too_slow",
|
|
3673
|
+
`Menu LCP was ${Math.round(values.lcp)} ms`,
|
|
3674
|
+
"Reduce critical rendering work until mobile LCP is at most 2.5 seconds."
|
|
3675
|
+
);
|
|
3676
|
+
assertion(
|
|
3677
|
+
values.cls <= 0.1,
|
|
3678
|
+
"cls_too_high",
|
|
3679
|
+
`Menu CLS was ${values.cls.toFixed(3)}`,
|
|
3680
|
+
"Reserve layout space so CLS stays at or below 0.1."
|
|
3681
|
+
);
|
|
3682
|
+
await cdp.detach();
|
|
3683
|
+
}
|
|
3684
|
+
};
|
|
3685
|
+
|
|
3686
|
+
// src/checks/index.ts
|
|
3687
|
+
var checks = [
|
|
3688
|
+
v01,
|
|
3689
|
+
v02,
|
|
3690
|
+
v03,
|
|
3691
|
+
v04,
|
|
3692
|
+
v05,
|
|
3693
|
+
v06,
|
|
3694
|
+
v07,
|
|
3695
|
+
v09,
|
|
3696
|
+
v10,
|
|
3697
|
+
v13,
|
|
3698
|
+
v14
|
|
3699
|
+
];
|
|
3700
|
+
var blocked = [
|
|
3701
|
+
[
|
|
3702
|
+
"V08",
|
|
3703
|
+
"Test customer sign-in",
|
|
3704
|
+
"Add the PRD 12/13 test accounts to run this check."
|
|
3705
|
+
],
|
|
3706
|
+
[
|
|
3707
|
+
"V11",
|
|
3708
|
+
"Check out with each payment method",
|
|
3709
|
+
"Set up payment sandboxes and the PRD 11 embed to run this check."
|
|
3710
|
+
],
|
|
3711
|
+
[
|
|
3712
|
+
"V12",
|
|
3713
|
+
"Order status page",
|
|
3714
|
+
"Add POST /test/orders/{id}/advance to run this check."
|
|
3715
|
+
]
|
|
3716
|
+
];
|
|
3717
|
+
function skippedChecks(suite) {
|
|
3718
|
+
const standard = blocked.map(([id, title, message]) => ({
|
|
3719
|
+
id,
|
|
3720
|
+
title,
|
|
3721
|
+
result: "skipped",
|
|
3722
|
+
durationMs: 0,
|
|
3723
|
+
error: { code: "dependency_blocked", message, hint: message }
|
|
3724
|
+
}));
|
|
3725
|
+
if (suite === "standard") {
|
|
3726
|
+
return standard;
|
|
3727
|
+
}
|
|
3728
|
+
return [
|
|
3729
|
+
...standard,
|
|
3730
|
+
...Array.from({ length: 6 }, (_, index) => ({
|
|
3731
|
+
id: `E0${index + 1}`,
|
|
3732
|
+
title: "Extended payment checks",
|
|
3733
|
+
result: "skipped",
|
|
3734
|
+
durationMs: 0,
|
|
3735
|
+
error: {
|
|
3736
|
+
code: "dependency_blocked",
|
|
3737
|
+
message: "The extended suite needs the payment elements from PRD 27.",
|
|
3738
|
+
hint: "Implement PRD 27 before you enable the extended suite."
|
|
3739
|
+
}
|
|
3740
|
+
}))
|
|
3741
|
+
];
|
|
3742
|
+
}
|
|
3743
|
+
|
|
3744
|
+
// src/report.ts
|
|
3745
|
+
import { createPrivateKey, randomUUID, sign } from "crypto";
|
|
3746
|
+
var REPORT_SCHEMA = "https://zappie.ai/schemas/verify-report.v1.json";
|
|
3747
|
+
function reportId() {
|
|
3748
|
+
return `ver_${randomUUID().replaceAll("-", "")}`;
|
|
3749
|
+
}
|
|
3750
|
+
function summarize(checks2) {
|
|
3751
|
+
return {
|
|
3752
|
+
passed: checks2.filter((check) => check.result === "passed").length,
|
|
3753
|
+
failed: checks2.filter((check) => check.result === "failed").length,
|
|
3754
|
+
skipped: checks2.filter((check) => check.result === "skipped").length
|
|
3755
|
+
};
|
|
3756
|
+
}
|
|
3757
|
+
function signReport(report, key) {
|
|
3758
|
+
if (!key) {
|
|
3759
|
+
return report;
|
|
3760
|
+
}
|
|
3761
|
+
const payload = JSON.stringify(report);
|
|
3762
|
+
return {
|
|
3763
|
+
...report,
|
|
3764
|
+
signature: sign(
|
|
3765
|
+
null,
|
|
3766
|
+
new TextEncoder().encode(payload),
|
|
3767
|
+
createPrivateKey(key)
|
|
3768
|
+
).toString("base64")
|
|
3769
|
+
};
|
|
3770
|
+
}
|
|
3771
|
+
function printSummary(report) {
|
|
3772
|
+
const lines = report.checks.map((check) => {
|
|
3773
|
+
const suffix = check.error ? ` \u2014 ${check.error.message}` : "";
|
|
3774
|
+
return `${check.result.toUpperCase().padEnd(7)} ${check.id} ${check.title}${suffix}`;
|
|
3775
|
+
});
|
|
3776
|
+
lines.push(
|
|
3777
|
+
`${report.result.toUpperCase()}: ${report.summary.passed} passed, ${report.summary.failed} failed, ${report.summary.skipped} skipped`,
|
|
3778
|
+
report.signature ? "Report signed (ed25519)." : "Report unsigned."
|
|
3779
|
+
);
|
|
3780
|
+
return lines.join("\n");
|
|
3781
|
+
}
|
|
3782
|
+
function target(options) {
|
|
3783
|
+
const url = new URL(options.url);
|
|
3784
|
+
return {
|
|
3785
|
+
url: url.href,
|
|
3786
|
+
origin: url.origin,
|
|
3787
|
+
store: options.store,
|
|
3788
|
+
...options.siteId ? { siteId: options.siteId } : {},
|
|
3789
|
+
...options.deploymentId ? { deploymentId: options.deploymentId } : {}
|
|
3790
|
+
};
|
|
3791
|
+
}
|
|
3792
|
+
|
|
3793
|
+
// src/reset.ts
|
|
3794
|
+
async function reset(options) {
|
|
3795
|
+
const started = performance.now();
|
|
3796
|
+
try {
|
|
3797
|
+
const response = await fetch(new URL("/test/reset", options.url), {
|
|
3798
|
+
method: "POST",
|
|
3799
|
+
headers: {
|
|
3800
|
+
Authorization: `Bearer ${options.key}`,
|
|
3801
|
+
"Content-Type": "application/json"
|
|
3802
|
+
},
|
|
3803
|
+
body: JSON.stringify({ store: options.store }),
|
|
3804
|
+
...options.signal ? { signal: options.signal } : {}
|
|
3805
|
+
});
|
|
3806
|
+
if (response.ok) {
|
|
3807
|
+
return {
|
|
3808
|
+
id: "RESET",
|
|
3809
|
+
title: "Reset test data",
|
|
3810
|
+
result: "passed",
|
|
3811
|
+
durationMs: performance.now() - started
|
|
3812
|
+
};
|
|
3813
|
+
}
|
|
3814
|
+
} catch {
|
|
3815
|
+
}
|
|
3816
|
+
return {
|
|
3817
|
+
id: "RESET",
|
|
3818
|
+
title: "Reset test data",
|
|
3819
|
+
result: "skipped",
|
|
3820
|
+
durationMs: performance.now() - started,
|
|
3821
|
+
error: {
|
|
3822
|
+
code: "reset_unavailable",
|
|
3823
|
+
message: "POST /test/reset is not available.",
|
|
3824
|
+
hint: "Add the PRD 09 /test/reset endpoint so each verification run starts with fresh test data."
|
|
3825
|
+
}
|
|
3826
|
+
};
|
|
3827
|
+
}
|
|
3828
|
+
|
|
3829
|
+
// src/version.ts
|
|
3830
|
+
var PACKAGE_VERSION = true ? "0.1.1" : "0.0.0-development";
|
|
3831
|
+
|
|
3832
|
+
// src/runner.ts
|
|
3833
|
+
var VERSION = PACKAGE_VERSION;
|
|
3834
|
+
var browserOptions = (options) => ({
|
|
3835
|
+
viewport: mobile,
|
|
3836
|
+
baseURL: options.url
|
|
3837
|
+
});
|
|
3838
|
+
function selectedViewports(check, options) {
|
|
3839
|
+
const configured = options.viewports ?? [mobile, desktop];
|
|
3840
|
+
return check.viewports === "mobile" ? [configured[0] ?? mobile] : configured;
|
|
3841
|
+
}
|
|
3842
|
+
async function retryTimeout(task) {
|
|
3843
|
+
try {
|
|
3844
|
+
return { value: await task(), retried: false };
|
|
3845
|
+
} catch (failure) {
|
|
3846
|
+
if (!(failure instanceof errors.TimeoutError)) {
|
|
3847
|
+
throw failure;
|
|
3848
|
+
}
|
|
3849
|
+
return { value: await task(), retried: true };
|
|
3850
|
+
}
|
|
3851
|
+
}
|
|
3852
|
+
async function execute(check, run2) {
|
|
3853
|
+
const started = performance.now();
|
|
3854
|
+
const viewports = selectedViewports(check, run2.options);
|
|
3855
|
+
let retried = false;
|
|
3856
|
+
let attempts = 0;
|
|
3857
|
+
try {
|
|
3858
|
+
await retryTimeout(async () => {
|
|
3859
|
+
retried = attempts++ > 0;
|
|
3860
|
+
for (const viewport of viewports) {
|
|
3861
|
+
run2.options.signal?.throwIfAborted();
|
|
3862
|
+
await check.run(run2, viewport);
|
|
3863
|
+
}
|
|
3864
|
+
});
|
|
3865
|
+
return {
|
|
3866
|
+
id: check.id,
|
|
3867
|
+
title: check.title,
|
|
3868
|
+
result: "passed",
|
|
3869
|
+
durationMs: performance.now() - started,
|
|
3870
|
+
viewport: viewports.map(label).join(","),
|
|
3871
|
+
...retried ? { retried } : {}
|
|
3872
|
+
};
|
|
3873
|
+
} catch (failure) {
|
|
3874
|
+
const error = failure instanceof VerifyError ? { code: failure.code, message: failure.message, hint: failure.hint } : {
|
|
3875
|
+
code: "check_failed",
|
|
3876
|
+
message: failure instanceof Error ? failure.message : String(failure),
|
|
3877
|
+
hint: `Fix the failure in ${check.id} (${check.title.toLowerCase()}) and rerun verify.`
|
|
3878
|
+
};
|
|
3879
|
+
if (error.code === "dependency_blocked") {
|
|
3880
|
+
return {
|
|
3881
|
+
id: check.id,
|
|
3882
|
+
title: check.title,
|
|
3883
|
+
result: "skipped",
|
|
3884
|
+
durationMs: performance.now() - started,
|
|
3885
|
+
error
|
|
3886
|
+
};
|
|
3887
|
+
}
|
|
3888
|
+
const screenshot = path3.join(
|
|
3889
|
+
run2.options.artifactsDir ?? "verify-artifacts",
|
|
3890
|
+
`${check.id}.png`
|
|
3891
|
+
);
|
|
3892
|
+
const trace = path3.join(
|
|
3893
|
+
run2.options.artifactsDir ?? "verify-artifacts",
|
|
3894
|
+
"verify.trace.zip"
|
|
3895
|
+
);
|
|
3896
|
+
await mkdir(path3.dirname(screenshot), { recursive: true });
|
|
3897
|
+
await run2.page.screenshot({ path: screenshot, fullPage: true }).catch(() => void 0);
|
|
3898
|
+
return {
|
|
3899
|
+
id: check.id,
|
|
3900
|
+
title: check.title,
|
|
3901
|
+
result: "failed",
|
|
3902
|
+
durationMs: performance.now() - started,
|
|
3903
|
+
viewport: viewports.map(label).join(","),
|
|
3904
|
+
...retried ? { retried } : {},
|
|
3905
|
+
error,
|
|
3906
|
+
artifacts: { screenshot, trace }
|
|
3907
|
+
};
|
|
3908
|
+
}
|
|
3909
|
+
}
|
|
3910
|
+
var label = (viewport) => `${viewport.width}x${viewport.height}`;
|
|
3911
|
+
async function runVerify(options, fault) {
|
|
3912
|
+
if (!/^pk_test_.+/.test(options.key)) {
|
|
3913
|
+
throw new VerifyError(
|
|
3914
|
+
"live_key_refused",
|
|
3915
|
+
"Verify accepts only pk_test_ keys",
|
|
3916
|
+
"Create a test publishable key and rerun verify."
|
|
3917
|
+
);
|
|
3918
|
+
}
|
|
3919
|
+
const started = Date.now();
|
|
3920
|
+
const browser = await chromium.launch();
|
|
3921
|
+
const context = await browser.newContext(browserOptions(options));
|
|
3922
|
+
if (options.standInForPlatform) {
|
|
3923
|
+
await context.route(
|
|
3924
|
+
"**/_zappie/view",
|
|
3925
|
+
(route) => route.fulfill({ status: 204 })
|
|
3926
|
+
);
|
|
3927
|
+
}
|
|
3928
|
+
await context.tracing.start({ screenshots: true, snapshots: true });
|
|
3929
|
+
const page = await context.newPage();
|
|
3930
|
+
page.setDefaultTimeout(Math.min(options.timeoutMs ?? 2e4, 2e4));
|
|
3931
|
+
await installFault(page, fault);
|
|
3932
|
+
const metrics = {};
|
|
3933
|
+
const run2 = {
|
|
3934
|
+
options,
|
|
3935
|
+
browser,
|
|
3936
|
+
context,
|
|
3937
|
+
page,
|
|
3938
|
+
metrics,
|
|
3939
|
+
...fault ? { fault } : {}
|
|
3940
|
+
};
|
|
3941
|
+
const results = [await reset(options)];
|
|
3942
|
+
try {
|
|
3943
|
+
const wanted = options.only ? new Set(options.only) : void 0;
|
|
3944
|
+
for (const check of checks) {
|
|
3945
|
+
if (wanted && !wanted.has(check.id)) {
|
|
3946
|
+
continue;
|
|
3947
|
+
}
|
|
3948
|
+
options.onProgress?.({ id: check.id, status: "running" });
|
|
3949
|
+
const result = await execute(check, run2);
|
|
3950
|
+
results.push(result);
|
|
3951
|
+
options.onProgress?.({ id: check.id, status: result.result });
|
|
3952
|
+
}
|
|
3953
|
+
if (!wanted) {
|
|
3954
|
+
results.push(...skippedChecks(options.suite));
|
|
3955
|
+
}
|
|
3956
|
+
const sdkVersion = await page.evaluate(() => window.__zappie?.version ?? "unknown").catch(() => "unknown");
|
|
3957
|
+
const summary = summarize(results);
|
|
3958
|
+
const report = {
|
|
3959
|
+
schema: REPORT_SCHEMA,
|
|
3960
|
+
id: reportId(),
|
|
3961
|
+
suite: options.suite,
|
|
3962
|
+
target: target(options),
|
|
3963
|
+
environment: {
|
|
3964
|
+
runner: options.runner ?? "cli",
|
|
3965
|
+
verifyVersion: VERSION,
|
|
3966
|
+
sdkVersion,
|
|
3967
|
+
browser: `chromium ${browser.version()}`
|
|
3968
|
+
},
|
|
3969
|
+
startedAt: new Date(started).toISOString(),
|
|
3970
|
+
durationMs: Date.now() - started,
|
|
3971
|
+
result: summary.failed ? "failed" : "passed",
|
|
3972
|
+
summary,
|
|
3973
|
+
checks: results,
|
|
3974
|
+
metrics
|
|
3975
|
+
};
|
|
3976
|
+
return signReport(
|
|
3977
|
+
report,
|
|
3978
|
+
options.signingKey ?? process.env.ZAPPIE_VERIFY_SIGNING_KEY
|
|
3979
|
+
);
|
|
3980
|
+
} finally {
|
|
3981
|
+
const trace = path3.join(
|
|
3982
|
+
options.artifactsDir ?? "verify-artifacts",
|
|
3983
|
+
"verify.trace.zip"
|
|
3984
|
+
);
|
|
3985
|
+
await mkdir(path3.dirname(trace), { recursive: true });
|
|
3986
|
+
await context.tracing.stop({ path: trace }).catch(() => void 0);
|
|
3987
|
+
await browser.close();
|
|
3988
|
+
}
|
|
3989
|
+
}
|
|
3990
|
+
|
|
3991
|
+
// src/project.ts
|
|
3992
|
+
async function verifyProject(options) {
|
|
3993
|
+
const store = options.store ?? await readStore(options.cwd);
|
|
3994
|
+
let preview;
|
|
3995
|
+
try {
|
|
3996
|
+
preview = options.url ? void 0 : await startLocalPreview({
|
|
3997
|
+
cwd: options.cwd,
|
|
3998
|
+
key: options.key,
|
|
3999
|
+
store,
|
|
4000
|
+
packageManager: options.packageManager,
|
|
4001
|
+
...options.signal ? { signal: options.signal } : {}
|
|
4002
|
+
});
|
|
4003
|
+
return await runVerify({
|
|
4004
|
+
url: normalizeUrl(options.url ?? preview?.url),
|
|
4005
|
+
store,
|
|
4006
|
+
key: options.key,
|
|
4007
|
+
suite: options.suite,
|
|
4008
|
+
runner: "cli",
|
|
4009
|
+
...options.only ? { only: options.only } : {},
|
|
4010
|
+
...options.viewports ? { viewports: options.viewports } : {},
|
|
4011
|
+
...options.artifactsDir ? { artifactsDir: options.artifactsDir } : {},
|
|
4012
|
+
...options.timeoutMs ? { timeoutMs: options.timeoutMs } : {},
|
|
4013
|
+
...options.signal ? { signal: options.signal } : {},
|
|
4014
|
+
...options.onProgress ? { onProgress: options.onProgress } : {},
|
|
4015
|
+
...options.signingKey ? { signingKey: options.signingKey } : {},
|
|
4016
|
+
// A preview started here has no site host in front of it.
|
|
4017
|
+
...preview ? { standInForPlatform: true } : {}
|
|
4018
|
+
});
|
|
4019
|
+
} finally {
|
|
4020
|
+
await preview?.close();
|
|
4021
|
+
}
|
|
4022
|
+
}
|
|
4023
|
+
function normalizeUrl(value) {
|
|
4024
|
+
try {
|
|
4025
|
+
const url = new URL(value ?? "");
|
|
4026
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
4027
|
+
throw new Error("protocol");
|
|
4028
|
+
}
|
|
4029
|
+
return url.href;
|
|
4030
|
+
} catch {
|
|
4031
|
+
throw new VerifyError(
|
|
4032
|
+
"invalid_url",
|
|
4033
|
+
"Verify needs an http(s) storefront URL",
|
|
4034
|
+
"Pass a valid URL or run the command from a supported project."
|
|
4035
|
+
);
|
|
4036
|
+
}
|
|
4037
|
+
}
|
|
4038
|
+
|
|
4039
|
+
// src/index.ts
|
|
4040
|
+
function verify(options) {
|
|
4041
|
+
return runVerify(options);
|
|
4042
|
+
}
|
|
4043
|
+
export {
|
|
4044
|
+
VerifyError,
|
|
4045
|
+
printSummary,
|
|
4046
|
+
verify,
|
|
4047
|
+
verifyProject
|
|
4048
|
+
};
|
|
4049
|
+
//# sourceMappingURL=index.js.map
|