@12-apps/discounts 1.5.0 → 1.6.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
@@ -349,21 +349,50 @@ between them.
349
349
  ### Authoring a combo
350
350
 
351
351
  The four discount types and the four scopes are not independent, and the form
352
- is where that stops being a table in a README:
352
+ is where that stops being a table in a README. It asks ONE question — what KIND
353
+ of promotion is this — and derives the pair (`src/react/form-kind.ts`):
353
354
 
354
- | the offer | scope | type | what the operator fills in |
355
+ | the operator picks | type | scope | and is then asked |
355
356
  |---|---|---|---|
356
- | "leve 3, pague 2" | `COMBO` | `FREE_UNITS` | one group of 3, and 1 free |
357
- | "2 refrigerantes, 2 hambúrgueres e 2 batatas por R$ 25" | `COMBO` | `BUNDLE_PRICE` | three groups of 2, and the price |
358
- | the same six items at 20% off | `COMBO` | `PERCENTAGE` | three groups of 2, and the rate |
357
+ | Porcentagem | `PERCENTAGE` | ORDER / CATEGORY / ITEM | the rate, and what it covers |
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 |
360
+ | Itens grátis | `FREE_UNITS` | `COMBO` | the products, how many, how many free |
359
361
 
360
- Picking `COMBO` swaps the single target picker for the **group builder**: each
362
+ So the offers a merchant describes map one to one onto what they fill in:
363
+
364
+ | the offer | kind | what the operator fills in |
365
+ |---|---|---|
366
+ | "leve 3, pague 2" | Itens grátis | 3 hambúrgueres, 1 free |
367
+ | "2 refrigerantes, 2 hambúrgueres e 2 batatas com 20% de desconto" | Combo | three groups of 2, and the rate |
368
+ | the same six items at R$ 5,00 off | Combo | three groups of 2, and the amount |
369
+
370
+ Two consequences of asking the kind instead of the pair:
371
+
372
+ - **`COMBO` is not a scope anybody picks.** A combo covers the groups it is
373
+ made of, so the scope toggle offers the three that are genuinely a choice.
374
+ Offering the fourth is what let an operator build "preço de combo,
375
+ 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.
382
+
383
+ Picking Combo swaps the single target picker for the **group builder**: each
361
384
  group is a quantity plus the rows that can fill it, and a group may name
362
385
  categories AND products at once, because a real menu mixes them — "any drink,
363
386
  or specifically the large fries" is one group. Every registered
364
387
  `DiscountableCollection` gets a picker inside every group, so a host that
365
388
  registers a third dimension gets it here with no change to this package.
366
389
 
390
+ "Itens grátis" gets a builder of its own instead (`free-units-builder.tsx`):
391
+ one group, PRODUCTS only, and the two numbers side by side — "o cliente leva 3,
392
+ 1 sai de graça". It is the same stored shape as a one-group combo, which is
393
+ what lets the two kinds share a validator, a write path and a row; nobody
394
+ describing "leve 3, pague 2" is building a combo, so nobody is asked to.
395
+
367
396
  Three consequences worth stating, because each is a bug this shape prevents:
368
397
 
369
398
  - **The reward input is MOUNTED, not hidden.** The four value columns are
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@12-apps/discounts",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "type": "module",
5
5
  "description": "The discount/promotion domain: a host-agnostic evaluator (percentage or fixed amount, order/category/item scope, automatic or coupon-triggered, stacking and exclusivity, integer-cent allocation) plus the admin CRUD surface as wiring-contract route descriptors over a store port. No database, no framework, no baked user copy.",
6
6
  "exports": {
@@ -28,10 +28,10 @@
28
28
  "typecheck": "tsc --noEmit"
29
29
  },
30
30
  "dependencies": {
31
- "@12-apps/app-shell": "^5.1.0",
31
+ "@12-apps/app-shell": "^5.2.0",
32
32
  "@12-apps/forms-core": "^1.20.0",
33
33
  "@12-apps/shared-helpers": "^2.0.2",
34
- "@12-apps/ui": "^6.3.0",
34
+ "@12-apps/ui": "^6.3.1",
35
35
  "@mui/icons-material": "^6.5.0",
36
36
  "zod": "^4.3.5"
37
37
  },
