@12-apps/discounts 1.16.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.
@@ -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
+ }
@@ -0,0 +1,59 @@
1
+ import type { ComboReward } from "./form-kind";
2
+
3
+ /**
4
+ * The combo reward's words (FUT-4417): the three choices, the sentence under
5
+ * each, and "Como fica no carrinho" — what the group's items sum to and what
6
+ * the buyer pays for them under this reward.
7
+ *
8
+ * A group of its own rather than more `form` keys because the preview is a
9
+ * small grammar of its own: which sentence is said depends on the reward, on
10
+ * whether the items' prices are one figure or a range, and on whether the form
11
+ * is wide enough to put the preview in its own column (where it reads in short
12
+ * sentences plus an example line) or under the field (one sentence carrying
13
+ * the range). Each placeholder is a figure the package has already formatted.
14
+ */
15
+ export interface DiscountsComboPreviewCopy {
16
+ /** The box's heading. */
17
+ readonly title: string;
18
+ /** `{units}` ("5 itens"), `{min}`, `{max}`: what one combo's items sum to. */
19
+ readonly itemsRange: string;
20
+ /** The same when every item the groups can take costs the same. `{units}`, `{sum}`. */
21
+ readonly itemsExact: string;
22
+ /** `{min}`, `{max}`: what the buyer pays, as a range. */
23
+ readonly paysRange: string;
24
+ /** `{min}`, `{max}`, `{off}` (the rate, "10%"): the percentage's range, under the field. */
25
+ readonly paysRangeOff: string;
26
+ /** `{pays}`: one figure. */
27
+ readonly paysExact: string;
28
+ /** `{pays}`, `{off}`: one figure, at a rate. */
29
+ readonly paysExactOff: string;
30
+ /** `{off}` — "10%" or "R$ 15,00": the reward itself, in the wide layout. */
31
+ readonly paysOff: string;
32
+ /** Closed price under every items sum: `{price}`, `{min}`, `{max}` (the saving's range). */
33
+ readonly paysPriceSaving: string;
34
+ /** The same with one saving figure: `{price}`, `{saving}`. */
35
+ readonly paysPriceSavingExact: string;
36
+ /**
37
+ * Closed price EQUAL to the cheapest sum: the cheapest combo saves nothing,
38
+ * so the saving reads "up to" rather than "from R$ 0,00 to": `{price}`, `{max}`.
39
+ */
40
+ readonly paysPriceSavingUpTo: string;
41
+ /** Closed price between the cheapest and dearest sums: `{min}`, `{price}`, `{max}` (most saved). */
42
+ readonly paysUpToPrice: string;
43
+ /** Closed price at or above the dearest sum: nothing is saved. */
44
+ readonly noSaving: string;
45
+ /** The wide layout's third line: `{cheapest}` and `{dearest}`, each an `example`. */
46
+ readonly examples: string;
47
+ /** `{quantity}`, `{name}` (as the catalog stores it), `{pays}`. */
48
+ readonly example: string;
49
+ /** The same with what it saves: `{quantity}`, `{name}`, `{pays}`, `{saving}`. */
50
+ readonly exampleSaving: string;
51
+ }
52
+
53
+ export interface DiscountsComboRewardsCopy {
54
+ /** The toggle's three choices, in `COMBO_REWARDS` order. */
55
+ readonly labels: Readonly<Record<ComboReward, string>>;
56
+ /** The sentence under the reward's field, one per choice. */
57
+ readonly hints: Readonly<Record<ComboReward, string>>;
58
+ readonly preview: DiscountsComboPreviewCopy;
59
+ }
@@ -0,0 +1,162 @@
1
+ "use client";
2
+
3
+ import { useLayoutEffect, useRef, useState, type JSX, type RefObject } from "react";
4
+
5
+ import { Fields, useFormContext } from "@12-apps/ui/form/total-form";
6
+ import { Box } from "@12-apps/ui/mui/Box";
7
+ import { Stack } from "@12-apps/ui/mui/Stack";
8
+ import { Text } from "@12-apps/ui/typography/Text";
9
+
10
+ import type { DiscountsWebCopy } from "./copy";
11
+ import { InlineComboCartPreview } from "./combo-cart-preview";
12
+ import type { CurrencyFieldComponent } from "./discount-form-fields";
13
+ import { COMBO_REWARDS, comboRewardOf } from "./form-kind";
14
+ import { useElementWidth } from "./kind-field";
15
+ import { PercentField } from "./number-fields";
16
+
17
+ /**
18
+ * A combo's reward: which of the three, how much, and what it comes to in the
19
+ * cart (FUT-4417).
20
+ *
21
+ * All of it is here rather than split between the top of the form and the
22
+ * bottom, because to a merchant it is one sentence — "este combo dá 15% de
23
+ * desconto", "…sai por R$ 120". "Preço fechado" is the third choice: the
24
+ * matched group costs exactly the price typed, whatever its items cost. That
25
+ * used to be refused as a choice because it goes on charging the same when an
26
+ * item's price changes — which is the point of a closed price, so the hint says
27
+ * it in those words and the preview shows it against the store's real prices.
28
+ *
29
+ * **One line at every width** (ADR 0190 in the host), like the kind question
30
+ * (`./kind-field`): a segmented control of three equal parts where they fit,
31
+ * the same field as a select where they do not. The value's field sits beside
32
+ * the choices where both fit on one row, and under them otherwise.
33
+ */
34
+
35
+ /**
36
+ * What one part needs before a label wraps inside it, when nothing could
37
+ * measure it (a test DOM): the kind toggle's own figure for "Porcentagem".
38
+ */
39
+ const FALLBACK_PART_PX = 136;
40
+
41
+ /** The gap between the choices and the value's field, `gap: 2`. */
42
+ const ROW_GAP_PX = 16;
43
+
44
+ const PART_SELECTOR = '[data-testid^="total-form-field-comboReward-item-"]';
45
+
46
+ /**
47
+ * A part's width with its label on ONE line: a hidden, unwrapped clone of it,
48
+ * measured beside it so it inherits the same type.
49
+ */
50
+ function naturalWidth(part: HTMLElement): number {
51
+ const clone = part.cloneNode(true) as HTMLElement;
52
+ Object.assign(clone.style, {
53
+ position: "absolute",
54
+ visibility: "hidden",
55
+ width: "auto",
56
+ minWidth: "0",
57
+ flex: "none",
58
+ whiteSpace: "nowrap",
59
+ });
60
+ (part.parentElement ?? document.body).appendChild(clone);
61
+ const width = clone.getBoundingClientRect().width;
62
+ clone.remove();
63
+ return width;
64
+ }
65
+
66
+ /**
67
+ * The width the three equal parts need, measured off the toggle the first time
68
+ * it is on screen — the theme decides the type size and the padding, so a
69
+ * figure typed here would be wrong in the next density mode. Parts are equal,
70
+ * so the widest label sizes all three; the group's own padding and gaps are
71
+ * added on top.
72
+ */
73
+ function useTogglePartsWidth(box: RefObject<HTMLElement | null>): number {
74
+ const [measured, setMeasured] = useState<number | null>(null);
75
+ useLayoutEffect(() => {
76
+ if (measured !== null || box.current === null) return;
77
+ const parts = [...box.current.querySelectorAll<HTMLElement>(PART_SELECTOR)];
78
+ const group = parts[0]?.parentElement;
79
+ if (parts.length === 0 || !group) return;
80
+ const widest = Math.max(...parts.map(naturalWidth));
81
+ if (widest <= 0) return;
82
+ const style = getComputedStyle(group);
83
+ const chrome =
84
+ (parseFloat(style.paddingLeft) || 0) +
85
+ (parseFloat(style.paddingRight) || 0) +
86
+ (parseFloat(style.columnGap) || 0) * (parts.length - 1);
87
+ // +1 px a part: sub-pixel widths round the other way often enough to wrap.
88
+ setMeasured(Math.ceil((widest + 1) * parts.length + chrome));
89
+ });
90
+ return measured ?? COMBO_REWARDS.length * FALLBACK_PART_PX;
91
+ }
92
+
93
+ /** The value's input for the chosen reward; the other two are unmounted (`discounts_value_check`). */
94
+ function RewardValueField({
95
+ reward,
96
+ copy,
97
+ currencyField,
98
+ }: {
99
+ reward: string;
100
+ copy: DiscountsWebCopy;
101
+ currencyField: CurrencyFieldComponent;
102
+ }): JSX.Element {
103
+ const Currency = currencyField;
104
+ if (reward === "BUNDLE_PRICE")
105
+ return <Currency name="bundlePrice" label={copy.form.bundlePrice} />;
106
+ if (reward === "FIXED_AMOUNT") return <Currency name="amountOff" label={copy.form.amountOff} />;
107
+ return <PercentField copy={copy} />;
108
+ }
109
+
110
+ export function ComboRewardFields({
111
+ copy,
112
+ currencyField,
113
+ }: {
114
+ copy: DiscountsWebCopy;
115
+ currencyField: CurrencyFieldComponent;
116
+ }): JSX.Element {
117
+ const { values } = useFormContext();
118
+ const box = useRef<HTMLDivElement>(null);
119
+ const width = useElementWidth(box);
120
+ const needed = useTogglePartsWidth(box);
121
+ const reward = comboRewardOf({ type: values.comboReward ?? "" });
122
+ const options = COMBO_REWARDS.map((value) => ({
123
+ value,
124
+ label: copy.comboRewards.labels[value],
125
+ }));
126
+ const sideBySide = width === null || width >= 2 * needed + ROW_GAP_PX;
127
+ const column = width === null ? null : sideBySide ? (width - ROW_GAP_PX) / 2 : width;
128
+ const fits = column === null || column >= needed;
129
+ return (
130
+ <Stack spacing={0.5} ref={box} sx={{ minWidth: 0 }}>
131
+ <Box
132
+ data-testid="combo-reward-row"
133
+ sx={{
134
+ display: "grid",
135
+ gap: 2,
136
+ gridTemplateColumns: sideBySide ? "1fr 1fr" : "1fr",
137
+ }}
138
+ >
139
+ <Box sx={{ minWidth: 0 }}>
140
+ {fits ? (
141
+ <Fields.ToggleField
142
+ name="comboReward"
143
+ label={copy.form.comboReward}
144
+ options={options}
145
+ />
146
+ ) : (
147
+ <Fields.SelectField
148
+ name="comboReward"
149
+ label={copy.form.comboReward}
150
+ options={options}
151
+ />
152
+ )}
153
+ </Box>
154
+ <RewardValueField reward={reward} copy={copy} currencyField={currencyField} />
155
+ </Box>
156
+ <Text variant="caption" data-testid="combo-reward-hint">
157
+ {copy.comboRewards.hints[reward]}
158
+ </Text>
159
+ <InlineComboCartPreview />
160
+ </Stack>
161
+ );
162
+ }
@@ -1,10 +1,9 @@
1
1
  "use client";
