@fire-spark/core 0.0.2 → 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.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
@@ -3746,20 +4303,27 @@ const childrenOf = (lines, parentUid) => {
3746
4303
  children.sort((a, b) => a.id.localeCompare(b.id));
3747
4304
  return children;
3748
4305
  };
3749
- const matchModifier = (parent, childId, lookup) => {
3750
- for (const groupId of parent.pricing.modifier_group_ids) if (lookup.modifierGroupsById[groupId]?.modifier_ids.includes(childId)) return true;
3751
- return false;
3752
- };
3753
4306
  const collectUnmatchedModifiers = (lines, lookup, parentProductId, parentUid, out) => {
3754
4307
  const parent = lookup.productsById[parentProductId];
3755
4308
  if (!parent) return;
3756
4309
  for (const child of lines) {
3757
4310
  if (child.parent_uid !== parentUid) continue;
3758
4311
  if (child.type === "FEE") continue;
3759
- 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) {
3760
4319
  pushLineIssue(out, child.uid, { code: "MODIFIER_NOT_FOUND" });
3761
4320
  continue;
3762
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
+ }
3763
4327
  collectUnmatchedModifiers(lines, lookup, child.id, child.uid, out);
3764
4328
  }
3765
4329
  };
@@ -3969,6 +4533,11 @@ const validateProductRoot = (root, lines, lookup, locale, out) => {
3969
4533
  pushLineIssue(out, root.uid, { code: "PRODUCT_NOT_FOUND" });
3970
4534
  return;
3971
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" });
3972
4541
  const status = product.availability.status;
3973
4542
  if (status === "HIDDEN") pushLineIssue(out, root.uid, { code: "HIDDEN" });
3974
4543
  if (status === "OUT_OF_STOCK") pushLineIssue(out, root.uid, { code: "OUT_OF_STOCK" });
@@ -4203,6 +4772,12 @@ const issuesForTree = (lines, rootUid, lineIssues) => {
4203
4772
  }
4204
4773
  return issues;
4205
4774
  };
4775
+ const UNRECOVERABLE = /* @__PURE__ */ new Set([
4776
+ "PRODUCT_NOT_FOUND",
4777
+ "HIDDEN",
4778
+ "VARIANT_BASE_NOT_ORDERABLE",
4779
+ "VARIANT_NOT_OFFERED"
4780
+ ]);
4206
4781
  const collectOrderLineDiffs = (input) => {
4207
4782
  const locale = input.locale ?? "en_us";
4208
4783
  const roots = input.lines.filter((line) => {
@@ -4215,9 +4790,7 @@ const collectOrderLineDiffs = (input) => {
4215
4790
  if (issues.length === 0) continue;
4216
4791
  const treeUids = descendantUids(input.lines, root.uid);
4217
4792
  const previous = input.lines.filter((line) => treeUids.has(line.uid));
4218
- const missing = issues.some((issue) => {
4219
- return issue.code === "PRODUCT_NOT_FOUND" || issue.code === "HIDDEN";
4220
- });
4793
+ const missing = issues.some((issue) => UNRECOVERABLE.has(issue.code));
4221
4794
  const product = lookup?.productsById[root.id];
4222
4795
  if (missing || !product || !lookup) {
4223
4796
  diffs.push({
@@ -5539,4 +6112,4 @@ const paymentMatchesClientConditions = (conditions, order, options) => {
5539
6112
  return Boolean(engine.matchOrConditions(groups).match);
5540
6113
  };
5541
6114
  //#endregion
5542
- export { DEFAULT_RECOMMENDATION_TYPES, Order, OrderSyncPendingError, OrderValidationError, RecurringDateCron, RulesEngine, StorefrontHttpError, addLines, auth, buildCustomerSnapshot, collectOrderLineDiffs, countCartProducts, createSession, currentNodes, defaultConditionError, flattenMenuCategories, fromOrderLines, genId, genOrderId, getScheduleWindow, indexMenu, isCheckoutFieldFilled, isDeviceNotLinked, isKioskUnauthorized, isMilestoneStepDone, isOnSchedule, isOrderValidationError, isStorefrontNotFound, listMenusStream, matchAbsoluteDateOperator, matchBooleanOperator, matchCurrencyOperator, matchEnumOperator, matchNumberOperator, matchRecurringDateOperator, matchRelativeDateOperator, matchStringOperator, milestoneSteps, milestoneTrack, nestMenuCategories, paymentMatchesClientConditions, recomputeRedemptions, 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 };