@@ -368,3 +368,24 @@ export function comboCoveredLineIds(
368
368
  export function comboRuleAcceptsLine(rule: DiscountRule, line: DiscountCartLine): boolean {
369
369
  return prepareSlots(rule.comboRequirements ?? []).some((slot) => slotAccepts(slot, line));
370
370
  }
371
+
372
+ /**
373
+ * Whether ONE slot accepts this line — the same predicate, asked per group.
374
+ *
375
+ * A storefront advertising a combo has to answer a question the cart never
376
+ * does: not "does this product take part" but "which GROUP does it fill", so
377
+ * that "escolha 2 refrigerantes" offers sodas and not burgers. That is
378
+ * `slotAccepts` with nothing added, and it is exported rather than reimplemented
379
+ * because a second copy is how a menu ends up offering a product the matcher
380
+ * will not accept — a combo the buyer can assemble on screen and never earn.
381
+ *
382
+ * It takes a `ComboRequirement` rather than a whole rule, because the caller is
383
+ * iterating a rule's groups and needs to know which one it is looking at.
384
+ */
385
+ export function comboSlotAcceptsLine(
386
+ requirement: ComboRequirement,
387
+ line: DiscountCartLine,
388
+ ): boolean {
389
+ const [slot] = prepareSlots([requirement]);
390
+ return slot !== undefined && slotAccepts(slot, line);
391
+ }
@@ -120,6 +120,32 @@ function toOffer(rule: DiscountRule): ComboOffer {
120
120
  };
121
121
  }
122
122
 
123
+ /**
124
+ * Every combo the store is currently advertising, in the evaluator's canonical
125
+ * rule order.
126
+ *
127
+ * The STORE-level sibling of {@link comboOffersForItem}: same predicate, no
128
+ * item. A storefront that shows combos as their own shelf — "here are the
129
+ * bundles this shop is running" rather than "this card takes part in one" —
130
+ * needs the list before it has a product in hand, and answering it host-side
131
+ * means a second copy of "live enough to advertise" that drifts from this one
132
+ * the first time a rule gains a way to be over.
133
+ *
134
+ * It says nothing about whether the store can still SELL them: a combo whose
135
+ * group has no orderable product left is a question about the host's catalog,
136
+ * which this package cannot see. The caller resolves the groups and drops what
137
+ * it cannot fill.
138
+ */
139
+ export function advertisableCombos(
140
+ rules: readonly DiscountRule[],
141
+ now: Date,
142
+ ): readonly ComboOffer[] {
143
+ return rules
144
+ .filter((rule) => isAdvertisable(rule, now))
145
+ .sort(compareRuleOrder)
146
+ .map(toOffer);
147
+ }
148
+
123
149
  /**
124
150
  * Every live combo one of whose slots this item can fill, in the evaluator's
125
151
  * canonical rule order so a card and a cart never disagree about which combo
package/src/index.ts CHANGED
@@ -77,6 +77,7 @@ export {
77
77
  * say about one.
78
78
  */