2
2
 
3
- import type { JSX } from "react";
3
+ import { useRef, type JSX } from "react";
4
4
 
5
5
  import { Button } from "@12-apps/ui/form/Button";
6
6
  import { FormControl, FormLabel, FormMessage } from "@12-apps/ui/form/Form";
7
- import { Input } from "@12-apps/ui/form/Input";
8
7
  import { Box } from "@12-apps/ui/mui/Box";
9
8
  import { Stack } from "@12-apps/ui/mui/Stack";
10
9
  import { Text } from "@12-apps/ui/typography/Text";
@@ -13,6 +12,7 @@ import type { ComboRequirement } from "../engine/types";
13
12
 
14
13
  import type { WireTargetGroup } from "./api";
15
14
  import { fill, type DiscountsWebCopy } from "./copy";
15
+ import { CountInput } from "./number-fields";
16
16
  import { TargetPickerField } from "./target-picker-field";
17
17
 
18
18
  /**
@@ -48,11 +48,45 @@ export function comboUnits(slots: readonly ComboRequirement[]): number {
48
48
  return slots.reduce((total, slot) => total + (Number.isFinite(slot.quantity) ? slot.quantity : 0), 0);
49
49
  }
50
50
 
51
+ /** "1 item" or "4 itens": the pack's one form for exactly 1, its other form otherwise. */
52
+ function counted(count: number, one: string, other: string): string {
53
+ return fill(count === 1 ? one : other, { count });
54
+ }
55
+
56
+ /** The read-back sentence, both nouns in the right number. */
57
+ function comboSummary(slots: readonly ComboRequirement[], copy: DiscountsWebCopy): string {
58
+ const { combo } = copy;
59
+ return fill(combo.summary, {
60
+ units: counted(comboUnits(slots), combo.unitsOne, combo.unitsOther),
61
+ groups: counted(slots.length, combo.groupsOne, combo.groupsOther),
62
+ });
63
+ }
64
+
51
65
  /** An empty group: one unit, nothing chosen yet. */
