@12-apps/discounts 1.17.0 → 1.18.0

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/README.md CHANGED
@@ -356,7 +356,7 @@ of promotion is this — and derives the pair (`src/react/form-kind.ts`):
356
356
  |---|---|---|---|
357
357
  | Porcentagem | `PERCENTAGE` | ORDER / CATEGORY / ITEM | the rate, and what it covers |
358
358
  | Valor fixo | `FIXED_AMOUNT` | ORDER / CATEGORY / ITEM | the amount, and what it covers |
359
- | Combo | `PERCENTAGE` or `FIXED_AMOUNT` | `COMBO` | the groups, and which of the two rewards |
359
+ | Combo | `PERCENTAGE`, `FIXED_AMOUNT` or `BUNDLE_PRICE` | `COMBO` | the groups, and which of the three rewards |
360
360
  | Itens grátis | `FREE_UNITS` | `COMBO` | the products, how many, how many free |
361
361
 
362
362
  So the offers a merchant describes map one to one onto what they fill in:
@@ -366,6 +366,7 @@ So the offers a merchant describes map one to one onto what they fill in:
366
366
  | "leve 3, pague 2" | Itens grátis | 3 hambúrgueres, 1 free |
367
367
  | "2 refrigerantes, 2 hambúrgueres e 2 batatas com 20% de desconto" | Combo | three groups of 2, and the rate |
368
368
  | the same six items at R$ 5,00 off | Combo | three groups of 2, and the amount |
369
+ | "5 marmitas por R$ 120" | Combo | one group of 5 (a category mixes flavours), and the closed price |
369
370
 
370
371
  Two consequences of asking the kind instead of the pair:
371
372
 
@@ -373,12 +374,27 @@ Two consequences of asking the kind instead of the pair:
373
374
  made of, so the scope toggle offers the three that are genuinely a choice.
374
375
  Offering the fourth is what let an operator build "preço de combo,
375
376
  abrangência: pedido" and have the write path refuse it.
376
- - **`BUNDLE_PRICE` is no longer offered.** A flat price for the group reprices
377
- it, and goes silently wrong the first time one of its items changes price —
378
- so a combo gives a percentage or an amount OFF what its items add up to. The
379
- type is still legal in the engine and in the database, and the kind toggle
380
- grows a fifth option on the form of a rule that already carries it, so an
381
- older promotion opens, edits and saves as what it is.
377
+ - **A closed price is a combo's third reward, "Preço fechado" (FUT-4417).**
378
+ `BUNDLE_PRICE` reprices the matched group whatever its items cost — which is
379
+ what "5 marmitas por R$ 120" asks for, so the form offers it beside the rate
380
+ and the amount, says so under the price, and previews it. It is a REWARD,
381
+ not a kind: a rule saved as `BUNDLE_PRICE` before FUT-4417 opens as a combo
382
+ with "Preço fechado" chosen, and the kind toggle is always the same four.
383
+ - **"Como fica no carrinho"** previews one combo under the chosen reward: what
384
+ its items sum to (each group filled with its cheapest product, then its
385
+ dearest) and what the buyer pays and saves, with the engine's own
386
+ arithmetic (`src/react/combo-preview.ts`, one application, before the
387
+ cart-level clamps). It needs the host's ITEM targets to carry
388
+ `priceRangeCents` (the product's own price and its variations') and
389
+ `categoryId` (only for products a buyer can add: listed, not paused); a
390
+ host that sends neither simply gets no preview. In a form at least
391
+ `PREVIEW_ASIDE_MIN_PX` wide (931 px: the narrowest form whose left column
392
+ still holds the four-part kind toggle) it sits in its own column beside
393
+ the form; narrower, it sits under the reward's field.
394
+ - **The create and edit forms open in `UsableModal`** (`size="wide"`): a
395
+ stacked modal on a computer, which at 1280 gives the form the room for
396
+ that column, and a bottom sheet on a phone. Every opening mounts a fresh
397
+ form.
382
398
 
383
399
  Picking Combo swaps the single target picker for the **group builder**: each