79
79
  export {
80
+ advertisableCombos,
80
81
  comboOffersForItem,
81
82
  type ComboOffer,
82
83
  type ComboOffersInput,
@@ -87,8 +88,14 @@ export {
87
88
  * "aplicado 2x", "faltam 2 refrigerantes" — needs the same match the evaluator
88
89
  * priced, and re-deriving it host-side is how a receipt and a cart start
89
90
  * disagreeing about what was in the bundle.
91
+ *
92
+ * `comboSlotAcceptsLine` is the same reason one level down: a storefront
93
+ * ADVERTISING a combo has to fill "escolha 2 refrigerantes" with sodas rather
94
+ * than with everything the combo names, and a host answering that itself is how
95
+ * a menu offers a product the matcher will not accept.
90
96
  */
91
97
  export {
98
+ comboSlotAcceptsLine,
92
99
  freshComboPool,
93
100
  matchCombo,
94
101
  type ComboMatch,
package/src/react/copy.ts CHANGED
@@ -73,6 +73,13 @@ export interface DiscountsScreenCopy {
73
73
 
74
74
  /** The four closed sets, each labelled in full. */
75
75
  export interface DiscountsVocabularyCopy {
76
+ /**
77
+ * The KIND toggle's words — the one question the form actually asks (see
78
+ * `./form-kind`). It is keyed by `DiscountType` because every kind is named
79
+ * after the type it stands for, `COMBO` included: a combo IS a discount, and
80
+ * `BUNDLE_PRICE`'s label survives for the legacy rules that still carry it.
81
+ */
82
+ readonly kind: LabelsFor<DiscountType | "COMBO">;
76
83
  readonly type: LabelsFor<DiscountType>;
77
84
  readonly scope: LabelsFor<DiscountScope>;
78
85
  readonly trigger: LabelsFor<DiscountTrigger>;
@@ -99,6 +106,11 @@ export interface DiscountsFormCopy {
99
106
  readonly submitEdit: string;
100
107
  readonly name: string;
101
108
  readonly type: string;
109
+ /** The kind toggle's own label — the form's first real question. */
110
+ readonly kind: string;
111
+ /** Which of the two rewards a COMBO gives, and the sentence under it. */
112
+ readonly comboReward: string;
113
+ readonly comboRewardHint: string;
102
114
  readonly percentOff: string;
103
115
  /** The example inside the empty percentage input. */
104
116
  readonly percentPlaceholder: string;
@@ -122,6 +134,8 @@ export interface DiscountsFormCopy {
122
134
  /** How many times ONE cart may claim the combo. Blank = as often as it fits. */
123
135
  readonly maxComboApplications: string;
124
136
  readonly maxComboApplicationsHint: string;
137
+ /** The same cap for "leve 3, pague 2", which nobody calls a combo. */
138
+ readonly maxFreeUnitsApplications: string;
125
139
  readonly active: string;
126
140
  readonly activeHint: string;
127
141
  readonly stackable: string;
@@ -142,6 +156,8 @@ export interface DiscountsFormCopy {
142
156
  * that number, so the operator is told the ceiling rather than just refused.
143
157
  */
144
158
  readonly freeUnitsExceedCombo: string;
159
+ /** The free-units builder names PRODUCTS, so its refusal says products. */
160
+ readonly freeUnitsTargetRequired: string;
145
161
  readonly codeRequired: string;
146
162
  readonly categoryTargetRequired: string;
147
163
  readonly itemTargetRequired: string;
@@ -185,6 +201,20 @@ export interface DiscountsComboCopy {
185
201
  * checked without saving it. `{units}` and `{groups}`.
186
202
  */
187
203
  readonly summary: string;
204
+ /**
205
+ * The "leve 3, pague 2" builder (`./free-units-builder`), which is the same
206
+ * stored shape wearing the sentence an operator would actually say. Its own
207
+ * keys rather than the group builder's: "Grupo 1" and "Quantidade" are right
208
+ * for a combo of several groups and wrong for one offer about one shelf.
209
+ */
210
+ readonly freeUnitsTitle: string;
211
+ readonly freeUnitsHint: string;
212
+ /** How many units the customer takes to earn the free ones. */
213
+ readonly buyQuantity: string;
214
+ /** That builder's picker label, `{collection}` — one offer, not one group. */
215
+ readonly freeUnitsPick: string;
216
+ /** The read-back, once both numbers make an offer. `{units}` and `{paid}`. */
217
+ readonly freeUnitsSummary: string;
188
218
  }
189
219
 
190
220
  /** The target pickers, and what an empty selection says. */
@@ -8,28 +8,32 @@ import { Box } from "@12-apps/ui/mui/Box";
8
8
  import { Stack } from "@12-apps/ui/mui/Stack";
9
9
  import { Text } from "@12-apps/ui/typography/Text";
10
10
 
11
- import { DISCOUNT_SCOPES, DISCOUNT_TRIGGERS, DISCOUNT_TYPES } from "../engine/kinds";
11
+ import { DISCOUNT_TRIGGERS } from "../engine/kinds";
12
12
 
13
13
  import type { DiscountsWebCopy } from "./copy";
14
+ import { COMBO_REWARDS, isComboKind, SELECTABLE_DISCOUNT_SCOPES, type DiscountKind } from "./form-kind";
14
15
 
15
16
  /**
16
17
  * The form's inputs, split from the form itself for the 400-line file gate —
17
18
  * comments count toward it, and the container plus these would not fit.
18
19
  *
19
- * Every option list is built FROM the vocabulary arrays rather than spelled out
20
+ * Every option list is built FROM a vocabulary array rather than spelled out
20
21
  * again: those arrays back the database's CHECK constraints, so an option an
21
22
  * operator can pick is by construction a value the column will store. A
22
23
  * hand-written list is how a form offers a fifth type the database refuses.
24
+ *
25
+ * The KIND is what the form asks first, and every other input here is a
26
+ * consequence of it (see `./form-kind` for the mapping and why it exists). That
27
+ * is the difference from the version this replaces, where type and scope were
28
+ * two free choices and most of their sixteen combinations were not offers.
23
29
  */
24
30
 
25
31
  /**
26
32
  * A field with a sentence under it.
27
33
  *
28
34
  * `Fields.TextField` has no helper-text slot — deliberately, it is a thin bind
29
- * over `Input` — and two of the combo inputs are meaningless without one:
30
- * "itens grátis" and "combos por pedido" are both numbers whose UNIT an
31
- * operator cannot guess. So the caption is composed here rather than pushed
32
- * into `@12-apps/ui`, where it would be a prop every other consumer ignores.
35
+ * over `Input` — and the combo cap is meaningless without one: "combos por
36
+ * pedido" is a number whose blank value an operator cannot guess.
33
37
  */
34
38
  function HintedField({
35
39
  name,
@@ -57,41 +61,106 @@ function optionsFor<TKey extends string>(
57
61
  }
58
62
 
59
63
  /**
60
- * The reward, in whichever unit the chosen type actually stores.
64
+ * A money input, supplied by the host.
61
65
  *
62
- * One input per type, and the wrong one must never be mounted: the four value
66
+ * Currency entry is a host decision — masking, the symbol's side, whether cents
67
+ * are typed or implied — and `@12-apps/ui` has no opinionated one. So the
68
+ * surface takes the component rather than shipping a guess; a host with nothing
69
+ * special passes a plain text field.
70
+ */
71
+ export type CurrencyFieldComponent = (props: { name: string; label: string }) => JSX.Element;
72
+
73
+ /**
74
+ * What kind of promotion this is — the form's first and most consequential
75
+ * question, and the only one that decides what the rest of it looks like.
76
+ *
77
+ * The option list comes from the caller rather than from the constant, because
78
+ * it is one longer when a legacy `BUNDLE_PRICE` rule is being edited.
79
+ */
80
+ export function KindField({
81
+ copy,
82
+ kinds,
83
+ }: {
84
+ copy: DiscountsWebCopy;
85
+ kinds: readonly DiscountKind[];
86
+ }): JSX.Element {
87
+ return (
88
+ <Fields.ToggleField
89
+ name="kind"
90
+ label={copy.form.kind}
91
+ options={optionsFor(kinds, copy.labels.kind)}
92
+ />
93
+ );
94
+ }
95
+
96
+ /**
97
+ * A combo's reward: which of the two, and how much.
98
+ *
99
+ * Both are here rather than split between the top of the form and the bottom,
100
+ * because to a merchant they are one sentence — "este combo dá 15% de
101
+ * desconto". The two are the ONLY rewards a combo can give: a flat bundle price
102
+ * reprices the group and goes silently wrong the first time one of its items
103
+ * changes price, so it is not offered (see `./form-kind`).
104
+ */
105
+ function ComboRewardFields({
106
+ copy,
107
+ currencyField,
108
+ }: {
109
+ copy: DiscountsWebCopy;
110
+ currencyField: CurrencyFieldComponent;
111
+ }): JSX.Element {
112
+ const { values } = useFormContext();
113
+ const Currency = currencyField;
114
+ return (
115
+ <Stack spacing={0.5}>
116
+ <Box sx={{ display: "grid", gap: 2, gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" } }}>
117
+ <Fields.ToggleField
118
+ name="comboReward"
119
+ label={copy.form.comboReward}
120
+ options={optionsFor(COMBO_REWARDS, copy.labels.type)}
121
+ />
122
+ {values.comboReward === "FIXED_AMOUNT" ? (
123
+ <Currency name="amountOff" label={copy.form.amountOff} />
124
+ ) : (
125
+ <Fields.TextField
126
+ name="percentOff"
127
+ label={copy.form.percentOff}
128
+ placeholder={copy.form.percentPlaceholder}
129
+ />
130
+ )}
131
+ </Box>
132
+ <Text variant="caption">{copy.form.comboRewardHint}</Text>
133
+ </Stack>
134
+ );
135
+ }
136
+
137
+ /**
138
+ * The reward, in whichever unit the chosen KIND actually stores.
139
+ *
140
+ * One input per kind, and the wrong one must never be mounted: the four value
63
141
  * columns are mutually exclusive at the database (`discounts_value_check`), so
64
142
  * a leftover value from another branch is a 500 on a form the operator filled
65
- * in correctly. Mounting rather than hiding is what guarantees it — an unmounted
66
- * field cannot carry a stale number to submit.
143
+ * in correctly. Mounting rather than hiding is what guarantees it — an
144
+ * unmounted field cannot carry a stale number to submit.
67
145
  *
68
- * The two combo rewards read differently from the two plain ones, and that is
69
- * the whole point of FUT-268: BUNDLE_PRICE is what the matched group COSTS
70
- * ("2 refrigerantes, 2 hambúrgueres e 2 batatas por R$ 49,90"), FREE_UNITS is
71
- * how many of the matched units are given away ("leve 3, pague 2"). Both are
72
- * money-or-count fields like the two above them; what makes them combos is the
73
- * scope, and the scope is what the builder below hangs off.
146
+ * `FREE_UNITS` is the one kind with nothing here: its reward is a count of the
147
+ * units it gives away, which only reads as an offer beside the units it takes,
148
+ * so the free-units builder carries both.
74
149
  */
75
- function ValueField({
150
+ export function RewardFields({
76
151
  copy,
77
152
  currencyField,
78
153
  }: {
79
154
  copy: DiscountsWebCopy;
80
155
  currencyField: CurrencyFieldComponent;
81
- }): JSX.Element {
156
+ }): JSX.Element | null {
82
157
  const { values } = useFormContext();
83
158
  const Currency = currencyField;
84
- if (values.type === "FIXED_AMOUNT") {
85
- return <Currency name="amountOff" label={copy.form.amountOff} />;
86
- }
87
- if (values.type === "BUNDLE_PRICE") {
88
- return <Currency name="bundlePrice" label={copy.form.bundlePrice} />;
89
- }
90
- if (values.type === "FREE_UNITS") {
91
- return (
92
- <HintedField name="freeUnits" label={copy.form.freeUnits} hint={copy.form.freeUnitsHint} />
93
- );
94
- }
159
+ const kind = values.kind ?? "PERCENTAGE";
160
+ if (kind === "COMBO") return <ComboRewardFields copy={copy} currencyField={currencyField} />;
161
+ if (kind === "FREE_UNITS") return null;
162
+ if (kind === "BUNDLE_PRICE") return <Currency name="bundlePrice" label={copy.form.bundlePrice} />;
163
+ if (kind === "FIXED_AMOUNT") return <Currency name="amountOff" label={copy.form.amountOff} />;
95
164
  return (
96
165
  <Fields.TextField
97
166
  name="percentOff"
@@ -101,16 +170,6 @@ function ValueField({
101
170
  );
102
171
  }
103
172
 
104
- /**
105
- * A money input, supplied by the host.
106
- *
107
- * Currency entry is a host decision — masking, the symbol's side, whether cents
108
- * are typed or implied — and `@12-apps/ui` has no opinionated one. So the
109
- * surface takes the component rather than shipping a guess; a host with nothing
110
- * special passes a plain text field.
111
- */
112
- export type CurrencyFieldComponent = (props: { name: string; label: string }) => JSX.Element;
113
-
114
173
  /** The coupon input, shown only for a CODE-triggered rule. */
115
174
  function CodeField({ copy }: { copy: DiscountsWebCopy }): JSX.Element | null {
116
175
  const { values } = useFormContext();
@@ -124,22 +183,10 @@ function CodeField({ copy }: { copy: DiscountsWebCopy }): JSX.Element | null {
124
183
  );
125
184
  }
126
185
 
127
- /** Type + value + trigger + code, the four that decide WHAT and HOW. */
128
- export function RuleFields({
129
- copy,
130
- currencyField,
131
- }: {
132
- copy: DiscountsWebCopy;
133
- currencyField: CurrencyFieldComponent;
134
- }): JSX.Element {
186
+ /** What makes the promotion fire, and the code it fires on. */
187
+ export function TriggerFields({ copy }: { copy: DiscountsWebCopy }): JSX.Element {
135
188
  return (
136
189
  <Box sx={{ display: "grid", gap: 2, gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" } }}>
137
- <Fields.ToggleField
138
- name="type"
139
- label={copy.form.type}
140
- options={optionsFor(DISCOUNT_TYPES, copy.labels.type)}
141
- />
142
- <ValueField copy={copy} currencyField={currencyField} />
143
190
  <Fields.ToggleField
144
191
  name="trigger"
145
192
  label={copy.form.trigger}
@@ -150,13 +197,22 @@ export function RuleFields({
150
197
  );
151
198
  }
152
199
 
153
- /** The scope toggle, which decides which target picker is shown below it. */
154
- export function ScopeField({ copy }: { copy: DiscountsWebCopy }): JSX.Element {
200
+ /**
201
+ * What the discount covers — and ONLY for the kinds where that is a question.
202
+ *
203
+ * A combo covers the groups it is made of, so `COMBO` scope is not one of four
204
+ * choices, it is what those two kinds mean. Offering it anyway is what let an
205
+ * operator build "preço de combo, abrangência: pedido" and have the write path
206
+ * refuse it.
207
+ */
208
+ export function ScopeField({ copy }: { copy: DiscountsWebCopy }): JSX.Element | null {
209
+ const { values } = useFormContext();
210
+ if (isComboKind(values.kind ?? "PERCENTAGE")) return null;
155
211
  return (
156
212
  <Fields.ToggleField
157
213
  name="scope"
158
214
  label={copy.form.scope}
159
- options={optionsFor(DISCOUNT_SCOPES, copy.labels.scope)}
215
+ options={optionsFor(SELECTABLE_DISCOUNT_SCOPES, copy.labels.scope)}
160
216
  />
161
217
  );
162
218
  }
@@ -164,18 +220,30 @@ export function ScopeField({ copy }: { copy: DiscountsWebCopy }): JSX.Element {
164
220
  /**
165
221
  * How many times one cart may claim the combo.
166
222
  *
167
- * Only at COMBO scope, because it is the only scope where the question means
223
+ * Only for a combo kind, because it is the only place the question means
168
224
  * anything: an order-wide or category discount applies once by construction. A
169
225
  * blank field is "as often as it fits", which is the merchant's usual answer
170
226
  * and therefore the default rather than a number they have to type.
227
+ *
228
+ * It sits with the builder rather than with the redemption limits next to it:
229
+ * "combos por pedido" is a fact about THIS combo, and among "limite de usos"
230
+ * and "limite por cliente" it read as a third redemption cap.
171
231
  */
172
- function ComboCapField({ copy }: { copy: DiscountsWebCopy }): JSX.Element | null {
232
+ export function ComboCapField({ copy }: { copy: DiscountsWebCopy }): JSX.Element | null {
173
233
  const { values } = useFormContext();
174
- if (values.scope !== "COMBO") return null;
234
+ const kind = values.kind ?? "PERCENTAGE";
235
+ if (!isComboKind(kind)) return null;
175
236
  return (
176
237
  <HintedField
177
238
  name="maxComboApplications"
178
- label={copy.form.maxComboApplications}
239
+ // Same column, same rule, two words for it: nobody calls "leve 3, pague
240
+ // 2" a combo, so "Combos por pedido" reads as a control for a promotion
241
+ // the operator is not creating.
242
+ label={
243
+ kind === "FREE_UNITS"
244
+ ? copy.form.maxFreeUnitsApplications
245
+ : copy.form.maxComboApplications
246
+ }
179
247
  hint={copy.form.maxComboApplicationsHint}
180
248
  />
181
249
  );
@@ -205,7 +273,6 @@ export function LimitFields({
205
273
  <Currency name="minSubtotal" label={copy.form.minSubtotal} />
206
274
  <Fields.TextField name="usageLimit" label={copy.form.usageLimit} type="number" />
207
275
  <Fields.TextField name="perBuyerLimit" label={copy.form.perBuyerLimit} type="number" />
208
- <ComboCapField copy={copy} />
209
276
  </Box>
210
277
  );
211
278
  }