52
66
  export function blankSlot(): ComboRequirement {
53
67
  return { quantity: 1, categoryIds: [], menuItemIds: [] };
54
68
  }
55
69
 
70
+ /**
71
+ * One screen-only id per group, in list order (FUT-4143).
72
+ *
73
+ * A group has no id until the database gives it one, and an index key hands a
74
+ * removed row's component — and its half-typed draft — to the row that moves
75
+ * up. These ids live here and nowhere else: the wire still carries the plain
76
+ * `ComboRequirement`s. Appends get a fresh id; a removal drops its own (see
77
+ * `onRemove`); a list replaced from outside is padded or cut to length.
78
+ */
79
+ function useRowIds(length: number): { current: number[] } {
80
+ const ids = useRef<number[]>([]);
81
+ const next = useRef(0);
82
+ while (ids.current.length < length) {
83
+ ids.current = [...ids.current, next.current];
84
+ next.current += 1;
85
+ }
86
+ if (ids.current.length > length) ids.current = ids.current.slice(0, length);
87
+ return ids;
88
+ }
89
+
56
90
  /** Replace one group, leaving the rest (and their order) alone. */
57
91
  function replaceAt(
58
92
  slots: readonly ComboRequirement[],
@@ -75,8 +109,9 @@ function withIds(slot: ComboRequirement, targetType: string, ids: string[]): Com
75
109
  * One group: its quantity, one picker per registered collection, and the
76
110
  * control that removes it.
77
111
  *
78
- * The quantity is a `number` input rather than a masked one — it counts units,
79
- * not money, so there is no locale in it. It is held as a NUMBER in state
112
+ * The quantity is a digits-only text box (`CountInput`, FUT-4143) rather than
113
+ * `type="number"`, which read "2,5" by the browser's language — as 25 in an
114
+ * English one. It is held as a NUMBER in state
80
115
  * rather than as the form's string, because the summary line above adds them up
81
116
  * and the validator compares them against `freeUnits`; parsing on every read
82
117
  * would put the same `Number()` in three places.
@@ -117,12 +152,13 @@ function ComboSlotRow({
117
152
  </Button>
118
153
  </Stack>
119
154
  <Stack spacing={2} sx={{ mt: 1 }}>
120
- <Input
121
- type="number"
155
+ <CountInput
122
156
  label={copy.combo.quantity}
123
- value={String(slot.quantity)}
124
- onChange={(event) => onChange({ ...slot, quantity: Number(event.target.value) })}
125
- data-testid={`combo-slot-${index}-quantity`}
157
+ value={slot.quantity}
158
+ copy={copy}
159
+ invalidMessage={copy.form.invalidComboQuantity}
160
+ dataTestId={`combo-slot-${index}-quantity`}
161
+ onChange={(quantity) => onChange({ ...slot, quantity })}
126
162
  />
127
163
  {groups.map((group) => (
128
164
  <TargetPickerField
@@ -170,6 +206,7 @@ export function ComboSlotBuilder({
170
206
  copy: DiscountsWebCopy;
171
207
  onChange: (next: ComboRequirement[]) => void;
172
208
  }): JSX.Element {
209
+ const rowIds = useRowIds(slots.length);
173
210
  return (
174
211
  <FormControl fullWidth>
175
212
  <FormLabel>{copy.combo.title}</FormLabel>
@@ -177,17 +214,19 @@ export function ComboSlotBuilder({
177
214
  <Stack spacing={2} sx={{ mt: 1 }} data-testid="combo-slots">
178
215
  {slots.map((slot, index) => (
179
216
  <ComboSlotRow
180
- // The index IS the identity here: `position` is the array index,
181
- // and a group carries no id of its own until the database gives it
182
- // one. Removing a group renumbers the rest, which is exactly what
183
- // the operator sees happen.
184
- key={index}
217
+ // `position` is the array index, and removing a group renumbers
218
+ // the rest — but the ROW is keyed by a screen-only id, so the
219
+ // group below does not inherit the removed one's draft.
220
+ key={rowIds.current[index]}
185
221
  slot={slot}
186
222
  index={index}
187
223
  groups={groups}
188
224
  copy={copy}
189
225
  onChange={(next) => onChange(replaceAt(slots, index, next))}
190
- onRemove={() => onChange(slots.filter((_, position) => position !== index))}
226
+ onRemove={() => {
227
+ rowIds.current = rowIds.current.filter((_, position) => position !== index);
228
+ onChange(slots.filter((_, position) => position !== index));
229
+ }}
191
230
  />
192
231
  ))}
193
232
  {slots.length === 0 && (
@@ -195,20 +234,27 @@ export function ComboSlotBuilder({
195
234
  {copy.combo.empty}
196
235
  </Text>
197
236
  )}
198
- <Stack direction="row" alignItems="center" justifyContent="space-between" spacing={2}>
199
- <Button
200
- variant="outline"
201
- onClick={() => onChange([...slots, blankSlot()])}
202
- data-testid="combo-add-slot"
203
- >
204
- {copy.combo.addSlot}
205
- </Button>
237
+ {/* Wraps as a ROW, never inside the button: where the note does not
238
+ fit beside "Adicionar grupo" it takes the line below, and the
239
+ button's own label stays on one line (FUT-4143 UI review). */}
240
+ <Box sx={{ display: "flex", flexWrap: "wrap", alignItems: "center", columnGap: 2, rowGap: 1 }}>
241
+ <Box sx={{ flexShrink: 0, "& > button": { whiteSpace: "nowrap" } }}>
242
+ <Button
243
+ variant="outline"
244
+ onClick={() => onChange([...slots, blankSlot()])}
245
+ data-testid="combo-add-slot"
246
+ >
247
+ {copy.combo.addSlot}
248
+ </Button>
249
+ </Box>
206
250
  {slots.length > 0 && (
207
- <Text variant="caption" data-testid="combo-summary">
208
- {fill(copy.combo.summary, { units: comboUnits(slots), groups: slots.length })}
209
- </Text>
251
+ <Box sx={{ flex: "1 1 200px", minWidth: 0 }}>
252
+ <Text variant="caption" data-testid="combo-summary">
253
+ {comboSummary(slots, copy)}
254
+ </Text>
255
+ </Box>
210
256
  )}
211
- </Stack>
257
+ </Box>
212
258
  </Stack>
213
259
  </FormControl>
214
260
  );