@fire-spark/core 0.0.1 → 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +508 -47
- package/dist/index.js +1389 -295
- package/package.json +6 -5
package/dist/index.js
CHANGED
|
@@ -27,10 +27,37 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
|
|
|
27
27
|
enumerable: true
|
|
28
28
|
}) : target, mod));
|
|
29
29
|
//#endregion
|
|
30
|
+
//#region src/productCustomization/clamp.utils.ts
|
|
31
|
+
const clampModifierQuantity = (type, quantity, min, max) => {
|
|
32
|
+
let next = Math.max(0, Math.floor(quantity));
|
|
33
|
+
if (type === "RADIO" || type === "CHECKBOX") next = next > 0 ? 1 : 0;
|
|
34
|
+
if (next === 0) return 0;
|
|
35
|
+
let lower = min;
|
|
36
|
+
if (type === "QUANTITY") lower = 0;
|
|
37
|
+
return Math.min(max, Math.max(lower, next));
|
|
38
|
+
};
|
|
39
|
+
const clampProductQuantity = (quantity, min, max) => {
|
|
40
|
+
return Math.min(max, Math.max(min, Math.max(0, Math.floor(quantity))));
|
|
41
|
+
};
|
|
42
|
+
//#endregion
|
|
30
43
|
//#region src/productCustomization/catalog.utils.ts
|
|
31
44
|
const resolvePricingModifier = (product, groupId) => {
|
|
32
45
|
return product.pricing.modifiers.find((modifier) => modifier.modifier_group_id === groupId);
|
|
33
46
|
};
|
|
47
|
+
/** Unit price and tax of a modifier in a group. A variant base listed in the
|
|
48
|
+
* group keeps the limits; the variant it resolves to sets what is charged. */
|
|
49
|
+
const modifierUnitPricing = (listed, ordered, groupId) => {
|
|
50
|
+
const product = ordered ?? listed;
|
|
51
|
+
const entry = resolvePricingModifier(product, groupId);
|
|
52
|
+
const { price, tax_rate, is_tax_inclusive } = product.pricing;
|
|
53
|
+
const taxRate = entry?.tax_rate ?? tax_rate;
|
|
54
|
+
const isTaxInclusive = entry?.is_tax_inclusive ?? is_tax_inclusive;
|
|
55
|
+
return {
|
|
56
|
+
price: entry?.price ?? price,
|
|
57
|
+
taxRate,
|
|
58
|
+
isTaxInclusive
|
|
59
|
+
};
|
|
60
|
+
};
|
|
34
61
|
const inferControlType = (pricingModifier, group) => {
|
|
35
62
|
if (pricingModifier?.display_type) return pricingModifier.display_type;
|
|
36
63
|
const gMin = group.minimum_quantity;
|
|
@@ -65,12 +92,12 @@ const collectReferencedGroupIds = (products) => {
|
|
|
65
92
|
for (const product of products) for (const groupId of product.pricing.modifier_group_ids ?? []) ids.add(groupId);
|
|
66
93
|
return ids;
|
|
67
94
|
};
|
|
68
|
-
const inferGroupFromProducts = (groupId, modifierIds,
|
|
95
|
+
const inferGroupFromProducts = (groupId, modifierIds, productsById) => {
|
|
69
96
|
let minimum_quantity = 0;
|
|
70
97
|
let maximum_quantity = 1;
|
|
71
98
|
let charge_above_quantity = null;
|
|
72
99
|
for (const modifierId of modifierIds) {
|
|
73
|
-
const entry =
|
|
100
|
+
const entry = productsById.get(modifierId)?.pricing.modifiers?.find((modifier) => modifier.modifier_group_id === groupId);
|
|
74
101
|
if (!entry) continue;
|
|
75
102
|
minimum_quantity = Math.max(minimum_quantity, entry.minimum_quantity ?? 0);
|
|
76
103
|
maximum_quantity = Math.max(maximum_quantity, entry.maximum_quantity ?? 1);
|
|
@@ -94,26 +121,324 @@ const resolveModifierGroupsForLookup = (products, modifierGroups) => {
|
|
|
94
121
|
const idsByGroup = collectModifierIdsByGroupId(products);
|
|
95
122
|
const referenced = collectReferencedGroupIds(products);
|
|
96
123
|
const needed = /* @__PURE__ */ new Set([...referenced, ...idsByGroup.keys()]);
|
|
124
|
+
let productsById = null;
|
|
97
125
|
for (const groupId of needed) {
|
|
98
126
|
if (byId.has(groupId)) continue;
|
|
99
127
|
const modifierIds = idsByGroup.get(groupId) ?? [];
|
|
100
128
|
if (modifierIds.length === 0) continue;
|
|
101
|
-
|
|
129
|
+
productsById ??= new Map(products.map((product) => [product.id, product]));
|
|
130
|
+
const args = [
|
|
131
|
+
groupId,
|
|
132
|
+
modifierIds,
|
|
133
|
+
productsById
|
|
134
|
+
];
|
|
135
|
+
byId.set(groupId, inferGroupFromProducts(...args));
|
|
102
136
|
}
|
|
103
137
|
return [...byId.values()];
|
|
104
138
|
};
|
|
105
139
|
//#endregion
|
|
106
|
-
//#region src/productCustomization/
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
140
|
+
//#region src/productCustomization/store.ts
|
|
141
|
+
const createStore = () => {
|
|
142
|
+
return {
|
|
143
|
+
quantities: {},
|
|
144
|
+
initialQuantities: {},
|
|
145
|
+
selections: {},
|
|
146
|
+
initialSelections: {}
|
|
147
|
+
};
|
|
114
148
|
};
|
|
115
|
-
const
|
|
116
|
-
return
|
|
149
|
+
const getQuantity = (store, path) => {
|
|
150
|
+
return store.quantities[path] ?? 0;
|
|
151
|
+
};
|
|
152
|
+
const setQuantity = (store, path, quantity) => {
|
|
153
|
+
if (quantity <= 0) delete store.quantities[path];
|
|
154
|
+
if (quantity <= 0) return;
|
|
155
|
+
store.quantities[path] = quantity;
|
|
156
|
+
};
|
|
157
|
+
const isInSubtree = (key, path) => {
|
|
158
|
+
return key === path || key.startsWith(`${path}/`);
|
|
159
|
+
};
|
|
160
|
+
const clearSubtree = (store, path) => {
|
|
161
|
+
for (const key of Object.keys(store.quantities)) if (isInSubtree(key, path)) delete store.quantities[key];
|
|
162
|
+
for (const key of Object.keys(store.selections)) if (isInSubtree(key, path)) delete store.selections[key];
|
|
163
|
+
};
|
|
164
|
+
/** True when `productId` already sits on `path` (root or any modifier), so
|
|
165
|
+
* descending into it again would loop through a cyclic menu. */
|
|
166
|
+
const pathVisits = (path, productId) => {
|
|
167
|
+
if (isInSubtree(path, `/${productId}`)) return true;
|
|
168
|
+
const segment = `/modifier/${productId}`;
|
|
169
|
+
return path.endsWith(segment) || path.includes(`${segment}/`);
|
|
170
|
+
};
|
|
171
|
+
/** Entries at or below `path`, e.g. to restore one product's subtree. */
|
|
172
|
+
const pickSubtree = (entries, path) => {
|
|
173
|
+
const picked = {};
|
|
174
|
+
for (const [key, value] of Object.entries(entries)) if (isInSubtree(key, path)) picked[key] = value;
|
|
175
|
+
return picked;
|
|
176
|
+
};
|
|
177
|
+
const cloneQuantities = (quantities) => {
|
|
178
|
+
return { ...quantities };
|
|
179
|
+
};
|
|
180
|
+
const cloneSelections = (selections) => {
|
|
181
|
+
const next = {};
|
|
182
|
+
for (const [key, value] of Object.entries(selections)) next[key] = { ...value };
|
|
183
|
+
return next;
|
|
184
|
+
};
|
|
185
|
+
//#endregion
|
|
186
|
+
//#region src/productCustomization/variants.ts
|
|
187
|
+
const isSelectable = (product) => {
|
|
188
|
+
if (product.status === "INACTIVE") return false;
|
|
189
|
+
return product.availability.status !== "HIDDEN";
|
|
190
|
+
};
|
|
191
|
+
const isInStock = (product) => {
|
|
192
|
+
return product.availability.status !== "OUT_OF_STOCK";
|
|
193
|
+
};
|
|
194
|
+
const isVariantBase = (product) => {
|
|
195
|
+
return (product.pricing.variant_group_ids ?? []).length > 0;
|
|
196
|
+
};
|
|
197
|
+
const variantGroupsOf = (lookup, base) => {
|
|
198
|
+
const groups = [];
|
|
199
|
+
for (const groupId of base.pricing.variant_group_ids ?? []) {
|
|
200
|
+
const group = lookup.variantGroupsById?.[groupId];
|
|
201
|
+
if (group) groups.push(group);
|
|
202
|
+
}
|
|
203
|
+
return groups;
|
|
204
|
+
};
|
|
205
|
+
const selectionFor = (groups, entry) => {
|
|
206
|
+
const selection = {};
|
|
207
|
+
for (const group of groups) {
|
|
208
|
+
const option = group.variants.find((variant) => {
|
|
209
|
+
return entry.variant_group_option_ids.includes(variant.id);
|
|
210
|
+
});
|
|
211
|
+
if (option) selection[group.id] = option.id;
|
|
212
|
+
}
|
|
213
|
+
return selection;
|
|
214
|
+
};
|
|
215
|
+
/** Base selector for a product: itself when it has variant groups, else the
|
|
216
|
+
* first base it resolves from (with its options preselected). */
|
|
217
|
+
const resolveVariantBase = (lookup, product) => {
|
|
218
|
+
if (isVariantBase(product)) return {
|
|
219
|
+
base: product,
|
|
220
|
+
selection: {}
|
|
221
|
+
};
|
|
222
|
+
for (const entry of product.pricing.variants ?? []) {
|
|
223
|
+
const base = lookup.productsById[entry.product_id];
|
|
224
|
+
if (!base || !isVariantBase(base)) continue;
|
|
225
|
+
return {
|
|
226
|
+
base,
|
|
227
|
+
selection: selectionFor(variantGroupsOf(lookup, base), entry)
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
return null;
|
|
231
|
+
};
|
|
232
|
+
const variantEntryFor = (product, baseId) => {
|
|
233
|
+
return product.pricing.variants?.find((entry) => entry.product_id === baseId);
|
|
234
|
+
};
|
|
235
|
+
const buildVariantCandidates = (lookup, base) => {
|
|
236
|
+
const byId = /* @__PURE__ */ new Map();
|
|
237
|
+
for (const group of variantGroupsOf(lookup, base)) for (const option of group.variants) for (const productId of option.product_ids) {
|
|
238
|
+
const product = lookup.productsById[productId];
|
|
239
|
+
if (!product || !isSelectable(product)) continue;
|
|
240
|
+
if (byId.has(productId)) continue;
|
|
241
|
+
const entry = variantEntryFor(product, base.id);
|
|
242
|
+
if (!entry) continue;
|
|
243
|
+
const optionIds = new Set(entry.variant_group_option_ids);
|
|
244
|
+
byId.set(productId, {
|
|
245
|
+
product,
|
|
246
|
+
optionIds
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
return [...byId.values()];
|
|
250
|
+
};
|
|
251
|
+
const indexCache = /* @__PURE__ */ new WeakMap();
|
|
252
|
+
const variantIndexFor = (lookup, base) => {
|
|
253
|
+
let byBase = indexCache.get(lookup);
|
|
254
|
+
if (!byBase) {
|
|
255
|
+
byBase = /* @__PURE__ */ new Map();
|
|
256
|
+
indexCache.set(lookup, byBase);
|
|
257
|
+
}
|
|
258
|
+
const cached = byBase.get(base.id);
|
|
259
|
+
if (cached) return cached;
|
|
260
|
+
const index = {
|
|
261
|
+
groups: variantGroupsOf(lookup, base),
|
|
262
|
+
candidates: buildVariantCandidates(lookup, base)
|
|
263
|
+
};
|
|
264
|
+
byBase.set(base.id, index);
|
|
265
|
+
return index;
|
|
266
|
+
};
|
|
267
|
+
const variantCandidates = (lookup, base) => {
|
|
268
|
+
return variantIndexFor(lookup, base).candidates;
|
|
269
|
+
};
|
|
270
|
+
/** Normalized selection for a base and the product it resolves to, if any. */
|
|
271
|
+
const resolveVariantSelection = (lookup, base, selection) => {
|
|
272
|
+
const { groups, candidates } = variantIndexFor(lookup, base);
|
|
273
|
+
const next = normalizeSelection(groups, candidates, selection);
|
|
274
|
+
return {
|
|
275
|
+
selection: next,
|
|
276
|
+
product: findVariantProduct(groups, candidates, next)
|
|
277
|
+
};
|
|
278
|
+
};
|
|
279
|
+
/** Options of `base` that resolve to `product`, or null if it isn't one. */
|
|
280
|
+
const selectionForVariant = (lookup, base, product) => {
|
|
281
|
+
const entry = variantEntryFor(product, base.id);
|
|
282
|
+
if (!entry) return null;
|
|
283
|
+
return selectionFor(variantIndexFor(lookup, base).groups, entry);
|
|
284
|
+
};
|
|
285
|
+
/** True when `product` is what `base` resolves to for its own options. */
|
|
286
|
+
const resolvesFromBase = (lookup, base, product) => {
|
|
287
|
+
if (!isVariantBase(base)) return false;
|
|
288
|
+
const selection = selectionForVariant(lookup, base, product);
|
|
289
|
+
if (!selection) return false;
|
|
290
|
+
const { groups, candidates } = variantIndexFor(lookup, base);
|
|
291
|
+
return findVariantProduct(groups, candidates, selection)?.id === product.id;
|
|
292
|
+
};
|
|
293
|
+
/** True when some active, visible base still resolves to this product. */
|
|
294
|
+
const isOfferedVariant = (lookup, product) => {
|
|
295
|
+
return (product.pricing.variants ?? []).some((entry) => {
|
|
296
|
+
const base = lookup.productsById[entry.product_id];
|
|
297
|
+
if (!base || !isSelectable(base)) return false;
|
|
298
|
+
return resolvesFromBase(lookup, base, product);
|
|
299
|
+
});
|
|
300
|
+
};
|
|
301
|
+
const matching = (candidates, optionIds) => {
|
|
302
|
+
return candidates.filter((candidate) => {
|
|
303
|
+
return optionIds.every((id) => candidate.optionIds.has(id));
|
|
304
|
+
});
|
|
305
|
+
};
|
|
306
|
+
const priorOptionIds = (groups, index, selection) => {
|
|
307
|
+
const ids = [];
|
|
308
|
+
for (const group of groups.slice(0, index)) {
|
|
309
|
+
const selected = selection[group.id];
|
|
310
|
+
if (selected) ids.push(selected);
|
|
311
|
+
}
|
|
312
|
+
return ids;
|
|
313
|
+
};
|
|
314
|
+
const visibleOptions = (candidates, group, prior) => {
|
|
315
|
+
return group.variants.flatMap((option) => {
|
|
316
|
+
const matches = matching(candidates, [...prior, option.id]);
|
|
317
|
+
if (matches.length === 0) return [];
|
|
318
|
+
return [{
|
|
319
|
+
option,
|
|
320
|
+
disabled: !matches.some((match) => isInStock(match.product))
|
|
321
|
+
}];
|
|
322
|
+
});
|
|
323
|
+
};
|
|
324
|
+
/** Drops picks no longer reachable from earlier groups and auto-picks groups
|
|
325
|
+
* left with a single enabled option. Out-of-stock picks are kept so an
|
|
326
|
+
* explicit variant still resolves (and reports OUT_OF_STOCK downstream). */
|
|
327
|
+
const normalizeSelection = (groups, candidates, selection) => {
|
|
328
|
+
const next = {};
|
|
329
|
+
groups.forEach((group, index) => {
|
|
330
|
+
const prior = priorOptionIds(groups, index, next);
|
|
331
|
+
const visible = visibleOptions(candidates, group, prior);
|
|
332
|
+
const current = selection[group.id];
|
|
333
|
+
const kept = visible.find((entry) => entry.option.id === current);
|
|
334
|
+
if (kept) {
|
|
335
|
+
next[group.id] = kept.option.id;
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
const enabled = visible.filter((entry) => !entry.disabled);
|
|
339
|
+
if (enabled.length === 1 && enabled[0]) next[group.id] = enabled[0].option.id;
|
|
340
|
+
});
|
|
341
|
+
return next;
|
|
342
|
+
};
|
|
343
|
+
const findVariantProduct = (groups, candidates, selection) => {
|
|
344
|
+
const optionIds = groups.map((group) => selection[group.id]);
|
|
345
|
+
if (optionIds.some((id) => !id)) return null;
|
|
346
|
+
const [match] = matching(candidates, optionIds);
|
|
347
|
+
return match?.product ?? null;
|
|
348
|
+
};
|
|
349
|
+
const buildVariantGroupCtxs = (groups, candidates, selection, onSelect, pathPrefix = "") => {
|
|
350
|
+
return groups.map((group, index) => {
|
|
351
|
+
const path = `${pathPrefix}/variant/${group.id}`;
|
|
352
|
+
const prior = priorOptionIds(groups, index, selection);
|
|
353
|
+
const selectedId = selection[group.id] ?? null;
|
|
354
|
+
const options = visibleOptions(candidates, group, prior).map((entry) => {
|
|
355
|
+
return {
|
|
356
|
+
path: `${path}/option/${entry.option.id}`,
|
|
357
|
+
data: entry.option,
|
|
358
|
+
selected: entry.option.id === selectedId,
|
|
359
|
+
disabled: entry.disabled,
|
|
360
|
+
select: () => {
|
|
361
|
+
if (!entry.disabled) onSelect(group.id, entry.option.id);
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
});
|
|
365
|
+
return {
|
|
366
|
+
path,
|
|
367
|
+
data: group,
|
|
368
|
+
selectedId,
|
|
369
|
+
status: selectedId ? "VALID" : "INCOMPLETE",
|
|
370
|
+
options
|
|
371
|
+
};
|
|
372
|
+
});
|
|
373
|
+
};
|
|
374
|
+
//#endregion
|
|
375
|
+
//#region src/productCustomization/seed.ts
|
|
376
|
+
/** Product whose modifier groups hang below a modifier path: a variant base
|
|
377
|
+
* defers to the variant its stored options resolve to (auto-picks persist). */
|
|
378
|
+
const customizedProduct = (store, lookup, product, path) => {
|
|
379
|
+
if (!isVariantBase(product)) return product;
|
|
380
|
+
const { selection, product: resolved } = resolveVariantSelection(...[
|
|
381
|
+
lookup,
|
|
382
|
+
product,
|
|
383
|
+
store.selections[path] ?? {}
|
|
384
|
+
]);
|
|
385
|
+
if (Object.keys(selection).length > 0) store.selections[path] = selection;
|
|
386
|
+
else delete store.selections[path];
|
|
387
|
+
return resolved ?? product;
|
|
388
|
+
};
|
|
389
|
+
const applyCatalogDefaults = (store, lookup, product, path) => {
|
|
390
|
+
setQuantity(store, path, product.pricing.minimum_quantity);
|
|
391
|
+
applyNestedDefaults(store, lookup, product, path);
|
|
392
|
+
};
|
|
393
|
+
const applyNestedDefaults = (store, lookup, product, path) => {
|
|
394
|
+
for (const groupId of product.pricing.modifier_group_ids) {
|
|
395
|
+
const group = lookup.modifierGroupsById[groupId];
|
|
396
|
+
if (!group) continue;
|
|
397
|
+
let radioDefaultApplied = false;
|
|
398
|
+
for (const modifierId of group.modifier_ids) {
|
|
399
|
+
const modifierProduct = lookup.productsById[modifierId];
|
|
400
|
+
if (!modifierProduct || pathVisits(path, modifierId)) continue;
|
|
401
|
+
const modifierPath = `${path}/group/${groupId}/modifier/${modifierId}`;
|
|
402
|
+
const pricingModifier = resolvePricingModifier(modifierProduct, groupId);
|
|
403
|
+
const defaultQuantity = pricingModifier?.default_quantity ?? 0;
|
|
404
|
+
if (defaultQuantity <= 0) continue;
|
|
405
|
+
if (inferControlType(pricingModifier, group) === "RADIO") {
|
|
406
|
+
if (radioDefaultApplied) continue;
|
|
407
|
+
radioDefaultApplied = true;
|
|
408
|
+
}
|
|
409
|
+
setQuantity(store, modifierPath, defaultQuantity);
|
|
410
|
+
applyNestedDefaults(store, lookup, customizedProduct(...[
|
|
411
|
+
store,
|
|
412
|
+
lookup,
|
|
413
|
+
modifierProduct,
|
|
414
|
+
modifierPath
|
|
415
|
+
]), modifierPath);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
};
|
|
419
|
+
const applyNestedInitial = (store, lookup, product, path, quantities) => {
|
|
420
|
+
for (const groupId of product.pricing.modifier_group_ids) {
|
|
421
|
+
const group = lookup.modifierGroupsById[groupId];
|
|
422
|
+
if (!group) continue;
|
|
423
|
+
for (const modifierId of group.modifier_ids) {
|
|
424
|
+
const modifierProduct = lookup.productsById[modifierId];
|
|
425
|
+
if (!modifierProduct || pathVisits(path, modifierId)) continue;
|
|
426
|
+
const modifierPath = `${path}/group/${groupId}/modifier/${modifierId}`;
|
|
427
|
+
if (quantities[modifierPath] !== void 0) setQuantity(store, modifierPath, quantities[modifierPath]);
|
|
428
|
+
if (getQuantity(store, modifierPath) <= 0) continue;
|
|
429
|
+
const nested = customizedProduct(...[
|
|
430
|
+
store,
|
|
431
|
+
lookup,
|
|
432
|
+
modifierProduct,
|
|
433
|
+
modifierPath
|
|
434
|
+
]);
|
|
435
|
+
applyNestedInitial(store, lookup, nested, modifierPath, quantities);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
};
|
|
439
|
+
const applyInitialQuantities = (store, lookup, product, path, quantities) => {
|
|
440
|
+
setQuantity(store, path, quantities[path] ?? product.pricing.minimum_quantity);
|
|
441
|
+
applyNestedInitial(store, lookup, product, path, quantities);
|
|
117
442
|
};
|
|
118
443
|
//#endregion
|
|
119
444
|
//#region ../../node_modules/.pnpm/big.js@6.2.1/node_modules/big.js/big.mjs
|
|
@@ -541,7 +866,11 @@ const forEachBillableUnit = (group, modifiers, onUnit) => {
|
|
|
541
866
|
for (const modifier of modifiers) {
|
|
542
867
|
if (modifier.quantity <= 0) continue;
|
|
543
868
|
const pricingModifier = resolvePricingModifier(modifier.data, group.id);
|
|
544
|
-
const unit = new Big(
|
|
869
|
+
const unit = new Big(modifierUnitPricing(...[
|
|
870
|
+
modifier.data,
|
|
871
|
+
modifier.resolved,
|
|
872
|
+
group.id
|
|
873
|
+
]).price);
|
|
545
874
|
const modifierChargeAbove = pricingModifier?.charge_above_quantity ?? 0;
|
|
546
875
|
let remaining = billableQuantity(modifier.quantity, modifierChargeAbove);
|
|
547
876
|
while (remaining > 0) {
|
|
@@ -584,95 +913,20 @@ const computeTotalPrice = (product) => {
|
|
|
584
913
|
return unit.plus(mods).times(product.quantity).toNumber();
|
|
585
914
|
};
|
|
586
915
|
//#endregion
|
|
587
|
-
//#region src/productCustomization/store.ts
|
|
588
|
-
const getQuantity = (store, path) => {
|
|
589
|
-
return store.quantities[path] ?? 0;
|
|
590
|
-
};
|
|
591
|
-
const setQuantity = (store, path, quantity) => {
|
|
592
|
-
if (quantity <= 0) delete store.quantities[path];
|
|
593
|
-
if (quantity <= 0) return;
|
|
594
|
-
store.quantities[path] = quantity;
|
|
595
|
-
};
|
|
596
|
-
const clearSubtree = (store, path) => {
|
|
597
|
-
for (const key of Object.keys(store.quantities)) if (key === path || key.startsWith(`${path}/`)) delete store.quantities[key];
|
|
598
|
-
};
|
|
599
|
-
const cloneQuantities = (quantities) => {
|
|
600
|
-
return { ...quantities };
|
|
601
|
-
};
|
|
602
|
-
//#endregion
|
|
603
916
|
//#region src/productCustomization/refresh.ts
|
|
604
|
-
const
|
|
605
|
-
const
|
|
606
|
-
modifier.min = pricingModifier?.minimum_quantity ?? 0;
|
|
607
|
-
modifier.max = pricingModifier?.maximum_quantity ?? group.data.maximum_quantity;
|
|
608
|
-
modifier.required = modifier.min > 0;
|
|
609
|
-
modifier.quantity = getQuantity(store, modifier.path);
|
|
610
|
-
modifier.type = inferControlType(pricingModifier, group.data);
|
|
611
|
-
};
|
|
612
|
-
const refreshProductTree = (product, store) => {
|
|
917
|
+
const refreshProductTree = (product, runtime) => {
|
|
918
|
+
const { store, variantSyncs } = runtime;
|
|
613
919
|
product.quantity = getQuantity(store, product.path);
|
|
614
920
|
for (const group of product.groups) {
|
|
921
|
+
let selectedQuantity = 0;
|
|
615
922
|
for (const modifier of group.modifiers) {
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
}
|
|
923
|
+
modifier.quantity = getQuantity(store, modifier.path);
|
|
924
|
+
selectedQuantity += modifier.quantity;
|
|
925
|
+
variantSyncs.get(modifier)?.();
|
|
926
|
+
if (modifier.product) refreshProductTree(modifier.product, runtime);
|
|
621
927
|
}
|
|
622
928
|
computeGroupModifierLinePrices(group.data, group.modifiers);
|
|
623
|
-
group.selectedQuantity =
|
|
624
|
-
return sum + modifier.quantity;
|
|
625
|
-
}, 0);
|
|
626
|
-
}
|
|
627
|
-
};
|
|
628
|
-
//#endregion
|
|
629
|
-
//#region src/productCustomization/seed.ts
|
|
630
|
-
const applyCatalogDefaults = (store, lookup, product, path) => {
|
|
631
|
-
setQuantity(store, path, product.pricing.minimum_quantity);
|
|
632
|
-
applyNestedDefaults(store, lookup, product, path);
|
|
633
|
-
};
|
|
634
|
-
const applyNestedDefaults = (store, lookup, product, path) => {
|
|
635
|
-
for (const groupId of product.pricing.modifier_group_ids) {
|
|
636
|
-
const group = lookup.modifierGroupsById[groupId];
|
|
637
|
-
if (!group) continue;
|
|
638
|
-
let radioDefaultApplied = false;
|
|
639
|
-
for (const modifierId of group.modifier_ids) {
|
|
640
|
-
const modifierProduct = lookup.productsById[modifierId];
|
|
641
|
-
if (!modifierProduct) continue;
|
|
642
|
-
const modifierPath = `${path}/group/${groupId}/modifier/${modifierId}`;
|
|
643
|
-
const pricingModifier = resolvePricingModifier(modifierProduct, groupId);
|
|
644
|
-
const defaultQuantity = pricingModifier?.default_quantity ?? 0;
|
|
645
|
-
if (defaultQuantity <= 0) continue;
|
|
646
|
-
if (inferControlType(pricingModifier, group) === "RADIO") {
|
|
647
|
-
if (radioDefaultApplied) continue;
|
|
648
|
-
radioDefaultApplied = true;
|
|
649
|
-
}
|
|
650
|
-
setQuantity(store, modifierPath, defaultQuantity);
|
|
651
|
-
applyNestedDefaults(store, lookup, modifierProduct, modifierPath);
|
|
652
|
-
}
|
|
653
|
-
}
|
|
654
|
-
};
|
|
655
|
-
const applyInitialQuantities = (store, lookup, product, path, quantities) => {
|
|
656
|
-
if (quantities[path] !== void 0) setQuantity(store, path, quantities[path]);
|
|
657
|
-
else setQuantity(store, path, product.pricing.minimum_quantity);
|
|
658
|
-
for (const groupId of product.pricing.modifier_group_ids) {
|
|
659
|
-
const group = lookup.modifierGroupsById[groupId];
|
|
660
|
-
if (!group) continue;
|
|
661
|
-
for (const modifierId of group.modifier_ids) {
|
|
662
|
-
const modifierProduct = lookup.productsById[modifierId];
|
|
663
|
-
if (!modifierProduct) continue;
|
|
664
|
-
const modifierPath = `${path}/group/${groupId}/modifier/${modifierId}`;
|
|
665
|
-
if (quantities[modifierPath] !== void 0) setQuantity(store, modifierPath, quantities[modifierPath]);
|
|
666
|
-
if (getQuantity(store, modifierPath) > 0) {
|
|
667
|
-
const args1 = [
|
|
668
|
-
store,
|
|
669
|
-
lookup,
|
|
670
|
-
modifierProduct
|
|
671
|
-
];
|
|
672
|
-
const args2 = [modifierPath, quantities];
|
|
673
|
-
applyInitialQuantities(...args1, ...args2);
|
|
674
|
-
}
|
|
675
|
-
}
|
|
929
|
+
group.selectedQuantity = selectedQuantity;
|
|
676
930
|
}
|
|
677
931
|
};
|
|
678
932
|
//#endregion
|
|
@@ -694,6 +948,7 @@ const validateModifier = (modifier, options) => {
|
|
|
694
948
|
if (nestedStatus === "INVALID") status = "INVALID";
|
|
695
949
|
else if (status === "VALID" && nestedStatus !== "VALID") status = "INCOMPLETE";
|
|
696
950
|
}
|
|
951
|
+
if (quantity > 0 && status === "VALID" && !modifier.resolved) status = "INCOMPLETE";
|
|
697
952
|
return finalize(status, soft);
|
|
698
953
|
};
|
|
699
954
|
const validateGroup = (group, options) => {
|
|
@@ -749,26 +1004,92 @@ const validateProduct = (product, options) => {
|
|
|
749
1004
|
product.status = "VALID";
|
|
750
1005
|
return product.status;
|
|
751
1006
|
};
|
|
1007
|
+
const validateRoot = (runtime, options) => {
|
|
1008
|
+
refreshProductTree(runtime.root, runtime);
|
|
1009
|
+
const status = validateProduct(runtime.root, options);
|
|
1010
|
+
if (status === "IDLE" || !runtime.pendingVariants?.()) return status;
|
|
1011
|
+
runtime.root.status = "INCOMPLETE";
|
|
1012
|
+
return runtime.root.status;
|
|
1013
|
+
};
|
|
752
1014
|
const runAfterChange = (runtime) => {
|
|
753
|
-
|
|
754
|
-
validateProduct(runtime.root, { soft: true });
|
|
1015
|
+
validateRoot(runtime, { soft: true });
|
|
755
1016
|
runtime.listeners.forEach((listener) => listener());
|
|
756
1017
|
};
|
|
757
1018
|
//#endregion
|
|
758
1019
|
//#region src/productCustomization/createCtx.ts
|
|
759
|
-
const
|
|
1020
|
+
const retargetModifierProduct = (runtime, ctx, product) => {
|
|
1021
|
+
const { store, lookup } = runtime;
|
|
1022
|
+
const prefix = `${ctx.path}/group/`;
|
|
1023
|
+
const carried = {};
|
|
1024
|
+
for (const [key, value] of Object.entries(store.quantities)) {
|
|
1025
|
+
if (!key.startsWith(prefix)) continue;
|
|
1026
|
+
carried[key] = value;
|
|
1027
|
+
delete store.quantities[key];
|
|
1028
|
+
}
|
|
1029
|
+
const args = [
|
|
1030
|
+
store,
|
|
1031
|
+
lookup,
|
|
1032
|
+
product,
|
|
1033
|
+
ctx.path
|
|
1034
|
+
];
|
|
1035
|
+
if (Object.keys(carried).length > 0) applyNestedInitial(...args, carried);
|
|
1036
|
+
else if (getQuantity(store, ctx.path) > 0) applyNestedDefaults(...args);
|
|
1037
|
+
assignProduct(runtime, ctx, product);
|
|
1038
|
+
};
|
|
1039
|
+
const attachModifierVariants = (runtime, modifier) => {
|
|
1040
|
+
const { store, lookup } = runtime;
|
|
1041
|
+
const base = modifier.data;
|
|
1042
|
+
const path = modifier.path;
|
|
1043
|
+
const { groups, candidates } = variantIndexFor(lookup, base);
|
|
1044
|
+
let lastKey = null;
|
|
1045
|
+
const selectHandler = (groupId, optionId) => {
|
|
1046
|
+
const current = store.selections[path] ?? {};
|
|
1047
|
+
store.selections[path] = {
|
|
1048
|
+
...current,
|
|
1049
|
+
[groupId]: optionId
|
|
1050
|
+
};
|
|
1051
|
+
if (getQuantity(store, path) > 0) {
|
|
1052
|
+
runAfterChange(runtime);
|
|
1053
|
+
return;
|
|
1054
|
+
}
|
|
1055
|
+
modifier.onChange(Math.max(1, modifier.min));
|
|
1056
|
+
};
|
|
1057
|
+
const sync = () => {
|
|
1058
|
+
if (JSON.stringify(store.selections[path] ?? {}) === lastKey) return;
|
|
1059
|
+
const target = customizedProduct(store, lookup, base, path);
|
|
1060
|
+
const selection = store.selections[path] ?? {};
|
|
1061
|
+
lastKey = JSON.stringify(selection);
|
|
1062
|
+
modifier.resolved = target === base ? null : target;
|
|
1063
|
+
if (!modifier.product) modifier.product = createProductCtx(runtime, target, path, false);
|
|
1064
|
+
else if (modifier.product.data.id !== target.id) retargetModifierProduct(runtime, modifier.product, target);
|
|
1065
|
+
modifier.variants = buildVariantGroupCtxs(...[
|
|
1066
|
+
groups,
|
|
1067
|
+
candidates,
|
|
1068
|
+
selection,
|
|
1069
|
+
selectHandler,
|
|
1070
|
+
path
|
|
1071
|
+
]);
|
|
1072
|
+
};
|
|
1073
|
+
runtime.variantSyncs.set(modifier, sync);
|
|
1074
|
+
sync();
|
|
1075
|
+
};
|
|
1076
|
+
const createModifierCtx = (runtime, group, modifierProduct, parentPath) => {
|
|
1077
|
+
const modifierPath = `${group.path}/modifier/${modifierProduct.id}`;
|
|
760
1078
|
const pricingModifier = resolvePricingModifier(...[modifierProduct, group.data.id]);
|
|
761
1079
|
const type = inferControlType(pricingModifier, group.data);
|
|
1080
|
+
const min = pricingModifier?.minimum_quantity ?? 0;
|
|
762
1081
|
const modifier = {
|
|
763
1082
|
path: modifierPath,
|
|
764
|
-
min
|
|
1083
|
+
min,
|
|
765
1084
|
max: pricingModifier?.maximum_quantity ?? group.data.maximum_quantity,
|
|
766
|
-
quantity:
|
|
767
|
-
required:
|
|
1085
|
+
quantity: getQuantity(runtime.store, modifierPath),
|
|
1086
|
+
required: min > 0,
|
|
768
1087
|
status: "IDLE",
|
|
769
1088
|
data: modifierProduct,
|
|
770
1089
|
type,
|
|
771
1090
|
product: null,
|
|
1091
|
+
variants: [],
|
|
1092
|
+
resolved: modifierProduct,
|
|
772
1093
|
linePrice: 0,
|
|
773
1094
|
onChange: () => {}
|
|
774
1095
|
};
|
|
@@ -790,7 +1111,9 @@ const createModifierCtx = (runtime, productCtx, group, modifierProduct, modifier
|
|
|
790
1111
|
} else setQuantity(runtime.store, modifierPath, next);
|
|
791
1112
|
runAfterChange(runtime);
|
|
792
1113
|
};
|
|
793
|
-
if (modifierProduct.
|
|
1114
|
+
if (pathVisits(parentPath, modifierProduct.id)) return modifier;
|
|
1115
|
+
if (isVariantBase(modifierProduct)) attachModifierVariants(runtime, modifier);
|
|
1116
|
+
else if (modifierProduct.pricing.modifier_group_ids.length > 0) modifier.product = createProductCtx(...[
|
|
794
1117
|
runtime,
|
|
795
1118
|
modifierProduct,
|
|
796
1119
|
modifierPath,
|
|
@@ -798,10 +1121,9 @@ const createModifierCtx = (runtime, productCtx, group, modifierProduct, modifier
|
|
|
798
1121
|
]);
|
|
799
1122
|
return modifier;
|
|
800
1123
|
};
|
|
801
|
-
const createModifierGroupCtx = (runtime,
|
|
802
|
-
const groupPath = `${productPath}/group/${group.id}`;
|
|
1124
|
+
const createModifierGroupCtx = (runtime, group, productPath) => {
|
|
803
1125
|
const groupCtx = {
|
|
804
|
-
path:
|
|
1126
|
+
path: `${productPath}/group/${group.id}`,
|
|
805
1127
|
min: group.minimum_quantity,
|
|
806
1128
|
max: group.maximum_quantity,
|
|
807
1129
|
required: group.minimum_quantity > 0,
|
|
@@ -814,25 +1136,36 @@ const createModifierGroupCtx = (runtime, productCtx, group, productPath) => {
|
|
|
814
1136
|
for (const modifierId of group.modifier_ids) {
|
|
815
1137
|
const modifierProduct = runtime.lookup.productsById[modifierId];
|
|
816
1138
|
if (!modifierProduct) continue;
|
|
817
|
-
const
|
|
818
|
-
const args1 = [
|
|
1139
|
+
const args = [
|
|
819
1140
|
runtime,
|
|
820
|
-
|
|
821
|
-
|
|
1141
|
+
groupCtx,
|
|
1142
|
+
modifierProduct,
|
|
1143
|
+
productPath
|
|
822
1144
|
];
|
|
823
|
-
|
|
824
|
-
const ctx = createModifierCtx(...args1, ...args2);
|
|
825
|
-
groupCtx.modifiers.push(ctx);
|
|
1145
|
+
groupCtx.modifiers.push(createModifierCtx(...args));
|
|
826
1146
|
}
|
|
827
1147
|
return groupCtx;
|
|
828
1148
|
};
|
|
1149
|
+
const assignProduct = (runtime, productCtx, product) => {
|
|
1150
|
+
productCtx.data = product;
|
|
1151
|
+
productCtx.min = product.pricing.minimum_quantity;
|
|
1152
|
+
productCtx.max = product.pricing.maximum_quantity;
|
|
1153
|
+
productCtx.required = product.pricing.minimum_quantity > 0;
|
|
1154
|
+
const groups = [];
|
|
1155
|
+
for (const groupId of product.pricing.modifier_group_ids) {
|
|
1156
|
+
const group = runtime.lookup.modifierGroupsById[groupId];
|
|
1157
|
+
if (!group) continue;
|
|
1158
|
+
groups.push(createModifierGroupCtx(runtime, group, productCtx.path));
|
|
1159
|
+
}
|
|
1160
|
+
productCtx.groups = groups;
|
|
1161
|
+
};
|
|
829
1162
|
const createProductCtx = (runtime, product, path, isRoot) => {
|
|
830
1163
|
const productCtx = {
|
|
831
1164
|
path,
|
|
832
|
-
min:
|
|
833
|
-
max:
|
|
834
|
-
quantity:
|
|
835
|
-
required:
|
|
1165
|
+
min: 0,
|
|
1166
|
+
max: 0,
|
|
1167
|
+
quantity: getQuantity(runtime.store, path),
|
|
1168
|
+
required: false,
|
|
836
1169
|
status: "IDLE",
|
|
837
1170
|
data: product,
|
|
838
1171
|
groups: [],
|
|
@@ -840,28 +1173,23 @@ const createProductCtx = (runtime, product, path, isRoot) => {
|
|
|
840
1173
|
validate: () => "IDLE",
|
|
841
1174
|
reset: () => {}
|
|
842
1175
|
};
|
|
843
|
-
productCtx
|
|
844
|
-
const group = runtime.lookup.modifierGroupsById[groupId];
|
|
845
|
-
if (!group) return null;
|
|
846
|
-
return createModifierGroupCtx(runtime, productCtx, group, path);
|
|
847
|
-
}).filter((entry) => entry !== null);
|
|
1176
|
+
assignProduct(runtime, productCtx, product);
|
|
848
1177
|
productCtx.onChange = (quantity) => {
|
|
849
1178
|
const next = clampProductQuantity(quantity, productCtx.min, productCtx.max);
|
|
850
1179
|
if (next <= 0) clearSubtree(runtime.store, path);
|
|
851
1180
|
setQuantity(runtime.store, path, next);
|
|
852
1181
|
runAfterChange(runtime);
|
|
853
1182
|
};
|
|
854
|
-
productCtx.validate = (options) =>
|
|
855
|
-
refreshProductTree(runtime.root, runtime.store);
|
|
856
|
-
return validateProduct(runtime.root, options);
|
|
857
|
-
};
|
|
1183
|
+
productCtx.validate = (options) => validateRoot(runtime, options);
|
|
858
1184
|
productCtx.reset = (options) => {
|
|
859
|
-
|
|
1185
|
+
const { store, lookup } = runtime;
|
|
1186
|
+
clearSubtree(store, path);
|
|
860
1187
|
if (options?.force) {
|
|
861
|
-
if (isRoot) applyCatalogDefaults(
|
|
1188
|
+
if (isRoot) applyCatalogDefaults(store, lookup, productCtx.data, path);
|
|
862
1189
|
} else {
|
|
863
|
-
|
|
864
|
-
|
|
1190
|
+
Object.assign(store.quantities, pickSubtree(store.initialQuantities, path));
|
|
1191
|
+
for (const [key, value] of Object.entries(pickSubtree(store.initialSelections, path))) store.selections[key] = { ...value };
|
|
1192
|
+
if (isRoot && getQuantity(store, path) === 0) setQuantity(store, path, productCtx.data.pricing.minimum_quantity);
|
|
865
1193
|
}
|
|
866
1194
|
runAfterChange(runtime);
|
|
867
1195
|
};
|
|
@@ -869,55 +1197,146 @@ const createProductCtx = (runtime, product, path, isRoot) => {
|
|
|
869
1197
|
};
|
|
870
1198
|
//#endregion
|
|
871
1199
|
//#region src/productCustomization/createSession.ts
|
|
1200
|
+
const reroot = (entries, from, to) => {
|
|
1201
|
+
const next = {};
|
|
1202
|
+
for (const [key, value] of Object.entries(entries)) {
|
|
1203
|
+
if (key === from) next[to] = value;
|
|
1204
|
+
if (key.startsWith(`${from}/`)) next[`${to}${key.slice(from.length)}`] = value;
|
|
1205
|
+
}
|
|
1206
|
+
return next;
|
|
1207
|
+
};
|
|
872
1208
|
const createSession = (lookup, productId, options) => {
|
|
873
1209
|
const product = lookup.productsById[productId];
|
|
874
1210
|
if (!product) throw new Error(`Product "${productId}" was not found in menu.`);
|
|
875
|
-
const
|
|
876
|
-
const
|
|
877
|
-
|
|
878
|
-
initialQuantities: {}
|
|
879
|
-
};
|
|
1211
|
+
const store = createStore();
|
|
1212
|
+
const snapshotVariants = options?.snapshot?.variants;
|
|
1213
|
+
if (snapshotVariants) store.selections = cloneSelections(snapshotVariants);
|
|
880
1214
|
const seed = options?.snapshot?.quantities ?? options?.initial ?? null;
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
1215
|
+
const seedArgs = [
|
|
1216
|
+
store,
|
|
1217
|
+
lookup,
|
|
1218
|
+
product,
|
|
1219
|
+
`/${product.id}`
|
|
1220
|
+
];
|
|
1221
|
+
if (seed) applyInitialQuantities(...seedArgs, seed);
|
|
1222
|
+
else applyCatalogDefaults(...seedArgs);
|
|
888
1223
|
const listeners = /* @__PURE__ */ new Set();
|
|
889
1224
|
const runtime = {
|
|
890
1225
|
lookup,
|
|
891
1226
|
store,
|
|
892
1227
|
listeners,
|
|
1228
|
+
variantSyncs: /* @__PURE__ */ new WeakMap(),
|
|
893
1229
|
root: null
|
|
894
1230
|
};
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
1231
|
+
const variantBase = resolveVariantBase(lookup, product);
|
|
1232
|
+
const { groups, candidates } = variantBase ? variantIndexFor(lookup, variantBase.base) : {
|
|
1233
|
+
groups: [],
|
|
1234
|
+
candidates: []
|
|
1235
|
+
};
|
|
1236
|
+
let selection = {};
|
|
1237
|
+
let variantCtxs = [];
|
|
1238
|
+
const moveStoreRoot = (from, next) => {
|
|
1239
|
+
const to = `/${next.id}`;
|
|
1240
|
+
const carried = reroot(store.quantities, from, to);
|
|
1241
|
+
const quantity = getQuantity(store, from);
|
|
1242
|
+
store.quantities = {};
|
|
1243
|
+
store.selections = reroot(store.selections, from, to);
|
|
1244
|
+
const hasModifiers = Object.keys(carried).length > 1;
|
|
1245
|
+
const args = [
|
|
1246
|
+
store,
|
|
1247
|
+
lookup,
|
|
1248
|
+
next,
|
|
1249
|
+
to
|
|
1250
|
+
];
|
|
1251
|
+
if (hasModifiers) applyInitialQuantities(...args, carried);
|
|
1252
|
+
else applyCatalogDefaults(...args);
|
|
1253
|
+
const { minimum_quantity: min, maximum_quantity: max } = next.pricing;
|
|
1254
|
+
setQuantity(store, to, clampProductQuantity(quantity, min, max));
|
|
1255
|
+
};
|
|
1256
|
+
const resolveRoot = (nextSelection, current) => {
|
|
1257
|
+
if (!variantBase) return current;
|
|
1258
|
+
selection = normalizeSelection(groups, candidates, nextSelection);
|
|
1259
|
+
const next = findVariantProduct(groups, candidates, selection) ?? variantBase.base;
|
|
1260
|
+
if (next.id !== current.id) moveStoreRoot(`/${current.id}`, next);
|
|
1261
|
+
return next;
|
|
1262
|
+
};
|
|
1263
|
+
const rebuildVariantCtxs = () => {
|
|
1264
|
+
if (!variantBase) return;
|
|
1265
|
+
variantCtxs = buildVariantGroupCtxs(...[
|
|
1266
|
+
groups,
|
|
1267
|
+
candidates,
|
|
1268
|
+
selection
|
|
1269
|
+
], selectHandler);
|
|
1270
|
+
};
|
|
1271
|
+
const applySelection = (nextSelection) => {
|
|
1272
|
+
const current = runtime.root.data;
|
|
1273
|
+
const next = resolveRoot(nextSelection, current);
|
|
1274
|
+
if (next.id !== current.id) runtime.root = createProductCtx(runtime, next, `/${next.id}`, true);
|
|
1275
|
+
rebuildVariantCtxs();
|
|
1276
|
+
};
|
|
1277
|
+
const selectHandler = (groupId, optionId) => {
|
|
1278
|
+
applySelection({
|
|
1279
|
+
...selection,
|
|
1280
|
+
[groupId]: optionId
|
|
1281
|
+
});
|
|
1282
|
+
runAfterChange(runtime);
|
|
1283
|
+
};
|
|
1284
|
+
runtime.pendingVariants = () => {
|
|
1285
|
+
return variantBase !== null && runtime.root.data.id === variantBase.base.id;
|
|
1286
|
+
};
|
|
1287
|
+
const rootProduct = resolveRoot(variantBase?.selection ?? {}, product);
|
|
1288
|
+
runtime.root = createProductCtx(runtime, rootProduct, `/${rootProduct.id}`, true);
|
|
1289
|
+
rebuildVariantCtxs();
|
|
1290
|
+
const initialSelection = { ...selection };
|
|
1291
|
+
store.initialQuantities = cloneQuantities(store.quantities);
|
|
1292
|
+
store.initialSelections = cloneSelections(store.selections);
|
|
1293
|
+
validateRoot(runtime);
|
|
1294
|
+
const activeSelections = () => {
|
|
1295
|
+
const variants = {};
|
|
1296
|
+
for (const [path, entry] of Object.entries(store.selections)) if (getQuantity(store, path) > 0) variants[path] = { ...entry };
|
|
1297
|
+
return variants;
|
|
1298
|
+
};
|
|
898
1299
|
return {
|
|
899
|
-
root
|
|
1300
|
+
get root() {
|
|
1301
|
+
return runtime.root;
|
|
1302
|
+
},
|
|
1303
|
+
get base() {
|
|
1304
|
+
return variantBase?.base ?? null;
|
|
1305
|
+
},
|
|
1306
|
+
get variants() {
|
|
1307
|
+
return variantCtxs;
|
|
1308
|
+
},
|
|
900
1309
|
subscribe: (listener) => {
|
|
901
1310
|
listeners.add(listener);
|
|
902
1311
|
return () => listeners.delete(listener);
|
|
903
1312
|
},
|
|
904
1313
|
snapshot: () => {
|
|
1314
|
+
const productId = runtime.root.data.id;
|
|
1315
|
+
const quantities = cloneQuantities(store.quantities);
|
|
1316
|
+
const variants = activeSelections();
|
|
1317
|
+
if (Object.keys(variants).length === 0) return {
|
|
1318
|
+
productId,
|
|
1319
|
+
quantities
|
|
1320
|
+
};
|
|
905
1321
|
return {
|
|
906
1322
|
productId,
|
|
907
|
-
quantities
|
|
1323
|
+
quantities,
|
|
1324
|
+
variants
|
|
908
1325
|
};
|
|
909
1326
|
},
|
|
910
1327
|
totalPrice: () => {
|
|
911
|
-
refreshProductTree(runtime.root, runtime
|
|
1328
|
+
refreshProductTree(runtime.root, runtime);
|
|
912
1329
|
return computeTotalPrice(runtime.root);
|
|
913
1330
|
},
|
|
914
|
-
validate: (
|
|
915
|
-
|
|
916
|
-
const status = validateProduct(runtime.root, options);
|
|
1331
|
+
validate: (validateOptions) => {
|
|
1332
|
+
const status = validateRoot(runtime, validateOptions);
|
|
917
1333
|
runtime.listeners.forEach((listener) => listener());
|
|
918
1334
|
return status;
|
|
919
1335
|
},
|
|
920
|
-
reset: (
|
|
1336
|
+
reset: (resetOptions) => {
|
|
1337
|
+
applySelection(initialSelection);
|
|
1338
|
+
runtime.root.reset(resetOptions);
|
|
1339
|
+
}
|
|
921
1340
|
};
|
|
922
1341
|
};
|
|
923
1342
|
//#endregion
|
|
@@ -988,33 +1407,74 @@ const flattenMenuCategories = (categories, categoriesById, options) => {
|
|
|
988
1407
|
};
|
|
989
1408
|
//#endregion
|
|
990
1409
|
//#region src/productCustomization/fromOrderLines.ts
|
|
1410
|
+
/** Group slot of a child line: listed directly, or as the variant of a base
|
|
1411
|
+
* listed in one of the parent's modifier groups. */
|
|
1412
|
+
const matchModifierLine = (lookup, parent, childId) => {
|
|
1413
|
+
const groups = parent.pricing.modifier_group_ids.flatMap((groupId) => {
|
|
1414
|
+
const group = lookup.modifierGroupsById[groupId];
|
|
1415
|
+
return group ? [group] : [];
|
|
1416
|
+
});
|
|
1417
|
+
for (const group of groups) {
|
|
1418
|
+
if (!group.modifier_ids.includes(childId)) continue;
|
|
1419
|
+
return {
|
|
1420
|
+
groupId: group.id,
|
|
1421
|
+
modifierId: childId,
|
|
1422
|
+
selection: null
|
|
1423
|
+
};
|
|
1424
|
+
}
|
|
1425
|
+
const child = lookup.productsById[childId];
|
|
1426
|
+
for (const entry of child?.pricing.variants ?? []) {
|
|
1427
|
+
const base = lookup.productsById[entry.product_id];
|
|
1428
|
+
if (!child || !base) continue;
|
|
1429
|
+
const group = groups.find((g) => g.modifier_ids.includes(base.id));
|
|
1430
|
+
if (!group) continue;
|
|
1431
|
+
const selection = selectionForVariant(lookup, base, child);
|
|
1432
|
+
if (!selection) continue;
|
|
1433
|
+
return {
|
|
1434
|
+
groupId: group.id,
|
|
1435
|
+
modifierId: base.id,
|
|
1436
|
+
selection
|
|
1437
|
+
};
|
|
1438
|
+
}
|
|
1439
|
+
return null;
|
|
1440
|
+
};
|
|
991
1441
|
/**
|
|
992
1442
|
* Inverse of {@link toOrderLines}: rebuild a {@link Snapshot} from a root cart
|
|
993
1443
|
* line so {@link createSession} can reopen it via `{ snapshot }`.
|
|
994
1444
|
*/
|
|
995
1445
|
const fromOrderLines = (root, lines, lookup) => {
|
|
996
1446
|
const quantities = { [`/${root.id}`]: root.quantity };
|
|
997
|
-
const
|
|
998
|
-
|
|
1447
|
+
const variants = {};
|
|
1448
|
+
const childrenByParent = /* @__PURE__ */ new Map();
|
|
1449
|
+
for (const line of lines) {
|
|
1450
|
+
if (line.parent_uid == null) continue;
|
|
1451
|
+
const siblings = childrenByParent.get(line.parent_uid) ?? [];
|
|
1452
|
+
siblings.push(line);
|
|
1453
|
+
childrenByParent.set(line.parent_uid, siblings);
|
|
1454
|
+
}
|
|
1455
|
+
const walk = (parentLine, pathPrefix) => {
|
|
1456
|
+
const parentProduct = lookup.productsById[parentLine.id];
|
|
999
1457
|
if (!parentProduct) return;
|
|
1000
|
-
for (const child of
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
if (!matchedPath) continue;
|
|
1009
|
-
quantities[matchedPath] = child.quantity;
|
|
1010
|
-
walk(child.id, child.uid, matchedPath);
|
|
1458
|
+
for (const child of childrenByParent.get(parentLine.uid) ?? []) {
|
|
1459
|
+
const match = matchModifierLine(lookup, parentProduct, child.id);
|
|
1460
|
+
if (!match) continue;
|
|
1461
|
+
const { groupId, modifierId, selection } = match;
|
|
1462
|
+
const path = `${pathPrefix}/group/${groupId}/modifier/${modifierId}`;
|
|
1463
|
+
quantities[path] = child.quantity;
|
|
1464
|
+
if (selection) variants[path] = selection;
|
|
1465
|
+
walk(child, path);
|
|
1011
1466
|
}
|
|
1012
1467
|
};
|
|
1013
|
-
walk(root
|
|
1014
|
-
return {
|
|
1468
|
+
walk(root, `/${root.id}`);
|
|
1469
|
+
if (Object.keys(variants).length === 0) return {
|
|
1015
1470
|
productId: root.id,
|
|
1016
1471
|
quantities
|
|
1017
1472
|
};
|
|
1473
|
+
return {
|
|
1474
|
+
productId: root.id,
|
|
1475
|
+
quantities,
|
|
1476
|
+
variants
|
|
1477
|
+
};
|
|
1018
1478
|
};
|
|
1019
1479
|
//#endregion
|
|
1020
1480
|
//#region src/productCustomization/menu.ts
|
|
@@ -1024,6 +1484,8 @@ const indexMenu = (menu) => {
|
|
|
1024
1484
|
const modifierGroupsById = {};
|
|
1025
1485
|
const modifierGroups = resolveModifierGroupsForLookup(...[menu.products, menu.modifier_groups]);
|
|
1026
1486
|
for (const group of modifierGroups) modifierGroupsById[group.id] = group;
|
|
1487
|
+
const variantGroupsById = {};
|
|
1488
|
+
for (const group of menu.variant_groups ?? []) variantGroupsById[group.id] = group;
|
|
1027
1489
|
const categoriesById = {};
|
|
1028
1490
|
for (const category of menu.categories) categoriesById[category.id] = category;
|
|
1029
1491
|
const recommendationsById = {};
|
|
@@ -1031,11 +1493,139 @@ const indexMenu = (menu) => {
|
|
|
1031
1493
|
return {
|
|
1032
1494
|
productsById,
|
|
1033
1495
|
modifierGroupsById,
|
|
1496
|
+
variantGroupsById,
|
|
1034
1497
|
categoriesById,
|
|
1035
1498
|
recommendationsById
|
|
1036
1499
|
};
|
|
1037
1500
|
};
|
|
1038
1501
|
//#endregion
|
|
1502
|
+
//#region src/productCustomization/startingPrice.ts
|
|
1503
|
+
const marginalCost = (candidate) => {
|
|
1504
|
+
const { quantity, chargeAbove, unit, nested } = candidate;
|
|
1505
|
+
return quantity >= chargeAbove ? unit.plus(nested) : nested;
|
|
1506
|
+
};
|
|
1507
|
+
const pickCheapest = (candidates) => {
|
|
1508
|
+
let best = null;
|
|
1509
|
+
for (const candidate of candidates) {
|
|
1510
|
+
if (candidate.quantity >= candidate.max) continue;
|
|
1511
|
+
if (!best || marginalCost(candidate).lt(marginalCost(best))) best = candidate;
|
|
1512
|
+
}
|
|
1513
|
+
return best;
|
|
1514
|
+
};
|
|
1515
|
+
const groupCost = (group, candidates) => {
|
|
1516
|
+
const groupChargeAbove = group.charge_above_quantity ?? 0;
|
|
1517
|
+
let chargedCount = 0;
|
|
1518
|
+
let total = new Big(0);
|
|
1519
|
+
for (const candidate of candidates) {
|
|
1520
|
+
if (candidate.quantity <= 0) continue;
|
|
1521
|
+
const billable = billableQuantity(...[candidate.quantity, candidate.chargeAbove]);
|
|
1522
|
+
const freeLeft = Math.max(0, groupChargeAbove - chargedCount);
|
|
1523
|
+
const charged = billable - Math.min(billable, freeLeft);
|
|
1524
|
+
chargedCount += billable;
|
|
1525
|
+
total = total.plus(candidate.unit.times(charged));
|
|
1526
|
+
total = total.plus(candidate.nested.times(candidate.quantity));
|
|
1527
|
+
}
|
|
1528
|
+
return total;
|
|
1529
|
+
};
|
|
1530
|
+
const cheapestModifierUnit = (lookup, groupId, listed, walk) => {
|
|
1531
|
+
const { state, visiting } = walk;
|
|
1532
|
+
const options = isVariantBase(listed) ? variantCandidates(lookup, listed).map((c) => c.product) : [listed];
|
|
1533
|
+
let best = null;
|
|
1534
|
+
for (const ordered of options) {
|
|
1535
|
+
if (ordered !== listed && visiting.has(ordered.id)) {
|
|
1536
|
+
state.cycleHits += 1;
|
|
1537
|
+
continue;
|
|
1538
|
+
}
|
|
1539
|
+
const unit = new Big(modifierUnitPricing(...[
|
|
1540
|
+
listed,
|
|
1541
|
+
ordered,
|
|
1542
|
+
groupId
|
|
1543
|
+
]).price);
|
|
1544
|
+
const nested = nestedModifiersCost(lookup, ordered, state, visiting);
|
|
1545
|
+
if (best && unit.plus(nested).gte(best.unit.plus(best.nested))) continue;
|
|
1546
|
+
best = {
|
|
1547
|
+
unit,
|
|
1548
|
+
nested
|
|
1549
|
+
};
|
|
1550
|
+
}
|
|
1551
|
+
return best;
|
|
1552
|
+
};
|
|
1553
|
+
const cheapestGroupCost = (lookup, group, state, visiting) => {
|
|
1554
|
+
const candidates = [];
|
|
1555
|
+
for (const modifierId of group.modifier_ids) {
|
|
1556
|
+
const modifierProduct = lookup.productsById[modifierId];
|
|
1557
|
+
if (!modifierProduct) continue;
|
|
1558
|
+
if (visiting.has(modifierId)) {
|
|
1559
|
+
state.cycleHits += 1;
|
|
1560
|
+
continue;
|
|
1561
|
+
}
|
|
1562
|
+
const pricingModifier = resolvePricingModifier(modifierProduct, group.id);
|
|
1563
|
+
const cost = cheapestModifierUnit(lookup, group.id, modifierProduct, {
|
|
1564
|
+
state,
|
|
1565
|
+
visiting
|
|
1566
|
+
});
|
|
1567
|
+
if (!cost) continue;
|
|
1568
|
+
const quantity = pricingModifier?.minimum_quantity ?? 0;
|
|
1569
|
+
const max = pricingModifier?.maximum_quantity ?? group.maximum_quantity;
|
|
1570
|
+
const chargeAbove = pricingModifier?.charge_above_quantity ?? 0;
|
|
1571
|
+
candidates.push({
|
|
1572
|
+
quantity,
|
|
1573
|
+
max,
|
|
1574
|
+
...cost,
|
|
1575
|
+
chargeAbove
|
|
1576
|
+
});
|
|
1577
|
+
}
|
|
1578
|
+
const selected = candidates.reduce((sum, c) => sum + c.quantity, 0);
|
|
1579
|
+
let remaining = group.minimum_quantity - selected;
|
|
1580
|
+
while (remaining > 0) {
|
|
1581
|
+
const cheapest = pickCheapest(candidates);
|
|
1582
|
+
if (!cheapest) break;
|
|
1583
|
+
const { quantity, chargeAbove, max } = cheapest;
|
|
1584
|
+
const step = Math.min(remaining, (quantity < chargeAbove ? Math.min(chargeAbove, max) : max) - quantity);
|
|
1585
|
+
cheapest.quantity += step;
|
|
1586
|
+
remaining -= step;
|
|
1587
|
+
}
|
|
1588
|
+
return groupCost(group, candidates);
|
|
1589
|
+
};
|
|
1590
|
+
const nestedModifiersCost = (lookup, product, state, visiting) => {
|
|
1591
|
+
const cached = state.cache.get(product.id);
|
|
1592
|
+
if (cached) return cached;
|
|
1593
|
+
const cycleHitsBefore = state.cycleHits;
|
|
1594
|
+
const nextVisiting = new Set(visiting).add(product.id);
|
|
1595
|
+
let total = new Big(0);
|
|
1596
|
+
for (const groupId of product.pricing.modifier_group_ids) {
|
|
1597
|
+
const group = lookup.modifierGroupsById[groupId];
|
|
1598
|
+
if (!group) continue;
|
|
1599
|
+
const args = [
|
|
1600
|
+
lookup,
|
|
1601
|
+
group,
|
|
1602
|
+
state,
|
|
1603
|
+
nextVisiting
|
|
1604
|
+
];
|
|
1605
|
+
total = total.plus(cheapestGroupCost(...args));
|
|
1606
|
+
}
|
|
1607
|
+
if (state.cycleHits === cycleHitsBefore) state.cache.set(product.id, total);
|
|
1608
|
+
return total;
|
|
1609
|
+
};
|
|
1610
|
+
/** Lowest unit price reachable while satisfying every required modifier.
|
|
1611
|
+
* For a variant base, the cheapest of its variant products. */
|
|
1612
|
+
const computeStartingPrice = (lookup, product, cache = /* @__PURE__ */ new Map()) => {
|
|
1613
|
+
if (isVariantBase(product)) {
|
|
1614
|
+
const prices = variantCandidates(lookup, product).map((c) => computeStartingPrice(lookup, c.product, cache));
|
|
1615
|
+
if (prices.length > 0) return Math.min(...prices);
|
|
1616
|
+
}
|
|
1617
|
+
const modifiers = nestedModifiersCost(...[
|
|
1618
|
+
lookup,
|
|
1619
|
+
product,
|
|
1620
|
+
{
|
|
1621
|
+
cache,
|
|
1622
|
+
cycleHits: 0
|
|
1623
|
+
},
|
|
1624
|
+
/* @__PURE__ */ new Set()
|
|
1625
|
+
]);
|
|
1626
|
+
return new Big(product.pricing.price).plus(modifiers).toNumber();
|
|
1627
|
+
};
|
|
1628
|
+
//#endregion
|
|
1039
1629
|
//#region src/shared/randomUUID.ts
|
|
1040
1630
|
/** UUID v4 — works when `crypto.randomUUID` is missing (React Native / Hermes). */
|
|
1041
1631
|
const randomUUID = () => {
|
|
@@ -1209,62 +1799,44 @@ const flatten = (node, out) => {
|
|
|
1209
1799
|
out.push(line);
|
|
1210
1800
|
for (const child of children) flatten(child, out);
|
|
1211
1801
|
};
|
|
1212
|
-
const
|
|
1213
|
-
for (const modifier of
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
});
|
|
1246
|
-
config.parent.children.push(line);
|
|
1247
|
-
if (modifier.product) for (const group of modifier.product.groups) {
|
|
1248
|
-
const n1 = { modifiers: group.modifiers };
|
|
1249
|
-
const n2 = {
|
|
1250
|
-
parentUid: line.uid,
|
|
1251
|
-
parentId: line.id
|
|
1252
|
-
};
|
|
1253
|
-
const n3 = {
|
|
1254
|
-
parent: line,
|
|
1255
|
-
locale: config.locale
|
|
1256
|
-
};
|
|
1257
|
-
appendModifierLines({
|
|
1258
|
-
...n1,
|
|
1259
|
-
...n2,
|
|
1260
|
-
...n3
|
|
1261
|
-
});
|
|
1262
|
-
}
|
|
1263
|
-
}
|
|
1802
|
+
const appendGroupLines = (groups, parent, locale) => {
|
|
1803
|
+
for (const group of groups) for (const modifier of group.modifiers) appendModifierLine(modifier, group.data.id, parent, locale);
|
|
1804
|
+
};
|
|
1805
|
+
const appendModifierLine = (modifier, groupId, parent, locale) => {
|
|
1806
|
+
const product = modifier.resolved;
|
|
1807
|
+
if (modifier.quantity <= 0 || !product) return;
|
|
1808
|
+
const { taxRate, isTaxInclusive } = modifierUnitPricing(...[
|
|
1809
|
+
modifier.data,
|
|
1810
|
+
product,
|
|
1811
|
+
groupId
|
|
1812
|
+
]);
|
|
1813
|
+
const unitSubtotal = new Big(modifier.linePrice).div(modifier.quantity);
|
|
1814
|
+
const l1 = {
|
|
1815
|
+
product,
|
|
1816
|
+
name: displayName(product.name, locale),
|
|
1817
|
+
quantity: modifier.quantity,
|
|
1818
|
+
unitSubtotal
|
|
1819
|
+
};
|
|
1820
|
+
const l2 = {
|
|
1821
|
+
taxRate,
|
|
1822
|
+
isTaxInclusive
|
|
1823
|
+
};
|
|
1824
|
+
const l3 = {
|
|
1825
|
+
parent_uid: parent.uid,
|
|
1826
|
+
parent_id: parent.id
|
|
1827
|
+
};
|
|
1828
|
+
const line = buildLine({
|
|
1829
|
+
...l1,
|
|
1830
|
+
...l2,
|
|
1831
|
+
...l3
|
|
1832
|
+
});
|
|
1833
|
+
parent.children.push(line);
|
|
1834
|
+
if (modifier.product) appendGroupLines(modifier.product.groups, line, locale);
|
|
1264
1835
|
};
|
|
1265
1836
|
/** Flatten a customized product tree into priced OrderLine rows. */
|
|
1266
1837
|
const toOrderLines = (root, locale) => {
|
|
1267
1838
|
if (root.quantity <= 0) return [];
|
|
1839
|
+
if (isVariantBase(root.data)) return [];
|
|
1268
1840
|
const name = displayName(root.data.name, locale);
|
|
1269
1841
|
const unitSubtotal = new Big(root.data.pricing.price);
|
|
1270
1842
|
const t1 = {
|
|
@@ -1287,22 +1859,7 @@ const toOrderLines = (root, locale) => {
|
|
|
1287
1859
|
...t3,
|
|
1288
1860
|
...t4
|
|
1289
1861
|
});
|
|
1290
|
-
|
|
1291
|
-
const n1 = { modifiers: group.modifiers };
|
|
1292
|
-
const n2 = {
|
|
1293
|
-
parentUid: rootLine.uid,
|
|
1294
|
-
parentId: rootLine.id
|
|
1295
|
-
};
|
|
1296
|
-
const n3 = {
|
|
1297
|
-
parent: rootLine,
|
|
1298
|
-
locale
|
|
1299
|
-
};
|
|
1300
|
-
appendModifierLines({
|
|
1301
|
-
...n1,
|
|
1302
|
-
...n2,
|
|
1303
|
-
...n3
|
|
1304
|
-
});
|
|
1305
|
-
}
|
|
1862
|
+
appendGroupLines(root.groups, rootLine, locale);
|
|
1306
1863
|
finalizeGroupMoney(rootLine);
|
|
1307
1864
|
const lines = [];
|
|
1308
1865
|
flatten(rootLine, lines);
|
|
@@ -1921,8 +2478,8 @@ const listNearbyStores = (input, options) => {
|
|
|
1921
2478
|
};
|
|
1922
2479
|
return storefrontJson(withQuery(storesPath("nearby"), query), {}, options);
|
|
1923
2480
|
};
|
|
1924
|
-
const getStore = (id, options) => {
|
|
1925
|
-
return storefrontJson(storesPath(encodeURIComponent(id)), {}, options);
|
|
2481
|
+
const getStore = (id, input = {}, options) => {
|
|
2482
|
+
return storefrontJson(withQuery(storesPath(encodeURIComponent(id)), { ...input }), {}, options);
|
|
1926
2483
|
};
|
|
1927
2484
|
//#endregion
|
|
1928
2485
|
//#region src/services/index.ts
|
|
@@ -1978,6 +2535,83 @@ const services = {
|
|
|
1978
2535
|
isKioskUnauthorized
|
|
1979
2536
|
}
|
|
1980
2537
|
};
|
|
2538
|
+
const DISALLOWED = /[^A-Za-z0-9]/g;
|
|
2539
|
+
const ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyz";
|
|
2540
|
+
const BYTE_LIMIT = 252;
|
|
2541
|
+
const toLiteral = (value, fallback) => {
|
|
2542
|
+
const trimmed = (value ?? "").trim();
|
|
2543
|
+
const source = trimmed === "" ? fallback : trimmed;
|
|
2544
|
+
if (source === void 0) throw new Error("Id literal must not be empty");
|
|
2545
|
+
const cleaned = source.replace(DISALLOWED, "");
|
|
2546
|
+
if (cleaned === "") throw new Error(`Id literal has no alphanumeric characters: "${source}"`);
|
|
2547
|
+
return cleaned.slice(0, 32);
|
|
2548
|
+
};
|
|
2549
|
+
const randomChars = (length) => {
|
|
2550
|
+
const c = globalThis.crypto;
|
|
2551
|
+
if (typeof c?.getRandomValues !== "function") throw new Error("crypto.getRandomValues is required");
|
|
2552
|
+
let out = "";
|
|
2553
|
+
const bytes = new Uint8Array(length * 2);
|
|
2554
|
+
while (out.length < length) {
|
|
2555
|
+
c.getRandomValues(bytes);
|
|
2556
|
+
for (const byte of bytes) {
|
|
2557
|
+
if (byte >= BYTE_LIMIT) continue;
|
|
2558
|
+
out += ALPHABET[byte % 36];
|
|
2559
|
+
if (out.length === length) break;
|
|
2560
|
+
}
|
|
2561
|
+
}
|
|
2562
|
+
return out;
|
|
2563
|
+
};
|
|
2564
|
+
const assertShape = (parts) => {
|
|
2565
|
+
if (!parts.some((part) => part.kind === "hash")) throw new Error("Id requires a random hash part");
|
|
2566
|
+
const first = parts[0];
|
|
2567
|
+
const last = parts[parts.length - 1];
|
|
2568
|
+
if (first?.kind === "separator" || last?.kind === "separator") throw new Error("Id must not start or end with a separator");
|
|
2569
|
+
parts.forEach((part, index) => {
|
|
2570
|
+
if (part.kind !== "separator") return;
|
|
2571
|
+
if (parts[index - 1]?.kind !== "separator") return;
|
|
2572
|
+
throw new Error("Id must not contain consecutive separators");
|
|
2573
|
+
});
|
|
2574
|
+
};
|
|
2575
|
+
const render = (part) => {
|
|
2576
|
+
if (part.kind === "literal") return part.value;
|
|
2577
|
+
if (part.kind === "separator") return "-";
|
|
2578
|
+
return randomChars(part.length);
|
|
2579
|
+
};
|
|
2580
|
+
const builder = (parts) => {
|
|
2581
|
+
return {
|
|
2582
|
+
literal: (value, fallback) => {
|
|
2583
|
+
const literal = {
|
|
2584
|
+
kind: "literal",
|
|
2585
|
+
value: toLiteral(value, fallback)
|
|
2586
|
+
};
|
|
2587
|
+
return builder([...parts, literal]);
|
|
2588
|
+
},
|
|
2589
|
+
separator: () => builder([...parts, { kind: "separator" }]),
|
|
2590
|
+
hash: (length = 12) => {
|
|
2591
|
+
if (!Number.isInteger(length) || length < 12) throw new Error(`Id hash must be an integer ≥ 12, got ${length}`);
|
|
2592
|
+
return builder([...parts, {
|
|
2593
|
+
kind: "hash",
|
|
2594
|
+
length
|
|
2595
|
+
}]);
|
|
2596
|
+
},
|
|
2597
|
+
build: () => {
|
|
2598
|
+
assertShape(parts);
|
|
2599
|
+
const id = parts.map(render).join("");
|
|
2600
|
+
if (id.length > 64) throw new Error(`Id must be ≤ 64 characters`);
|
|
2601
|
+
return id;
|
|
2602
|
+
}
|
|
2603
|
+
};
|
|
2604
|
+
};
|
|
2605
|
+
/**
|
|
2606
|
+
* Safe id builder: `genId().literal("ord").separator().hash(12).build()`.
|
|
2607
|
+
* Literals are cleaned to `[A-Za-z0-9]`; `-` is the only separator and a
|
|
2608
|
+
* random hash of at least 12 characters is required.
|
|
2609
|
+
*/
|
|
2610
|
+
const genId = () => builder([]);
|
|
2611
|
+
/** `ord-<store>-<hash>`; `nostore` when the store id is blank. */
|
|
2612
|
+
const genOrderId = (storeId) => {
|
|
2613
|
+
return genId().literal("ord").separator().literal(storeId, "nostore").separator().hash().build();
|
|
2614
|
+
};
|
|
1981
2615
|
//#endregion
|
|
1982
2616
|
//#region src/order/retry.ts
|
|
1983
2617
|
const defaultSleep = (ms) => {
|
|
@@ -2037,6 +2671,8 @@ const createFlushController = (runtime) => {
|
|
|
2037
2671
|
const b5 = { customer: order.customer };
|
|
2038
2672
|
const b6 = { payment_intents: order.payment_intents };
|
|
2039
2673
|
const b7 = { payment_status: order.payment_status };
|
|
2674
|
+
const b8 = { redeem: order.redeem };
|
|
2675
|
+
const b9 = { redemption_steps: order.redemption_steps };
|
|
2040
2676
|
return {
|
|
2041
2677
|
...b1,
|
|
2042
2678
|
...b2,
|
|
@@ -2044,7 +2680,9 @@ const createFlushController = (runtime) => {
|
|
|
2044
2680
|
...b4,
|
|
2045
2681
|
...b5,
|
|
2046
2682
|
...b6,
|
|
2047
|
-
...b7
|
|
2683
|
+
...b7,
|
|
2684
|
+
...b8,
|
|
2685
|
+
...b9
|
|
2048
2686
|
};
|
|
2049
2687
|
};
|
|
2050
2688
|
const insertBody = (order) => {
|
|
@@ -2068,6 +2706,8 @@ const createFlushController = (runtime) => {
|
|
|
2068
2706
|
};
|
|
2069
2707
|
const b7 = { payment_intents: order.payment_intents };
|
|
2070
2708
|
const b8 = { payment_status: order.payment_status };
|
|
2709
|
+
const b9 = { redeem: order.redeem };
|
|
2710
|
+
const b10 = { redemption_steps: order.redemption_steps };
|
|
2071
2711
|
return {
|
|
2072
2712
|
...b1,
|
|
2073
2713
|
...b2,
|
|
@@ -2076,7 +2716,9 @@ const createFlushController = (runtime) => {
|
|
|
2076
2716
|
...b5,
|
|
2077
2717
|
...b6,
|
|
2078
2718
|
...b7,
|
|
2079
|
-
...b8
|
|
2719
|
+
...b8,
|
|
2720
|
+
...b9,
|
|
2721
|
+
...b10
|
|
2080
2722
|
};
|
|
2081
2723
|
};
|
|
2082
2724
|
const flushNow = () => {
|
|
@@ -2564,6 +3206,227 @@ const emptyTotals = (currency = "USD") => {
|
|
|
2564
3206
|
};
|
|
2565
3207
|
};
|
|
2566
3208
|
//#endregion
|
|
3209
|
+
//#region src/order/redemptions.ts
|
|
3210
|
+
const ORDER_KEY = "ORDER";
|
|
3211
|
+
/** Same rules as `vOrders.redeem()`; throws on invalid input. */
|
|
3212
|
+
const buildRedeem = (input) => {
|
|
3213
|
+
const { reward_id, quantity, discount_rate, discount_amount } = input;
|
|
3214
|
+
const { expires_at } = input;
|
|
3215
|
+
if (!reward_id.trim()) throw new Error("Redeem requires reward_id");
|
|
3216
|
+
if (!Number.isInteger(quantity) || quantity < 1) throw new Error("Redeem quantity must be an integer ≥ 1");
|
|
3217
|
+
if (discount_rate === void 0 && discount_amount === void 0) throw new Error("Redeem requires discount_rate or discount_amount");
|
|
3218
|
+
const rate = discount_rate ?? 0;
|
|
3219
|
+
if (!(rate >= 0 && rate <= 1)) throw new Error("Redeem discount_rate must be between 0 and 1");
|
|
3220
|
+
if (discount_amount !== void 0 && !(discount_amount > 0)) throw new Error("Redeem discount_amount must be > 0");
|
|
3221
|
+
if (expires_at !== void 0 && Number.isNaN(Date.parse(expires_at))) throw new Error("Redeem expires_at must be an ISO datetime");
|
|
3222
|
+
const redeem = {
|
|
3223
|
+
reward_id,
|
|
3224
|
+
quantity
|
|
3225
|
+
};
|
|
3226
|
+
if (discount_rate !== void 0) redeem.discount_rate = discount_rate;
|
|
3227
|
+
if (discount_amount !== void 0) redeem.discount_amount = discount_amount;
|
|
3228
|
+
if (expires_at !== void 0) redeem.expires_at = expires_at;
|
|
3229
|
+
return redeem;
|
|
3230
|
+
};
|
|
3231
|
+
/**
|
|
3232
|
+
* Takes `quantity` units of `reward_id` off the latest entries first, so the
|
|
3233
|
+
* earlier redeems keep their step position. Entries that reach 0 are removed.
|
|
3234
|
+
*/
|
|
3235
|
+
const subtractRedeem = (items, reward_id, quantity) => {
|
|
3236
|
+
if (!Number.isInteger(quantity) || quantity < 1) throw new Error("Redeem quantity must be an integer ≥ 1");
|
|
3237
|
+
let pending = quantity;
|
|
3238
|
+
const kept = [];
|
|
3239
|
+
for (let i = items.length - 1; i >= 0; i--) {
|
|
3240
|
+
const item = items[i];
|
|
3241
|
+
if (pending === 0 || item.reward_id !== reward_id) {
|
|
3242
|
+
kept.unshift(item);
|
|
3243
|
+
continue;
|
|
3244
|
+
}
|
|
3245
|
+
const taken = Math.min(item.quantity, pending);
|
|
3246
|
+
pending -= taken;
|
|
3247
|
+
const rest = item.quantity - taken;
|
|
3248
|
+
if (rest > 0) kept.unshift({
|
|
3249
|
+
...item,
|
|
3250
|
+
quantity: rest
|
|
3251
|
+
});
|
|
3252
|
+
}
|
|
3253
|
+
return kept;
|
|
3254
|
+
};
|
|
3255
|
+
const redemptionTargetKey = (target) => {
|
|
3256
|
+
if (target.target === "ORDER") return ORDER_KEY;
|
|
3257
|
+
return `LINE:${target.target_id ?? ""}`;
|
|
3258
|
+
};
|
|
3259
|
+
/** Only root lines own redeems; child-line redeems are never applied. */
|
|
3260
|
+
const redeemPools = (lines, redeem) => {
|
|
3261
|
+
const pools = /* @__PURE__ */ new Map();
|
|
3262
|
+
for (const line of lines) {
|
|
3263
|
+
const items = line.redeem ?? [];
|
|
3264
|
+
if (line.parent_uid != null || items.length === 0) continue;
|
|
3265
|
+
const target = {
|
|
3266
|
+
target: "LINE",
|
|
3267
|
+
target_id: line.uid
|
|
3268
|
+
};
|
|
3269
|
+
const pool = items.map((item) => ({
|
|
3270
|
+
...target,
|
|
3271
|
+
redeem: item
|
|
3272
|
+
}));
|
|
3273
|
+
pools.set(redemptionTargetKey(target), pool);
|
|
3274
|
+
}
|
|
3275
|
+
const order = { target: "ORDER" };
|
|
3276
|
+
const pool = redeem.map((item) => ({
|
|
3277
|
+
...order,
|
|
3278
|
+
redeem: item
|
|
3279
|
+
}));
|
|
3280
|
+
pools.set(ORDER_KEY, pool);
|
|
3281
|
+
return pools;
|
|
3282
|
+
};
|
|
3283
|
+
/**
|
|
3284
|
+
* Pairs the k-th step of a target with the k-th redeem of that target, in
|
|
3285
|
+
* step order. Steps without a matching redeem are dropped; redeems without a
|
|
3286
|
+
* step are appended (lines first, then order).
|
|
3287
|
+
*/
|
|
3288
|
+
const sequenceRedeems = (lines, redeem, steps) => {
|
|
3289
|
+
const pools = redeemPools(lines, redeem);
|
|
3290
|
+
const cursors = /* @__PURE__ */ new Map();
|
|
3291
|
+
const sequence = [];
|
|
3292
|
+
for (const step of steps) {
|
|
3293
|
+
const key = redemptionTargetKey(step);
|
|
3294
|
+
const at = cursors.get(key) ?? 0;
|
|
3295
|
+
const next = pools.get(key)?.[at];
|
|
3296
|
+
if (!next || next.redeem.reward_id !== step.reward_id) continue;
|
|
3297
|
+
cursors.set(key, at + 1);
|
|
3298
|
+
sequence.push(next);
|
|
3299
|
+
}
|
|
3300
|
+
for (const [key, pool] of pools) sequence.push(...pool.slice(cursors.get(key) ?? 0));
|
|
3301
|
+
return sequence;
|
|
3302
|
+
};
|
|
3303
|
+
const minBig = (a, b) => {
|
|
3304
|
+
return a.lt(b) ? a : b;
|
|
3305
|
+
};
|
|
3306
|
+
const left = (run) => {
|
|
3307
|
+
return run.base.minus(run.discount);
|
|
3308
|
+
};
|
|
3309
|
+
const rateOf = (discount, base) => {
|
|
3310
|
+
if (base.lte(0)) return 0;
|
|
3311
|
+
return minBig(discount.div(base), new Big(1)).toNumber();
|
|
3312
|
+
};
|
|
3313
|
+
const snapshot = (run) => {
|
|
3314
|
+
const b1 = {
|
|
3315
|
+
value: left(run).toNumber(),
|
|
3316
|
+
quantity: run.quantity
|
|
3317
|
+
};
|
|
3318
|
+
const b2 = { discount_amount: run.discount.toNumber() };
|
|
3319
|
+
const b3 = { discount_rate: rateOf(run.discount, run.base) };
|
|
3320
|
+
return {
|
|
3321
|
+
...b1,
|
|
3322
|
+
...b2,
|
|
3323
|
+
...b3
|
|
3324
|
+
};
|
|
3325
|
+
};
|
|
3326
|
+
/**
|
|
3327
|
+
* Rate compounds once per reward unit over the remaining value; amount is
|
|
3328
|
+
* per unit. With both, the rate discount is capped at `amount × quantity`.
|
|
3329
|
+
*/
|
|
3330
|
+
const redeemDiscount = (redeem, remaining) => {
|
|
3331
|
+
const { quantity, discount_rate, discount_amount } = redeem;
|
|
3332
|
+
let cap = null;
|
|
3333
|
+
if (discount_amount !== void 0) cap = new Big(discount_amount).times(quantity);
|
|
3334
|
+
if (discount_rate === void 0) return cap ?? new Big(0);
|
|
3335
|
+
const kept = new Big(1).minus(discount_rate).pow(quantity);
|
|
3336
|
+
const byRate = remaining.times(new Big(1).minus(kept));
|
|
3337
|
+
return cap ? minBig(byRate, cap) : byRate;
|
|
3338
|
+
};
|
|
3339
|
+
const runningFor = (base, quantity) => {
|
|
3340
|
+
return {
|
|
3341
|
+
base: new Big(base),
|
|
3342
|
+
discount: new Big(0),
|
|
3343
|
+
quantity
|
|
3344
|
+
};
|
|
3345
|
+
};
|
|
3346
|
+
/**
|
|
3347
|
+
* Replays every redeem in persisted step order over the root lines' group
|
|
3348
|
+
* totals. A discount never exceeds what is left on its target or the order.
|
|
3349
|
+
*/
|
|
3350
|
+
const computeRedemptionSteps = (lines, redeem, steps) => {
|
|
3351
|
+
const sequence = sequenceRedeems(lines, redeem, steps);
|
|
3352
|
+
if (sequence.length === 0) return [];
|
|
3353
|
+
const roots = lines.filter((line) => line.parent_uid == null);
|
|
3354
|
+
let orderBase = new Big(0);
|
|
3355
|
+
const lineRuns = /* @__PURE__ */ new Map();
|
|
3356
|
+
for (const root of roots) {
|
|
3357
|
+
orderBase = orderBase.plus(root.group_total);
|
|
3358
|
+
lineRuns.set(root.uid, runningFor(root.group_total, root.quantity));
|
|
3359
|
+
}
|
|
3360
|
+
const order = runningFor(orderBase, 1);
|
|
3361
|
+
return sequence.map((item) => {
|
|
3362
|
+
const line = item.target_id ? lineRuns.get(item.target_id) : void 0;
|
|
3363
|
+
const before = snapshot(order);
|
|
3364
|
+
const targetBefore = line ? snapshot(line) : void 0;
|
|
3365
|
+
const orderLeft = left(order);
|
|
3366
|
+
const targetLeft = line ? left(line) : orderLeft;
|
|
3367
|
+
const raw = redeemDiscount(item.redeem, targetLeft);
|
|
3368
|
+
const discount = minBig(minBig(raw, targetLeft), orderLeft);
|
|
3369
|
+
order.discount = order.discount.plus(discount);
|
|
3370
|
+
if (line) line.discount = line.discount.plus(discount);
|
|
3371
|
+
const action = discount.gt(0) ? "UPDATE" : "READ";
|
|
3372
|
+
const { reward_id, quantity } = item.redeem;
|
|
3373
|
+
const b1 = {
|
|
3374
|
+
reward_id,
|
|
3375
|
+
reward_quantity: quantity,
|
|
3376
|
+
target: item.target
|
|
3377
|
+
};
|
|
3378
|
+
const b2 = item.target_id ? { target_id: item.target_id } : {};
|
|
3379
|
+
const b3 = {
|
|
3380
|
+
action,
|
|
3381
|
+
discount_amount: discount.toNumber()
|
|
3382
|
+
};
|
|
3383
|
+
const b4 = {
|
|
3384
|
+
before,
|
|
3385
|
+
after: snapshot(order)
|
|
3386
|
+
};
|
|
3387
|
+
let b5 = {};
|
|
3388
|
+
if (line) b5 = {
|
|
3389
|
+
target_before: targetBefore,
|
|
3390
|
+
target_after: snapshot(line)
|
|
3391
|
+
};
|
|
3392
|
+
return {
|
|
3393
|
+
...b1,
|
|
3394
|
+
...b2,
|
|
3395
|
+
...b3,
|
|
3396
|
+
...b4,
|
|
3397
|
+
...b5
|
|
3398
|
+
};
|
|
3399
|
+
});
|
|
3400
|
+
};
|
|
3401
|
+
/** Subtract redemption discounts from line-based order totals. */
|
|
3402
|
+
const applyRedemptionTotals = (totals, steps) => {
|
|
3403
|
+
if (steps.length === 0) return totals;
|
|
3404
|
+
let redeemed = new Big(0);
|
|
3405
|
+
for (const step of steps) redeemed = redeemed.plus(step.discount_amount);
|
|
3406
|
+
const discountTotal = new Big(totals.discount_total).plus(redeemed);
|
|
3407
|
+
const subtotal = new Big(totals.subtotal);
|
|
3408
|
+
const discount_rate = rateOf(discountTotal, subtotal);
|
|
3409
|
+
const discount_total = discountTotal.toNumber();
|
|
3410
|
+
const total = new Big(totals.total).minus(redeemed).toNumber();
|
|
3411
|
+
return {
|
|
3412
|
+
...totals,
|
|
3413
|
+
discount_rate,
|
|
3414
|
+
discount_total,
|
|
3415
|
+
total
|
|
3416
|
+
};
|
|
3417
|
+
};
|
|
3418
|
+
/** Steps + totals for a set of lines and redeems, keeping step order. */
|
|
3419
|
+
const recomputeRedemptions = (input) => {
|
|
3420
|
+
const { lines, redeem, currency } = input;
|
|
3421
|
+
const steps = input.redemption_steps;
|
|
3422
|
+
const redemption_steps = computeRedemptionSteps(lines, redeem, steps);
|
|
3423
|
+
const lineTotals = recomputeTotals(lines, currency);
|
|
3424
|
+
return {
|
|
3425
|
+
redemption_steps,
|
|
3426
|
+
totals: applyRedemptionTotals(lineTotals, redemption_steps)
|
|
3427
|
+
};
|
|
3428
|
+
};
|
|
3429
|
+
//#endregion
|
|
2567
3430
|
//#region src/order/Order.ts
|
|
2568
3431
|
const cloneOrder = (order) => {
|
|
2569
3432
|
return structuredClone(order);
|
|
@@ -2585,7 +3448,7 @@ const optionalBrandId = (value) => {
|
|
|
2585
3448
|
const buildInsertFromOptions = (options) => {
|
|
2586
3449
|
if (!options.scope) throw new Error("Order requires scope when fromOrderId is omitted");
|
|
2587
3450
|
if (!options.customer) throw new Error("Order requires customer when fromOrderId is omitted");
|
|
2588
|
-
const orderId = options.orderId ??
|
|
3451
|
+
const orderId = options.orderId ?? genOrderId(options.scope.store_id);
|
|
2589
3452
|
const brand_id = optionalBrandId(options.scope.brand_id);
|
|
2590
3453
|
const customer = buildCustomerSnapshot(options.customer);
|
|
2591
3454
|
const is_anonymous = options.isAnonymous ?? false;
|
|
@@ -2606,7 +3469,8 @@ const buildInsertFromOptions = (options) => {
|
|
|
2606
3469
|
};
|
|
2607
3470
|
const b5 = {
|
|
2608
3471
|
totals: emptyTotals(),
|
|
2609
|
-
metadata: {}
|
|
3472
|
+
metadata: {},
|
|
3473
|
+
redemption_steps: []
|
|
2610
3474
|
};
|
|
2611
3475
|
return {
|
|
2612
3476
|
...b1,
|
|
@@ -2617,8 +3481,8 @@ const buildInsertFromOptions = (options) => {
|
|
|
2617
3481
|
};
|
|
2618
3482
|
};
|
|
2619
3483
|
const buildInsertFromSource = (options, source) => {
|
|
2620
|
-
const orderId = options.orderId ?? `ord-${randomUUID().slice(0, 8)}`;
|
|
2621
3484
|
const store_id = options.scope?.store_id ?? source.store_id;
|
|
3485
|
+
const orderId = options.orderId ?? genOrderId(store_id);
|
|
2622
3486
|
const channel_id = options.scope?.channel_id ?? source.channel_id;
|
|
2623
3487
|
const fulfillment_id = options.scope?.fulfillment_id ?? source.fulfillment_id;
|
|
2624
3488
|
let brand_id = optionalBrandId(source.brand_id);
|
|
@@ -2627,7 +3491,13 @@ const buildInsertFromSource = (options, source) => {
|
|
|
2627
3491
|
if (options.customer) customer = buildCustomerSnapshot(options.customer);
|
|
2628
3492
|
const lines = structuredClone(source.lines);
|
|
2629
3493
|
const is_anonymous = options.isAnonymous ?? source.is_anonymous;
|
|
2630
|
-
const
|
|
3494
|
+
const redeem = structuredClone(source.redeem ?? []);
|
|
3495
|
+
const { redemption_steps, totals } = recomputeRedemptions({
|
|
3496
|
+
lines,
|
|
3497
|
+
redeem,
|
|
3498
|
+
redemption_steps: source.redemption_steps ?? [],
|
|
3499
|
+
currency: source.totals.currency
|
|
3500
|
+
});
|
|
2631
3501
|
const b1 = {
|
|
2632
3502
|
id: orderId,
|
|
2633
3503
|
store_id,
|
|
@@ -2638,7 +3508,6 @@ const buildInsertFromSource = (options, source) => {
|
|
|
2638
3508
|
brand_id,
|
|
2639
3509
|
is_anonymous
|
|
2640
3510
|
};
|
|
2641
|
-
const redeem = source.redeem ?? [];
|
|
2642
3511
|
const b3 = {
|
|
2643
3512
|
customer,
|
|
2644
3513
|
lines,
|
|
@@ -2649,13 +3518,14 @@ const buildInsertFromSource = (options, source) => {
|
|
|
2649
3518
|
return {
|
|
2650
3519
|
...b1,
|
|
2651
3520
|
...b2,
|
|
2652
|
-
...b3
|
|
3521
|
+
...b3,
|
|
3522
|
+
redemption_steps
|
|
2653
3523
|
};
|
|
2654
3524
|
};
|
|
2655
3525
|
const toRedeem = (items) => {
|
|
2656
3526
|
return (items ?? []).map((item) => ({
|
|
2657
3527
|
...item,
|
|
2658
|
-
|
|
3528
|
+
quantity: item.quantity ?? 1
|
|
2659
3529
|
}));
|
|
2660
3530
|
};
|
|
2661
3531
|
/** Local draft until the first mutating flush POSTs to the API. */
|
|
@@ -2708,7 +3578,10 @@ const buildLocalDraft = (insert) => {
|
|
|
2708
3578
|
totals,
|
|
2709
3579
|
metadata
|
|
2710
3580
|
};
|
|
2711
|
-
const b6 = {
|
|
3581
|
+
const b6 = {
|
|
3582
|
+
payment_status,
|
|
3583
|
+
redemption_steps: insert.redemption_steps ?? []
|
|
3584
|
+
};
|
|
2712
3585
|
const b7 = {
|
|
2713
3586
|
fulfillment_status: "IDLE",
|
|
2714
3587
|
status: "OPEN"
|
|
@@ -2924,54 +3797,89 @@ var Order = class Order {
|
|
|
2924
3797
|
}
|
|
2925
3798
|
addLine(lines) {
|
|
2926
3799
|
if (this.deleted) return;
|
|
2927
|
-
|
|
2928
|
-
this.draft = {
|
|
2929
|
-
...this.draft,
|
|
2930
|
-
lines: nextLines,
|
|
2931
|
-
totals: recomputeTotals(nextLines, this.draft.totals.currency)
|
|
2932
|
-
};
|
|
2933
|
-
this.scheduleAfterMutate();
|
|
3800
|
+
this.commitLines({ lines: addLines(this.draft.lines, lines) });
|
|
2934
3801
|
}
|
|
2935
3802
|
subtractLine(lineUid, quantity = 1) {
|
|
2936
3803
|
if (this.deleted) return;
|
|
2937
|
-
const
|
|
2938
|
-
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
};
|
|
2943
|
-
this.scheduleAfterMutate();
|
|
3804
|
+
const p = [
|
|
3805
|
+
this.draft.lines,
|
|
3806
|
+
lineUid,
|
|
3807
|
+
quantity
|
|
3808
|
+
];
|
|
3809
|
+
this.commitLines({ lines: subtractLine(...p) });
|
|
2944
3810
|
}
|
|
2945
3811
|
removeLine(lineUid) {
|
|
2946
3812
|
if (this.deleted) return;
|
|
2947
|
-
|
|
2948
|
-
this.draft = {
|
|
2949
|
-
...this.draft,
|
|
2950
|
-
lines: nextLines,
|
|
2951
|
-
totals: recomputeTotals(nextLines, this.draft.totals.currency)
|
|
2952
|
-
};
|
|
2953
|
-
this.scheduleAfterMutate();
|
|
3813
|
+
this.commitLines({ lines: removeLine(this.draft.lines, lineUid) });
|
|
2954
3814
|
}
|
|
2955
3815
|
replaceLine(lineUid, lines) {
|
|
2956
3816
|
if (this.deleted) return;
|
|
2957
|
-
const
|
|
2958
|
-
|
|
2959
|
-
|
|
2960
|
-
lines
|
|
2961
|
-
|
|
2962
|
-
};
|
|
2963
|
-
this.scheduleAfterMutate();
|
|
3817
|
+
const p = [
|
|
3818
|
+
this.draft.lines,
|
|
3819
|
+
lineUid,
|
|
3820
|
+
lines
|
|
3821
|
+
];
|
|
3822
|
+
this.commitLines({ lines: replaceLine(...p) });
|
|
2964
3823
|
}
|
|
2965
3824
|
emptyOrder() {
|
|
2966
3825
|
if (this.deleted) return;
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
|
|
2971
|
-
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
|
|
3826
|
+
this.commitLines({
|
|
3827
|
+
lines: emptyLines(),
|
|
3828
|
+
redeem: []
|
|
3829
|
+
});
|
|
3830
|
+
}
|
|
3831
|
+
/**
|
|
3832
|
+
* Appends a redeem to `line_uid` (root line) or to the order. Steps keep
|
|
3833
|
+
* the order in which redeems were applied.
|
|
3834
|
+
*/
|
|
3835
|
+
applyRedeem(input) {
|
|
3836
|
+
if (this.deleted) return;
|
|
3837
|
+
const { line_uid, ...rest } = input;
|
|
3838
|
+
const redeem = buildRedeem(rest);
|
|
3839
|
+
if (line_uid === void 0) {
|
|
3840
|
+
this.commitLines({ redeem: [...this.draft.redeem ?? [], redeem] });
|
|
3841
|
+
return;
|
|
3842
|
+
}
|
|
3843
|
+
const line = this.draft.lines.find((item) => item.uid === line_uid);
|
|
3844
|
+
if (!line || line.parent_uid != null || line.type === "FEE") throw new Error(`Redeem requires a root product line, got ${line_uid}`);
|
|
3845
|
+
const lines = this.draft.lines.map((item) => {
|
|
3846
|
+
if (item.uid !== line_uid) return item;
|
|
3847
|
+
return {
|
|
3848
|
+
...item,
|
|
3849
|
+
redeem: [...item.redeem ?? [], redeem]
|
|
3850
|
+
};
|
|
3851
|
+
});
|
|
3852
|
+
this.commitLines({ lines });
|
|
3853
|
+
}
|
|
3854
|
+
/**
|
|
3855
|
+
* Removes `quantity` units of `reward_id` from `line_uid` or the order,
|
|
3856
|
+
* latest applied first. Removing more units than applied clears them all.
|
|
3857
|
+
*/
|
|
3858
|
+
removeRedeem(input) {
|
|
3859
|
+
if (this.deleted) return;
|
|
3860
|
+
const { reward_id, quantity, line_uid } = input;
|
|
3861
|
+
if (line_uid === void 0) {
|
|
3862
|
+
const p = [
|
|
3863
|
+
this.draft.redeem ?? [],
|
|
3864
|
+
reward_id,
|
|
3865
|
+
quantity
|
|
3866
|
+
];
|
|
3867
|
+
this.commitLines({ redeem: subtractRedeem(...p) });
|
|
3868
|
+
return;
|
|
3869
|
+
}
|
|
3870
|
+
const lines = this.draft.lines.map((item) => {
|
|
3871
|
+
if (item.uid !== line_uid) return item;
|
|
3872
|
+
const p = [
|
|
3873
|
+
item.redeem ?? [],
|
|
3874
|
+
reward_id,
|
|
3875
|
+
quantity
|
|
3876
|
+
];
|
|
3877
|
+
return {
|
|
3878
|
+
...item,
|
|
3879
|
+
redeem: subtractRedeem(...p)
|
|
3880
|
+
};
|
|
3881
|
+
});
|
|
3882
|
+
this.commitLines({ lines });
|
|
2975
3883
|
}
|
|
2976
3884
|
setScope(scope) {
|
|
2977
3885
|
if (this.deleted) return;
|
|
@@ -3068,7 +3976,11 @@ var Order = class Order {
|
|
|
3068
3976
|
lines: draft.lines,
|
|
3069
3977
|
redeem: draft.redeem ?? []
|
|
3070
3978
|
};
|
|
3071
|
-
const
|
|
3979
|
+
const steps = draft.redemption_steps ?? [];
|
|
3980
|
+
const b5 = {
|
|
3981
|
+
totals: draft.totals,
|
|
3982
|
+
redemption_steps: steps
|
|
3983
|
+
};
|
|
3072
3984
|
const body = {
|
|
3073
3985
|
...b1,
|
|
3074
3986
|
...b2,
|
|
@@ -3092,6 +4004,25 @@ var Order = class Order {
|
|
|
3092
4004
|
this.synced = cloneOrder(next);
|
|
3093
4005
|
this.notify();
|
|
3094
4006
|
}
|
|
4007
|
+
/** Applies lines/redeem, replaying redemption steps and totals. */
|
|
4008
|
+
commitLines(patch) {
|
|
4009
|
+
const lines = patch.lines ?? this.draft.lines;
|
|
4010
|
+
const redeem = patch.redeem ?? this.draft.redeem ?? [];
|
|
4011
|
+
const { redemption_steps, totals } = recomputeRedemptions({
|
|
4012
|
+
lines,
|
|
4013
|
+
redeem,
|
|
4014
|
+
redemption_steps: this.draft.redemption_steps ?? [],
|
|
4015
|
+
currency: this.draft.totals.currency
|
|
4016
|
+
});
|
|
4017
|
+
this.draft = {
|
|
4018
|
+
...this.draft,
|
|
4019
|
+
lines,
|
|
4020
|
+
redeem,
|
|
4021
|
+
redemption_steps,
|
|
4022
|
+
totals
|
|
4023
|
+
};
|
|
4024
|
+
this.scheduleAfterMutate();
|
|
4025
|
+
}
|
|
3095
4026
|
scheduleAfterMutate() {
|
|
3096
4027
|
this.flush.markDirtyDuringFlight();
|
|
3097
4028
|
this.notify();
|
|
@@ -3372,20 +4303,27 @@ const childrenOf = (lines, parentUid) => {
|
|
|
3372
4303
|
children.sort((a, b) => a.id.localeCompare(b.id));
|
|
3373
4304
|
return children;
|
|
3374
4305
|
};
|
|
3375
|
-
const matchModifier = (parent, childId, lookup) => {
|
|
3376
|
-
for (const groupId of parent.pricing.modifier_group_ids) if (lookup.modifierGroupsById[groupId]?.modifier_ids.includes(childId)) return true;
|
|
3377
|
-
return false;
|
|
3378
|
-
};
|
|
3379
4306
|
const collectUnmatchedModifiers = (lines, lookup, parentProductId, parentUid, out) => {
|
|
3380
4307
|
const parent = lookup.productsById[parentProductId];
|
|
3381
4308
|
if (!parent) return;
|
|
3382
4309
|
for (const child of lines) {
|
|
3383
4310
|
if (child.parent_uid !== parentUid) continue;
|
|
3384
4311
|
if (child.type === "FEE") continue;
|
|
3385
|
-
|
|
4312
|
+
const childProduct = lookup.productsById[child.id];
|
|
4313
|
+
if (childProduct && isVariantBase(childProduct)) {
|
|
4314
|
+
pushLineIssue(out, child.uid, { code: "VARIANT_BASE_NOT_ORDERABLE" });
|
|
4315
|
+
continue;
|
|
4316
|
+
}
|
|
4317
|
+
const match = matchModifierLine(lookup, parent, child.id);
|
|
4318
|
+
if (!match) {
|
|
3386
4319
|
pushLineIssue(out, child.uid, { code: "MODIFIER_NOT_FOUND" });
|
|
3387
4320
|
continue;
|
|
3388
4321
|
}
|
|
4322
|
+
const base = lookup.productsById[match.modifierId];
|
|
4323
|
+
if (match.selection && childProduct && base && !resolvesFromBase(lookup, base, childProduct)) {
|
|
4324
|
+
pushLineIssue(out, child.uid, { code: "VARIANT_NOT_OFFERED" });
|
|
4325
|
+
continue;
|
|
4326
|
+
}
|
|
3389
4327
|
collectUnmatchedModifiers(lines, lookup, child.id, child.uid, out);
|
|
3390
4328
|
}
|
|
3391
4329
|
};
|
|
@@ -3595,6 +4533,11 @@ const validateProductRoot = (root, lines, lookup, locale, out) => {
|
|
|
3595
4533
|
pushLineIssue(out, root.uid, { code: "PRODUCT_NOT_FOUND" });
|
|
3596
4534
|
return;
|
|
3597
4535
|
}
|
|
4536
|
+
if (isVariantBase(product)) {
|
|
4537
|
+
pushLineIssue(out, root.uid, { code: "VARIANT_BASE_NOT_ORDERABLE" });
|
|
4538
|
+
return;
|
|
4539
|
+
}
|
|
4540
|
+
if ((product.pricing.variants ?? []).length > 0 && !isOfferedVariant(lookup, product)) pushLineIssue(out, root.uid, { code: "VARIANT_NOT_OFFERED" });
|
|
3598
4541
|
const status = product.availability.status;
|
|
3599
4542
|
if (status === "HIDDEN") pushLineIssue(out, root.uid, { code: "HIDDEN" });
|
|
3600
4543
|
if (status === "OUT_OF_STOCK") pushLineIssue(out, root.uid, { code: "OUT_OF_STOCK" });
|
|
@@ -3611,6 +4554,152 @@ const validateProductRoot = (root, lines, lookup, locale, out) => {
|
|
|
3611
4554
|
}
|
|
3612
4555
|
compareTrees(lines, expected, root.uid, expectedRoot.uid, out);
|
|
3613
4556
|
};
|
|
4557
|
+
const isExpired = (redeem, now) => {
|
|
4558
|
+
if (redeem.expires_at === void 0) return false;
|
|
4559
|
+
return Date.parse(redeem.expires_at) <= now;
|
|
4560
|
+
};
|
|
4561
|
+
const expiredIssues = (redeem, now) => {
|
|
4562
|
+
const issues = [];
|
|
4563
|
+
redeem.forEach((item, index) => {
|
|
4564
|
+
if (!isExpired(item, now)) return;
|
|
4565
|
+
const field = `redeem.${index}.expires_at`;
|
|
4566
|
+
issues.push({
|
|
4567
|
+
code: "REDEEM_EXPIRED",
|
|
4568
|
+
field,
|
|
4569
|
+
actual: item.expires_at
|
|
4570
|
+
});
|
|
4571
|
+
});
|
|
4572
|
+
return issues;
|
|
4573
|
+
};
|
|
4574
|
+
const redeemSignature = (items) => {
|
|
4575
|
+
return items.map((item) => `${item.reward_id}:${item.quantity}`).join(",");
|
|
4576
|
+
};
|
|
4577
|
+
const stepSignature = (steps) => {
|
|
4578
|
+
return steps.map((step) => `${step.reward_id}:${step.reward_quantity}`).join(",");
|
|
4579
|
+
};
|
|
4580
|
+
/** Every target's redeems must have one step each, in the same order. */
|
|
4581
|
+
const checkRedemptionTargets = (lines, redeem, steps, issues, lineIssues) => {
|
|
4582
|
+
const byTarget = /* @__PURE__ */ new Map();
|
|
4583
|
+
for (const step of steps) {
|
|
4584
|
+
const key = redemptionTargetKey(step);
|
|
4585
|
+
byTarget.set(key, [...byTarget.get(key) ?? [], step]);
|
|
4586
|
+
}
|
|
4587
|
+
const check = (key, items) => {
|
|
4588
|
+
const targetSteps = byTarget.get(key) ?? [];
|
|
4589
|
+
byTarget.delete(key);
|
|
4590
|
+
const expected = redeemSignature(items);
|
|
4591
|
+
const actual = stepSignature(targetSteps);
|
|
4592
|
+
if (expected === actual) return null;
|
|
4593
|
+
return {
|
|
4594
|
+
code: "REDEMPTION_STEPS_MISMATCH",
|
|
4595
|
+
field: "redemption_steps",
|
|
4596
|
+
expected,
|
|
4597
|
+
actual
|
|
4598
|
+
};
|
|
4599
|
+
};
|
|
4600
|
+
const orderIssue = check(redemptionTargetKey({ target: "ORDER" }), redeem);
|
|
4601
|
+
if (orderIssue) issues.push(orderIssue);
|
|
4602
|
+
for (const line of lines) {
|
|
4603
|
+
const lineIssue = check(redemptionTargetKey({
|
|
4604
|
+
target: "LINE",
|
|
4605
|
+
target_id: line.uid
|
|
4606
|
+
}), line.redeem ?? []);
|
|
4607
|
+
if (lineIssue) pushLineIssue(lineIssues, line.uid, lineIssue);
|
|
4608
|
+
}
|
|
4609
|
+
for (const orphan of byTarget.values()) {
|
|
4610
|
+
const b1 = { code: "REDEMPTION_STEPS_MISMATCH" };
|
|
4611
|
+
const b2 = {
|
|
4612
|
+
field: "redemption_steps.target_id",
|
|
4613
|
+
expected: ""
|
|
4614
|
+
};
|
|
4615
|
+
issues.push({
|
|
4616
|
+
...b1,
|
|
4617
|
+
...b2,
|
|
4618
|
+
actual: stepSignature(orphan)
|
|
4619
|
+
});
|
|
4620
|
+
}
|
|
4621
|
+
};
|
|
4622
|
+
const STATE_FIELDS = [
|
|
4623
|
+
"value",
|
|
4624
|
+
"quantity",
|
|
4625
|
+
"discount_amount",
|
|
4626
|
+
"discount_rate"
|
|
4627
|
+
];
|
|
4628
|
+
const STATE_KEYS = [
|
|
4629
|
+
"before",
|
|
4630
|
+
"after",
|
|
4631
|
+
"target_before",
|
|
4632
|
+
"target_after"
|
|
4633
|
+
];
|
|
4634
|
+
const flattenState = (prefix, state, out) => {
|
|
4635
|
+
for (const field of STATE_FIELDS) out[`${prefix}.${field}`] = state ? bigStr(state[field]) : "";
|
|
4636
|
+
};
|
|
4637
|
+
const flattenStep = (step) => {
|
|
4638
|
+
const b1 = { reward_id: step.reward_id };
|
|
4639
|
+
const b2 = { reward_quantity: String(step.reward_quantity) };
|
|
4640
|
+
const b3 = {
|
|
4641
|
+
target: step.target,
|
|
4642
|
+
target_id: step.target_id ?? ""
|
|
4643
|
+
};
|
|
4644
|
+
const b4 = { action: step.action };
|
|
4645
|
+
const b5 = { discount_amount: bigStr(step.discount_amount) };
|
|
4646
|
+
const out = {
|
|
4647
|
+
...b1,
|
|
4648
|
+
...b2,
|
|
4649
|
+
...b3,
|
|
4650
|
+
...b4,
|
|
4651
|
+
...b5
|
|
4652
|
+
};
|
|
4653
|
+
for (const key of STATE_KEYS) flattenState(key, step[key], out);
|
|
4654
|
+
return out;
|
|
4655
|
+
};
|
|
4656
|
+
/** Replayed steps must equal the submitted ones field by field. */
|
|
4657
|
+
const compareRedemptionSteps = (submitted, expected, issues) => {
|
|
4658
|
+
if (submitted.length !== expected.length) {
|
|
4659
|
+
const b1 = { code: "REDEMPTION_MISMATCH" };
|
|
4660
|
+
const b2 = { field: "redemption_steps" };
|
|
4661
|
+
const b3 = { expected: String(expected.length) };
|
|
4662
|
+
issues.push({
|
|
4663
|
+
...b1,
|
|
4664
|
+
...b2,
|
|
4665
|
+
...b3,
|
|
4666
|
+
actual: String(submitted.length)
|
|
4667
|
+
});
|
|
4668
|
+
return;
|
|
4669
|
+
}
|
|
4670
|
+
submitted.forEach((step, index) => {
|
|
4671
|
+
const actual = flattenStep(step);
|
|
4672
|
+
const wanted = flattenStep(expected[index]);
|
|
4673
|
+
for (const field of Object.keys(wanted)) {
|
|
4674
|
+
if (actual[field] === wanted[field]) continue;
|
|
4675
|
+
const b1 = { code: "REDEMPTION_MISMATCH" };
|
|
4676
|
+
const b2 = { field: `redemption_steps.${index}.${field}` };
|
|
4677
|
+
const b3 = {
|
|
4678
|
+
expected: wanted[field],
|
|
4679
|
+
actual: actual[field]
|
|
4680
|
+
};
|
|
4681
|
+
issues.push({
|
|
4682
|
+
...b1,
|
|
4683
|
+
...b2,
|
|
4684
|
+
...b3
|
|
4685
|
+
});
|
|
4686
|
+
}
|
|
4687
|
+
});
|
|
4688
|
+
};
|
|
4689
|
+
const validateRedemptions = (input, issues, lineIssues) => {
|
|
4690
|
+
const redeem = input.redeem ?? [];
|
|
4691
|
+
const steps = input.redemptionSteps ?? [];
|
|
4692
|
+
const now = input.now ?? Date.now();
|
|
4693
|
+
issues.push(...expiredIssues(redeem, now));
|
|
4694
|
+
for (const line of input.lines) {
|
|
4695
|
+
const expired = expiredIssues(line.redeem ?? [], now);
|
|
4696
|
+
for (const issue of expired) pushLineIssue(lineIssues, line.uid, issue);
|
|
4697
|
+
}
|
|
4698
|
+
checkRedemptionTargets(input.lines, redeem, steps, issues, lineIssues);
|
|
4699
|
+
const expected = computeRedemptionSteps(input.lines, redeem, steps);
|
|
4700
|
+
compareRedemptionSteps(steps, expected, issues);
|
|
4701
|
+
return expected;
|
|
4702
|
+
};
|
|
3614
4703
|
/** Compare cart lines/totals against a fresh menu (exact Big.js equality). */
|
|
3615
4704
|
const validateOrderAgainstMenu = (input) => {
|
|
3616
4705
|
const locale = input.locale ?? "en_us";
|
|
@@ -3641,7 +4730,8 @@ const validateOrderAgainstMenu = (input) => {
|
|
|
3641
4730
|
}
|
|
3642
4731
|
checkRootProductQuantities(roots, lookup, lineIssues);
|
|
3643
4732
|
if (input.expectedFeeLines) compareExpectedFeeLines(input.lines, input.expectedFeeLines, lineIssues);
|
|
3644
|
-
const
|
|
4733
|
+
const expectedSteps = validateRedemptions(input, issues, lineIssues);
|
|
4734
|
+
const expectedTotals = applyRedemptionTotals(recomputeTotals(input.lines, input.totals.currency), expectedSteps);
|
|
3645
4735
|
for (const field of TOTALS_FIELDS) {
|
|
3646
4736
|
if (bigEq(input.totals[field], expectedTotals[field])) continue;
|
|
3647
4737
|
issues.push({
|
|
@@ -3682,6 +4772,12 @@ const issuesForTree = (lines, rootUid, lineIssues) => {
|
|
|
3682
4772
|
}
|
|
3683
4773
|
return issues;
|
|
3684
4774
|
};
|
|
4775
|
+
const UNRECOVERABLE = /* @__PURE__ */ new Set([
|
|
4776
|
+
"PRODUCT_NOT_FOUND",
|
|
4777
|
+
"HIDDEN",
|
|
4778
|
+
"VARIANT_BASE_NOT_ORDERABLE",
|
|
4779
|
+
"VARIANT_NOT_OFFERED"
|
|
4780
|
+
]);
|
|
3685
4781
|
const collectOrderLineDiffs = (input) => {
|
|
3686
4782
|
const locale = input.locale ?? "en_us";
|
|
3687
4783
|
const roots = input.lines.filter((line) => {
|
|
@@ -3694,9 +4790,7 @@ const collectOrderLineDiffs = (input) => {
|
|
|
3694
4790
|
if (issues.length === 0) continue;
|
|
3695
4791
|
const treeUids = descendantUids(input.lines, root.uid);
|
|
3696
4792
|
const previous = input.lines.filter((line) => treeUids.has(line.uid));
|
|
3697
|
-
const missing = issues.some((issue) =>
|
|
3698
|
-
return issue.code === "PRODUCT_NOT_FOUND" || issue.code === "HIDDEN";
|
|
3699
|
-
});
|
|
4793
|
+
const missing = issues.some((issue) => UNRECOVERABLE.has(issue.code));
|
|
3700
4794
|
const product = lookup?.productsById[root.id];
|
|
3701
4795
|
if (missing || !product || !lookup) {
|
|
3702
4796
|
diffs.push({
|
|
@@ -5018,4 +6112,4 @@ const paymentMatchesClientConditions = (conditions, order, options) => {
|
|
|
5018
6112
|
return Boolean(engine.matchOrConditions(groups).match);
|
|
5019
6113
|
};
|
|
5020
6114
|
//#endregion
|
|
5021
|
-
export { DEFAULT_RECOMMENDATION_TYPES, Order, OrderSyncPendingError, OrderValidationError, RecurringDateCron, RulesEngine, StorefrontHttpError, addLines, auth, buildCustomerSnapshot, collectOrderLineDiffs, countCartProducts, createSession, currentNodes, defaultConditionError, flattenMenuCategories, fromOrderLines, getScheduleWindow, indexMenu, isCheckoutFieldFilled, isDeviceNotLinked, isKioskUnauthorized, isMilestoneStepDone, isOnSchedule, isOrderValidationError, isStorefrontNotFound, listMenusStream, matchAbsoluteDateOperator, matchBooleanOperator, matchCurrencyOperator, matchEnumOperator, matchNumberOperator, matchRecurringDateOperator, matchRelativeDateOperator, matchStringOperator, milestoneSteps, milestoneTrack, nestMenuCategories, paymentMatchesClientConditions, recomputeTotals, resolveCheckout, services, storefrontJson, toOrderLines, validateOrderAgainstMenu, visitedStates };
|
|
6115
|
+
export { DEFAULT_RECOMMENDATION_TYPES, Order, OrderSyncPendingError, OrderValidationError, RecurringDateCron, RulesEngine, StorefrontHttpError, addLines, auth, buildCustomerSnapshot, collectOrderLineDiffs, computeStartingPrice, countCartProducts, createSession, currentNodes, defaultConditionError, flattenMenuCategories, fromOrderLines, genId, genOrderId, getScheduleWindow, indexMenu, isCheckoutFieldFilled, isDeviceNotLinked, isKioskUnauthorized, isMilestoneStepDone, isOnSchedule, isOrderValidationError, isStorefrontNotFound, isVariantBase, listMenusStream, matchAbsoluteDateOperator, matchBooleanOperator, matchCurrencyOperator, matchEnumOperator, matchNumberOperator, matchRecurringDateOperator, matchRelativeDateOperator, matchStringOperator, milestoneSteps, milestoneTrack, nestMenuCategories, paymentMatchesClientConditions, recomputeRedemptions, recomputeTotals, resolveCheckout, services, storefrontJson, toOrderLines, validateOrderAgainstMenu, visitedStates };
|