@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.
Files changed (3) hide show
  1. package/dist/index.d.ts +508 -47
  2. package/dist/index.js +1389 -295
  3. 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, products) => {
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 = products.find((entry) => entry.id === modifierId)?.pricing.modifiers?.find((modifier) => modifier.modifier_group_id === groupId);
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
- byId.set(groupId, inferGroupFromProducts(groupId, modifierIds, products));
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/clamp.utils.ts
107
- const clampModifierQuantity = (type, quantity, min, max) => {
108
- let next = Math.max(0, Math.floor(quantity));
109
- if (type === "RADIO" || type === "CHECKBOX") next = next > 0 ? 1 : 0;
110
- if (next === 0) return 0;
111
- let lower = min;
112
- if (type === "QUANTITY") lower = 0;
113
- return Math.min(max, Math.max(lower, next));
140
+ //#region src/productCustomization/store.ts
141
+ const createStore = () => {
142
+ return {
143
+ quantities: {},
144
+ initialQuantities: {},
145
+ selections: {},
146
+ initialSelections: {}
147
+ };
114
148
  };
115
- const clampProductQuantity = (quantity, min, max) => {
116
- return Math.min(max, Math.max(min, Math.max(0, Math.floor(quantity))));
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(pricingModifier?.price ?? modifier.data.pricing.price);
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 refreshModifierFields = (modifier, group, store) => {
605
- const pricingModifier = resolvePricingModifier(modifier.data, group.data.id);
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
- refreshModifierFields(modifier, group, store);
617
- if (modifier.product) {
618
- modifier.product.quantity = modifier.quantity;
619
- refreshProductTree(modifier.product, store);
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 = group.modifiers.reduce((sum, modifier) => {
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
- refreshProductTree(runtime.root, runtime.store);
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 createModifierCtx = (runtime, productCtx, group, modifierProduct, modifierPath) => {
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: pricingModifier?.minimum_quantity ?? 0,
1083
+ min,
765
1084
  max: pricingModifier?.maximum_quantity ?? group.data.maximum_quantity,
766
- quantity: 0,
767
- required: (pricingModifier?.minimum_quantity ?? 0) > 0,
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.pricing.modifier_group_ids.length > 0) modifier.product = createProductCtx(...[
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, productCtx, group, productPath) => {
802
- const groupPath = `${productPath}/group/${group.id}`;
1124
+ const createModifierGroupCtx = (runtime, group, productPath) => {
803
1125
  const groupCtx = {
804
- path: groupPath,
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 modifierPath = `${groupPath}/modifier/${modifierId}`;
818
- const args1 = [
1139
+ const args = [
819
1140
  runtime,
820
- productCtx,
821
- groupCtx
1141
+ groupCtx,
1142
+ modifierProduct,
1143
+ productPath
822
1144
  ];
823
- const args2 = [modifierProduct, modifierPath];
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: product.pricing.minimum_quantity,
833
- max: product.pricing.maximum_quantity,
834
- quantity: 0,
835
- required: product.pricing.minimum_quantity > 0,
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.groups = product.pricing.modifier_group_ids.map((groupId) => {
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
- clearSubtree(runtime.store, path);
1185
+ const { store, lookup } = runtime;
1186
+ clearSubtree(store, path);
860
1187
  if (options?.force) {
861
- if (isRoot) applyCatalogDefaults(runtime.store, runtime.lookup, product, path);
1188
+ if (isRoot) applyCatalogDefaults(store, lookup, productCtx.data, path);
862
1189
  } else {
863
- for (const [key, value] of Object.entries(runtime.store.initialQuantities)) if (key === path || key.startsWith(`${path}/`)) setQuantity(runtime.store, key, value);
864
- if (isRoot && getQuantity(runtime.store, path) === 0) setQuantity(runtime.store, path, product.pricing.minimum_quantity);
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 rootPath = `/${productId}`;
876
- const store = {
877
- quantities: {},
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
- if (seed) {
882
- applyInitialQuantities(store, lookup, product, rootPath, seed);
883
- store.initialQuantities = cloneQuantities(store.quantities);
884
- } else {
885
- applyCatalogDefaults(store, lookup, product, rootPath);
886
- store.initialQuantities = cloneQuantities(store.quantities);
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
- runtime.root = createProductCtx(runtime, product, rootPath, true);
896
- refreshProductTree(runtime.root, runtime.store);
897
- validateProduct(runtime.root);
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: runtime.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: cloneQuantities(store.quantities)
1323
+ quantities,
1324
+ variants
908
1325
  };
909
1326
  },
910
1327
  totalPrice: () => {
911
- refreshProductTree(runtime.root, runtime.store);
1328
+ refreshProductTree(runtime.root, runtime);
912
1329
  return computeTotalPrice(runtime.root);
913
1330
  },
914
- validate: (options) => {
915
- refreshProductTree(runtime.root, runtime.store);
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: (options) => runtime.root.reset(options)
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 walk = (parentProductId, parentUid, pathPrefix) => {
998
- const parentProduct = lookup.productsById[parentProductId];
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 lines) {
1001
- if (child.parent_uid !== parentUid) continue;
1002
- let matchedPath = null;
1003
- for (const groupId of parentProduct.pricing.modifier_group_ids) {
1004
- if (!lookup.modifierGroupsById[groupId]?.modifier_ids.includes(child.id)) continue;
1005
- matchedPath = `${pathPrefix}/group/${groupId}/modifier/${child.id}`;
1006
- break;
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.id, root.uid, `/${root.id}`);
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 appendModifierLines = (config) => {
1213
- for (const modifier of config.modifiers) {
1214
- if (modifier.quantity <= 0) continue;
1215
- const groupId = modifier.path.split("/group/")[1]?.split("/")[0];
1216
- let pricing;
1217
- if (groupId) pricing = resolvePricingModifier(modifier.data, groupId);
1218
- const taxRate = pricing?.tax_rate ?? modifier.data.pricing.tax_rate;
1219
- const fromGroup = pricing?.is_tax_inclusive;
1220
- const fromData = modifier.data.pricing.is_tax_inclusive;
1221
- const isTaxInclusive = fromGroup ?? fromData;
1222
- let unitSubtotal = new Big(0);
1223
- if (modifier.quantity > 0) unitSubtotal = new Big(modifier.linePrice).div(modifier.quantity);
1224
- const name = displayName(modifier.data.name, config.locale);
1225
- const l1 = {
1226
- product: modifier.data,
1227
- name
1228
- };
1229
- const l2 = {
1230
- quantity: modifier.quantity,
1231
- unitSubtotal
1232
- };
1233
- const l3 = {
1234
- taxRate,
1235
- isTaxInclusive
1236
- };
1237
- const l4 = { parent_uid: config.parentUid };
1238
- const l5 = { parent_id: config.parentId };
1239
- const line = buildLine({
1240
- ...l1,
1241
- ...l2,
1242
- ...l3,
1243
- ...l4,
1244
- ...l5
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
- for (const group of root.groups) {
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 ?? `ord-${randomUUID().slice(0, 8)}`;
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 totals = recomputeTotals(lines, source.totals.currency);
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
- amount: item.amount ?? 1
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 = { payment_status };
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
- const nextLines = addLines(this.draft.lines, lines);
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 nextLines = subtractLine(this.draft.lines, lineUid, quantity);
2938
- this.draft = {
2939
- ...this.draft,
2940
- lines: nextLines,
2941
- totals: recomputeTotals(nextLines, this.draft.totals.currency)
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
- const nextLines = removeLine(this.draft.lines, lineUid);
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 nextLines = replaceLine(this.draft.lines, lineUid, lines);
2958
- this.draft = {
2959
- ...this.draft,
2960
- lines: nextLines,
2961
- totals: recomputeTotals(nextLines, this.draft.totals.currency)
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
- const nextLines = emptyLines();
2968
- this.draft = {
2969
- ...this.draft,
2970
- lines: nextLines,
2971
- redeem: [],
2972
- totals: recomputeTotals(nextLines, this.draft.totals.currency)
2973
- };
2974
- this.scheduleAfterMutate();
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 b5 = { totals: draft.totals };
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
- if (!matchModifier(parent, child.id, lookup)) {
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 expectedTotals = recomputeTotals(input.lines, input.totals.currency);
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 };