384
400
  group is a quantity plus the rows that can fill it, and a group may name
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@12-apps/discounts",
3
- "version": "1.17.0",
3
+ "version": "1.18.0",
4
4
  "type": "module",
5
5
  "sideEffects": [
6
6
  "**/e2e/**"
@@ -43,8 +43,8 @@
43
43
  "dependencies": {
44
44
  "@12-apps/app-shell": "^5.14.1",
45
45
  "@12-apps/forms-core": "^1.23.0",
46
- "@12-apps/shared-helpers": "^2.1.0",
47
- "@12-apps/ui": "^6.86.0",
46
+ "@12-apps/shared-helpers": "^2.2.0",
47
+ "@12-apps/ui": "^6.95.1",
48
48
  "@mui/icons-material": "^6.5.0",
49
49
  "zod": "^4.3.5"
50
50
  },
package/src/react/api.ts CHANGED
@@ -66,6 +66,10 @@ export interface WireTarget {
66
66
  id: string;
67
67
  name: string;
68
68
  parentId?: string | null;
69
+ /** An ITEM row's unit price range (FUT-4417), for the combo preview only. */
70
+ priceRangeCents?: { minCents: number; maxCents: number } | null;
71
+ /** An ITEM row's own category, for the same preview. */
72
+ categoryId?: string | null;
69
73
  }
70
74
 
71
75
  /** One registered collection and its rows. */
@@ -119,7 +123,7 @@ export interface DiscountFormPayload {
119
123
  }
120
124
 
121
125
  /** A money amount as integer cents, or null when blank. */
122
- function toCents(typed: string, formatters: DiscountsFormatters): number | null {
126
+ export function toCents(typed: string, formatters: DiscountsFormatters): number | null {
123
127
  const amount = formatters.parseDecimal(typed);
124
128
  return amount === null ? null : Math.round(amount * 100);
125
129
  }
@@ -138,7 +142,7 @@ function toCount(typed: string, formatters: DiscountsFormatters): number | null
138
142
  * engine computes without a float. The `Math.round` after the ×100 is what
139
143
  * keeps "12,5" exactly 1250 rather than 1249.9999999999998.
140
144
  */
141
- function toBasisPoints(typed: string, formatters: DiscountsFormatters): number | null {
145
+ export function toBasisPoints(typed: string, formatters: DiscountsFormatters): number | null {
142
146
  const percent = formatters.parseDecimal(typed);
143
147
  return percent === null ? null : Math.round(percent * 100);
144
148
  }
@@ -0,0 +1,189 @@
1
+ "use client";
2
+
3
+ import { createContext, useContext, useRef, type JSX, type ReactNode } from "react";
4
+
5
+ import { Alert } from "@12-apps/ui/data-display/Alert";
6
+ import { useFormContext } from "@12-apps/ui/form/total-form";
7
+ import { Box } from "@12-apps/ui/mui/Box";
8
+ import { Stack } from "@12-apps/ui/mui/Stack";
9
+ import { Text } from "@12-apps/ui/typography/Text";
10
+
11
+ import type { ComboRequirement } from "../engine/types";
12
+
13
+ import { toBasisPoints, toCents, type WireTargetGroup } from "./api";
14
+ import type { DiscountsWebCopy } from "./copy";
15
+ import { comboPreviewOf, type ComboPreview, type ComboRewardValue } from "./combo-preview";
16
+ import { comboPreviewLines, type ComboPreviewLayout } from "./combo-preview-lines";
17
+ import { comboRewardOf } from "./form-kind";
18
+ import type { DiscountsFormatters } from "./format";
19
+ import { KIND_TOGGLE_MIN_PX, useElementWidth } from "./kind-field";
20
+
21
+ /**
22
+ * "Como fica no carrinho" on screen (FUT-4417), and where it sits.
23
+ *
24
+ * Under the reward's field on a phone or a narrow form; in its own column
25
+ * beside the form when the form has the room, as the approved prototype draws
26
+ * it on a computer. What decides is the FORM's own width, measured the way
27
+ * `KindField` measures it, because the form also lives in a modal narrower
28
+ * than the screen. In the wide stacked modal the screens open it in
29
+ * (`DiscountDialog`), a 1280 window gives the form well over the threshold;
30
+ * a tablet's or a phone's does not, and the preview sits under the field.
31
+ */
32
+
33
+ /** The side column's share and gap: `minmax(0, 3fr) minmax(0, 2fr)`, `gap: 3`. */
34
+ const ASIDE_GAP_PX = 24;
35
+ const FORM_SHARE = 3 / 5;
36
+
37
+ /**
38
+ * The narrowest form that takes the side column: one whose left column still
39
+ * holds the kind question as a toggle (`KIND_TOGGLE_MIN_PX`). A column that
40
+ * squeezed it into a select would trade a control the operator already knows
41
+ * for a box of text — the preview has room only when nothing else pays for it.
42
+ */
43
+ const PREVIEW_ASIDE_MIN_PX = Math.ceil(KIND_TOGGLE_MIN_PX / FORM_SHARE + ASIDE_GAP_PX);
44
+
45
+ /** What the preview reads that is not a form value. */
46
+ interface PreviewInputs {
47
+ copy: DiscountsWebCopy;
48
+ formatters: DiscountsFormatters;
49
+ groups: readonly WireTargetGroup[];
50
+ slots: readonly ComboRequirement[];
51
+ }
52
+
53
+ /**
54
+ * The preview, worked out ONCE per render of the layout and handed down: the
55
+ * inline box under the reward's field reads it from here rather than summing
56
+ * the combo a second time.
57
+ */
58
+ interface PreviewWorld extends PreviewInputs {
59
+ layout: ComboPreviewLayout;
60
+ reward: ComboRewardValue;
61
+ preview: ComboPreview | null;
62
+ }
63
+
64
+ const PreviewContext = createContext<PreviewWorld | null>(null);
65
+
66
+ /** The reward the form currently describes, as numbers. */
67
+ function rewardFrom(
68
+ values: Record<string, string>,
69
+ formatters: DiscountsFormatters,
70
+ ): ComboRewardValue {
71
+ return {
72
+ type: comboRewardOf({ type: values.comboReward ?? "" }),
73
+ percentOffBp: toBasisPoints(values.percentOff ?? "", formatters),
74
+ amountOffCents: toCents(values.amountOff ?? "", formatters),
75
+ bundlePriceCents: toCents(values.bundlePrice ?? "", formatters),
76
+ };
77
+ }
78
+
79
+ /** The preview the form currently describes, or null when there is nothing honest to sum. */
80
+ function useComboPreview(world: PreviewInputs): {
81
+ reward: ComboRewardValue;
82
+ preview: ComboPreview | null;
83
+ } {
84
+ const { values } = useFormContext();
85
+ const reward = rewardFrom(values, world.formatters);
86
+ const preview =
87
+ values.kind === "COMBO" ? comboPreviewOf(world.slots, world.groups, reward) : null;
88
+ return { reward, preview };
89
+ }
90
+
91
+ function PreviewBox({ world }: { world: PreviewWorld }): JSX.Element | null {
92
+ const { reward, preview } = world;
93
+ if (preview === null) return null;
94
+ const lines = comboPreviewLines(preview, reward, world.layout, world.copy, world.formatters);
95
+ const aside = world.layout === "aside";
96
+ return (
97
+ <Alert variant="info" showIcon={false} animate={false} data-testid="combo-cart-preview">
98
+ <Stack spacing={aside ? 1 : 0.5}>
99
+ {/* A small label over the figures, as the prototype draws it — not the
100
+ Alert's own title, which is larger than the lines it heads. */}
101
+ <Text as="p" variant="caption" weight="semibold" data-testid="combo-cart-preview-heading">
102
+ {world.copy.comboRewards.preview.title}
103
+ </Text>
104
+ <Text variant="body" size="sm" data-testid="combo-cart-preview-items">
105
+ {lines.items}
106
+ </Text>
107
+ {lines.pays !== null && (
108
+ <Text
109
+ variant={aside ? "heading" : "body"}
110
+ {...(aside ? {} : { size: "sm" as const })}
111
+ data-testid="combo-cart-preview-pays"
112
+ >
113
+ {lines.pays}
114
+ </Text>
115
+ )}
116
+ {lines.examples !== null && (
117
+ <Text variant="caption" data-testid="combo-cart-preview-examples">
118
+ {lines.examples}
119
+ </Text>
120
+ )}
121
+ </Stack>
122
+ </Alert>
123
+ );
124
+ }
125
+
126
+ /** The preview under the reward's field — rendered only when the form is narrow. */
127
+ export function InlineComboCartPreview(): JSX.Element | null {
128
+ const world = useContext(PreviewContext);
129
+ if (world === null || world.layout !== "inline") return null;
130
+ return <PreviewBox world={world} />;
131
+ }
132
+
133
+ /**
134
+ * The form's body, with the preview's column beside it when a combo is being
135
+ * built and the form is wide enough; otherwise the body alone, as it always
136
+ * was, and the reward field places the preview under itself.
137
+ */
138
+ export function ComboPreviewLayout({
139
+ copy,
140
+ formatters,
141
+ groups,
142
+ slots,
143
+ children,
144
+ }: PreviewInputs & { children: ReactNode }): JSX.Element {
145
+ const box = useRef<HTMLDivElement>(null);
146
+ const width = useElementWidth(box);
147
+ // The column exists only while there is a preview to put in it: an empty
148
+ // column beside a combo with no priced group yet is empty space, and the form
149
+ // would be squeezed for nothing.
150
+ const { reward, preview } = useComboPreview({
151
+ copy,
152
+ formatters,
153
+ groups,
154
+ slots,
155
+ });
156
+ const layout: ComboPreviewLayout =
157
+ preview !== null && width !== null && width >= PREVIEW_ASIDE_MIN_PX ? "aside" : "inline";
158
+ const world: PreviewWorld = {
159
+ layout,
160
+ copy,
161
+ formatters,
162
+ groups,
163
+ slots,
164
+ reward,
165
+ preview,
166
+ };
167
+ return (
168
+ <PreviewContext.Provider value={world}>
169
+ <Box
170
+ ref={box}
171
+ data-testid="combo-preview-layout"
172
+ data-layout={layout}
173
+ sx={
174
+ layout === "aside"
175
+ ? {
176
+ display: "grid",
177
+ gridTemplateColumns: "minmax(0, 3fr) minmax(0, 2fr)",
178
+ gap: 3,
179
+ alignItems: "start",
180
+ }
181
+ : { minWidth: 0 }
182
+ }
183
+ >
184
+ <Box sx={{ minWidth: 0 }}>{children}</Box>
185
+ {layout === "aside" && <PreviewBox world={world} />}
186
+ </Box>
187
+ </PreviewContext.Provider>
188
+ );
189
+ }
@@ -0,0 +1,171 @@
1
+ import { fill, type DiscountsWebCopy } from "./copy";
2
+ import type { ComboPreview, ComboRewardValue } from "./combo-preview";
3
+ import type { DiscountsFormatters } from "./format";
4
+
5
+ /**
6
+ * The preview's sentences (FUT-4417), from the figures `comboPreviewOf` worked
7
+ * out. Pure, so every branch below is a unit test rather than a screenshot.
8
+ *
9
+ * Two layouts say the same thing in two shapes, as the approved prototype
10
+ * draws them:
11
+ *
12
+ * - **inline** — under the reward's field, on a phone or a narrow form: two
13
+ * lines, the second carrying the whole range ("Cliente paga de R$ 121,05 a
14
+ * R$ 143,55 (10% a menos)");
15
+ * - **aside** — its own column beside a wide form: the second line short and
16
+ * emphasised ("Cliente paga 10% a menos"), and a third line of examples at
17
+ * both ends of a one-group combo ("Exemplos: 5 de Frango → R$ 121,05 · …").
18
+ *
19
+ * The first line is ONE wording for every reward ("5 itens somam de … a …").
20
+ * The prototype's phone frame said "Itens: de … a …" under the two discounts
21
+ * and "5 marmitas somam…" under the closed price; the data has no noun like
22
+ * "marmitas" for a group, so the pack's own counted noun stands in for both.
23
+ */
24
+ export type ComboPreviewLayout = "inline" | "aside";
25
+
26
+ interface ComboPreviewLines {
27
+ items: string;
28
+ /** What the buyer pays; null while the reward's value is not typed. */
29
+ pays: string | null;
30
+ /** The aside's example line; null inline, for several groups, or when both ends cost the same. */
31
+ examples: string | null;
32
+ }
33
+
34
+ interface Say {
35
+ copy: DiscountsWebCopy;
36
+ money: (cents: number) => string;
37
+ }
38
+
39
+ function itemsLine(preview: ComboPreview, say: Say): string {
40
+ const { combo, comboRewards } = say.copy;
41
+ const units = fill(preview.units === 1 ? combo.unitsOne : combo.unitsOther, {
42
+ count: preview.units,
43
+ });
44
+ const { cheapest, dearest } = preview;
45
+ return cheapest.groupCents === dearest.groupCents
46
+ ? fill(comboRewards.preview.itemsExact, {
47
+ units,
48
+ sum: say.money(cheapest.groupCents),
49
+ })
50
+ : fill(comboRewards.preview.itemsRange, {
51
+ units,
52
+ min: say.money(cheapest.groupCents),
53
+ max: say.money(dearest.groupCents),
54
+ });
55
+ }
56
+
57
+ /** "10%" or "R$ 15,00": what a discount reward takes off, as the operator typed it. */
58
+ function offOf(reward: ComboRewardValue, formatters: DiscountsFormatters): string {
59
+ return reward.type === "PERCENTAGE"
60
+ ? formatters.percent(reward.percentOffBp)
61
+ : formatters.money(reward.amountOffCents);
62
+ }
63
+
64
+ /** The two discounts: a range of what is paid, or the reward itself in the aside. */
65
+ function discountPays(
66
+ preview: ComboPreview,
67
+ reward: ComboRewardValue,
68
+ layout: ComboPreviewLayout,
69
+ say: Say,
70
+ off: string,
71
+ ): string {
72
+ const words = say.copy.comboRewards.preview;
73
+ if (layout === "aside") return fill(words.paysOff, { off });
74
+ const { cheapest, dearest } = preview;
75
+ const rate = reward.type === "PERCENTAGE";
76
+ if (cheapest.paysCents === dearest.paysCents) {
77
+ const pays = say.money(cheapest.paysCents);
78
+ return rate ? fill(words.paysExactOff, { pays, off }) : fill(words.paysExact, { pays });
79
+ }
80
+ const range = {
81
+ min: say.money(cheapest.paysCents),
82
+ max: say.money(dearest.paysCents),
83
+ };
84
+ return rate ? fill(words.paysRangeOff, { ...range, off }) : fill(words.paysRange, range);
85
+ }
86
+
87
+ /** The closed price, in the three places it can sit against the items' range. */
88
+ function pricePays(
89
+ preview: ComboPreview,
90
+ price: number,
91
+ layout: ComboPreviewLayout,
92
+ say: Say,
93
+ ): string {
94
+ const words = say.copy.comboRewards.preview;
95
+ const { cheapest, dearest } = preview;
96
+ const priced = say.money(price);
97
+ // At or above the dearest sum the engine charges the items (`max(0, …)`).
98
+ if (dearest.savingCents === 0) return words.noSaving;
99
+ if (cheapest.savingCents > 0 || cheapest.groupCents === price) {
100
+ if (layout === "aside") return fill(words.paysExact, { pays: priced });
101
+ if (cheapest.savingCents === 0) {
102
+ return fill(words.paysPriceSavingUpTo, {
103
+ price: priced,
104
+ max: say.money(dearest.savingCents),
105
+ });
106
+ }
107
+ return cheapest.savingCents === dearest.savingCents
108
+ ? fill(words.paysPriceSavingExact, {
109
+ price: priced,
110
+ saving: say.money(dearest.savingCents),
111
+ })
112
+ : fill(words.paysPriceSaving, {
113
+ price: priced,
114
+ min: say.money(cheapest.savingCents),
115
+ max: say.money(dearest.savingCents),
116
+ });
117
+ }
118
+ // Between the two: the cheapest combo costs its items, the dearest the price.
119
+ const min = say.money(cheapest.paysCents);
120
+ return layout === "aside"
121
+ ? fill(words.paysRange, { min, max: priced })
122
+ : fill(words.paysUpToPrice, {
123
+ min,
124
+ price: priced,
125
+ max: say.money(dearest.savingCents),
126
+ });
127
+ }
128
+
129
+ function examplesLine(preview: ComboPreview, reward: ComboRewardValue, say: Say): string | null {
130
+ const { single, cheapest, dearest } = preview;
131
+ if (single === null || cheapest.groupCents === dearest.groupCents) return null;
132
+ const words = say.copy.comboRewards.preview;
133
+ const example = (name: string, end: typeof cheapest): string => {
134
+ const values = {
135
+ quantity: single.quantity,
136
+ name,
137
+ pays: say.money(end.paysCents),
138
+ };
139
+ return reward.type === "BUNDLE_PRICE" && end.savingCents > 0
140
+ ? fill(words.exampleSaving, {
141
+ ...values,
142
+ saving: say.money(end.savingCents),
143
+ })
144
+ : fill(words.example, values);
145
+ };
146
+ return fill(words.examples, {
147
+ cheapest: example(single.cheapestName, cheapest),
148
+ dearest: example(single.dearestName, dearest),
149
+ });
150
+ }
151
+
152
+ export function comboPreviewLines(
153
+ preview: ComboPreview,
154
+ reward: ComboRewardValue,
155
+ layout: ComboPreviewLayout,
156
+ copy: DiscountsWebCopy,
157
+ formatters: DiscountsFormatters,
158
+ ): ComboPreviewLines {
159
+ const say: Say = { copy, money: (cents) => formatters.money(cents) };
160
+ const items = itemsLine(preview, say);
161
+ if (!preview.rewardTyped) return { items, pays: null, examples: null };
162
+ const pays =
163
+ reward.type === "BUNDLE_PRICE"
164
+ ? pricePays(preview, reward.bundlePriceCents ?? 0, layout, say)
165
+ : discountPays(preview, reward, layout, say, offOf(reward, formatters));
166
+ return {
167
+ items,
168
+ pays,
169
+ examples: layout === "aside" ? examplesLine(preview, reward, say) : null,
170
+ };
171
+ }
@@ -0,0 +1,217 @@
1
+ import { comboSlotAcceptsLine, freshComboPool, matchCombo } from "../engine/combo-match";
2
+ import type { ComboRequirement, DiscountCartLine, DiscountRule } from "../engine/types";
3
+ import { buildTargetPath } from "../server/collections";
4
+
5
+ import type { WireTarget, WireTargetGroup } from "./api";
6
+ import type { ComboReward } from "./form-kind";
7
+
8
+ /**
9
+ * "Como fica no carrinho" — what one combo's items sum to and what the buyer
10
+ * pays for them, worked out from the store's own prices while the operator is
11
+ * still typing (FUT-4417).
12
+ *
13
+ * The figures are the ENGINE's, not a second derivation of them: each end of
14
+ * the range is a small synthetic cart — every group filled with its cheapest
15
+ * product, then with its dearest — handed to `matchCombo`, so the rounding of a
16
+ * percentage, the cap of a fixed amount at the group's total and the closed
17
+ * price's "never more than the items" (`applicationReward`,
18
+ * `engine/combo-match.ts`) are the ones the cart will apply.
19
+ *
20
+ * It is ONE application, before the cart-level clamps (R4's remainders and
21
+ * R9's payable floor, `ComboMatch.rewardCents`): a preview of the offer, not
22
+ * of a particular cart.
23
+ *
24
+ * Prices come from the targets endpoint's ITEM rows (`priceRangeCents`, its own
25
+ * price and its variations'); a group that can take no priced product makes
26
+ * the whole preview `null`, because a sum with a missing term is not a figure
27
+ * worth showing.
28
+ */
29
+
30
+ /** The reward as numbers; a value left blank or invalid is `null`. */
31
+ export interface ComboRewardValue {
32
+ type: ComboReward;
33
+ percentOffBp: number | null;
34
+ amountOffCents: number | null;
35
+ bundlePriceCents: number | null;
36
+ }
37
+
38
+ /** One end of the range: the combo filled with the cheapest (or dearest) products. */
39
+ export interface ComboPreviewEnd {
40
+ /** What the matched items cost before the reward. */
41
+ groupCents: number;
42
+ /** What the reward takes off them. 0 when the reward's value is not typed yet. */
43
+ savingCents: number;
44
+ /** What the buyer pays for this combo. */
45
+ paysCents: number;
46
+ }
47
+
48
+ export interface ComboPreview {
49
+ /** How many units one combo takes. */
50
+ units: number;
51
+ cheapest: ComboPreviewEnd;
52
+ dearest: ComboPreviewEnd;
53
+ /** Whether the reward has a usable value; without one only the items sum means anything. */
54
+ rewardTyped: boolean;
55
+ /**
56
+ * For a ONE-group combo: the quantity and the product names at each end
57
+ * (as the catalog stores them), for the example line. `null` for several groups.
58
+ */
59
+ single: {
60
+ quantity: number;
61
+ cheapestName: string;
62
+ dearestName: string;
63
+ } | null;
64
+ }
65
+
66
+ interface PricedItem {
67
+ target: WireTarget;
68
+ line: DiscountCartLine;
69
+ minCents: number;
70
+ maxCents: number;
71
+ }
72
+
73
+ /** Every priced ITEM row as the cart line the matcher would see for it. */
74
+ function pricedItems(groups: readonly WireTargetGroup[]): PricedItem[] {
75
+ const parents = new Map<string, string | null>(
76
+ groups
77
+ .filter((group) => group.targetType === "CATEGORY")
78
+ .flatMap((group) => group.targets)
79
+ .map((target) => [target.id, target.parentId ?? null] as const),
80
+ );
81
+ return groups
82
+ .filter((group) => group.targetType === "ITEM")
83
+ .flatMap((group) => group.targets)
84
+ .flatMap((target) => {
85
+ const range = target.priceRangeCents;
86
+ if (!range || range.minCents < 0 || range.maxCents < range.minCents) return [];
87
+ const line: DiscountCartLine = {
88
+ lineId: target.id,
89
+ menuItemId: target.id,
90
+ variationMenuItemId: null,
91
+ categoryPath: buildTargetPath(target.categoryId ?? null, parents),
92
+ quantity: 1,
93
+ unitPriceCents: range.minCents,
94
+ };
95
+ return [{ target, line, minCents: range.minCents, maxCents: range.maxCents }];
96
+ });
97
+ }
98
+
99
+ /** The rule the synthetic carts are matched against: this combo, applied once. */
100
+ function previewRule(slots: readonly ComboRequirement[], reward: ComboRewardValue): DiscountRule {
101
+ const typed = rewardIsTyped(reward);
102
+ return {
103
+ id: "preview",
104
+ name: "preview",
105
+ // An untyped reward is matched as a zero percentage, which takes nothing
106
+ // off and still sums the group.
107
+ type: typed ? reward.type : "PERCENTAGE",
108
+ percentOffBp: typed ? reward.percentOffBp : 0,
109
+ amountOffCents: reward.amountOffCents,
110
+ bundlePriceCents: reward.bundlePriceCents,
111
+ scope: "COMBO",
112
+ targetCategoryIds: [],
113
+ targetMenuItemIds: [],
114
+ comboRequirements: slots,
115
+ maxComboApplications: 1,
116
+ trigger: "AUTOMATIC",
117
+ code: null,
118
+ active: true,
119
+ startsAt: null,
120
+ endsAt: null,
121
+ minSubtotalCents: null,
122
+ usageLimit: null,
123
+ usageCount: 0,
124
+ perBuyerLimit: null,
125
+ buyerUsageCount: 0,
126
+ stackable: true,
127
+ createdAt: new Date(0),
128
+ };
129
+ }
130
+
131
+ function rewardIsTyped(reward: ComboRewardValue): boolean {
132
+ if (reward.type === "PERCENTAGE") return reward.percentOffBp !== null && reward.percentOffBp > 0;
133
+ if (reward.type === "FIXED_AMOUNT")
134
+ return reward.amountOffCents !== null && reward.amountOffCents > 0;
135
+ return reward.bundlePriceCents !== null && reward.bundlePriceCents > 0;
136
+ }
137
+
138
+ /** One end: each group filled by `pick`'s product, priced at `price`, then matched. */
139
+ function endOf(
140
+ slots: readonly ComboRequirement[],
141
+ picks: readonly PricedItem[],
142
+ price: (item: PricedItem) => number,
143
+ rule: DiscountRule,
144
+ ): ComboPreviewEnd | null {
145
+ const lines = picks.map((item, index) => ({
146
+ ...item.line,
147
+ lineId: `${index}:${item.target.id}`,
148
+ quantity: slots[index]?.quantity ?? 0,
149
+ unitPriceCents: price(item),
150
+ }));
151
+ const match = matchCombo(rule, freshComboPool(lines));
152
+ if (match === null) return null;
153
+ return {
154
+ groupCents: match.groupCents,
155
+ savingCents: match.rewardCents,
156
+ paysCents: match.groupCents - match.rewardCents,
157
+ };
158
+ }
159
+
160
+ /** The cheapest and the dearest product one group can take, or null when it can take none. */
161
+ function extremesFor(
162
+ slot: ComboRequirement,
163
+ items: readonly PricedItem[],
164
+ ): { cheapest: PricedItem; dearest: PricedItem } | null {
165
+ // The matcher's own predicate, asked one group at a time.
166
+ const fits = items.filter((item) => comboSlotAcceptsLine(slot, item.line));
167
+ if (fits.length === 0) return null;
168
+ const cheapest = fits.reduce((best, item) => (item.minCents < best.minCents ? item : best));
169
+ const dearest = fits.reduce((best, item) => (item.maxCents > best.maxCents ? item : best));
170
+ return { cheapest, dearest };
171
+ }
172
+
173
+ export function comboPreviewOf(
174
+ slots: readonly ComboRequirement[],
175
+ groups: readonly WireTargetGroup[],
176
+ reward: ComboRewardValue,
177
+ ): ComboPreview | null {
178
+ if (
179
+ slots.length === 0 ||
180
+ slots.some((slot) => !Number.isInteger(slot.quantity) || slot.quantity <= 0)
181
+ ) {
182
+ return null;
183
+ }
184
+ const items = pricedItems(groups);
185
+ const extremes = slots.map((slot) => extremesFor(slot, items));
186
+ if (extremes.some((pair) => pair === null)) return null;
187
+ const pairs = extremes as { cheapest: PricedItem; dearest: PricedItem }[];
188
+ const rule = previewRule(slots, reward);
189
+ const cheapest = endOf(
190
+ slots,
191
+ pairs.map((pair) => pair.cheapest),
192
+ (item) => item.minCents,
193
+ rule,
194
+ );
195
+ const dearest = endOf(
196
+ slots,
197
+ pairs.map((pair) => pair.dearest),
198
+ (item) => item.maxCents,
199
+ rule,
200
+ );
201
+ if (cheapest === null || dearest === null) return null;
202
+ const [only] = pairs;
203
+ return {
204
+ units: slots.reduce((total, slot) => total + slot.quantity, 0),
205
+ cheapest,
206
+ dearest,
207
+ rewardTyped: rewardIsTyped(reward),
208
+ single:
209
+ slots.length === 1 && only
210
+ ? {
211
+ quantity: slots[0]?.quantity ?? 0,
212
+ cheapestName: only.cheapest.target.name,
213
+ dearestName: only.dearest.target.name,
214
+ }
215
+ : null,
216
+ };
217
+ }