@12-apps/discounts 1.3.0 → 1.4.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
@@ -341,9 +341,44 @@ export const discounts = createWebDiscounts({
341
341
  ```
342
342
 
343
343
  What that one call replaces: a server-driven grid with eight columns, five
344
- filter pills and a CSV/JSON export; a create/edit form of fourteen inputs, four
345
- of which appear only when another says so; the target pickers; two card
346
- layouts; four confirmation popups; and every wire call between them.
344
+ filter pills and a CSV/JSON export; a create/edit form whose value input and
345
+ target block both follow the toggles above them; the target pickers and the
346
+ combo builder; two card layouts; four confirmation popups; and every wire call
347
+ between them.
348
+
349
+ ### Authoring a combo
350
+
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:
353
+
354
+ | the offer | scope | type | what the operator fills in |
355
+ |---|---|---|---|
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 |
359
+
360
+ Picking `COMBO` swaps the single target picker for the **group builder**: each
361
+ group is a quantity plus the rows that can fill it, and a group may name
362
+ categories AND products at once, because a real menu mixes them — "any drink,
363
+ or specifically the large fries" is one group. Every registered
364
+ `DiscountableCollection` gets a picker inside every group, so a host that
365
+ registers a third dimension gets it here with no change to this package.
366
+
367
+ Three consequences worth stating, because each is a bug this shape prevents:
368
+
369
+ - **The reward input is MOUNTED, not hidden.** The four value columns are
370
+ mutually exclusive at the database, so a leftover number from a branch the
371
+ operator flipped away from is a 500 on a form they filled in correctly.
372
+ - **`freeUnits` is bounded by the groups.** "Take 3, three free" is a giveaway,
373
+ so the form compares it against what one application takes out of the cart
374
+ and names the ceiling when it refuses.
375
+ - **Position is the array index.** The list an operator builds is the list a
376
+ card reads back; two groups of the same size are otherwise indistinguishable
377
+ once stored, which is why `toTargetRows` stamps the index as `position`.
378
+
379
+ `maxComboApplications` appears at `COMBO` scope and nowhere else — it is the
380
+ only scope where "how many times may one cart claim this" means anything — and
381
+ blank means as often as it fits.
347
382
 
348
383
  **Build it once, at module scope.** The members are component TYPES, so
349
384
  rebuilding per render gives React new types every time and remounts the whole
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@12-apps/discounts",
3
- "version": "1.3.0",
3
+ "version": "1.4.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": {
package/src/react/api.ts CHANGED
@@ -80,6 +80,12 @@ export interface DiscountFormPayload {
80
80
  percentOff: string;
81
81
  /** A money amount, as the currency field stores it. */
82
82
  amountOff: string;
83
+ /** What the matched combo group costs, as the currency field stores it. */
84
+ bundlePrice: string;
85
+ /** How many of the matched units are free, as typed. */
86
+ freeUnits: string;
87
+ /** How many combos one cart may claim. Blank = as often as it fits. */
88
+ maxComboApplications: string;
83
89
  scope: string;
84
90
  trigger: string;
85
91
  code: string;
@@ -92,6 +98,8 @@ export interface DiscountFormPayload {
92
98
  active: boolean;
93
99
  categoryIds: string[];
94
100
  menuItemIds: string[];
101
+ /** The combo's groups, in the operator's own order. Empty at every other scope. */
102
+ comboRequirements: readonly ComboRequirement[];
95
103
  }
96
104
 
97
105
  /** A money amount as integer cents, or null when blank. */
@@ -130,12 +138,28 @@ export function toWriteBody(
130
138
  input: DiscountFormPayload,
131
139
  formatters: DiscountsFormatters,
132
140
  ): Record<string, unknown> {
133
- const isPercentage = input.type === "PERCENTAGE";
141
+ const isCombo = input.scope === "COMBO";
134
142
  return {
135
143
  name: input.name.trim(),
136
144
  type: input.type,
137
- percentOffBp: isPercentage ? toBasisPoints(input.percentOff, formatters) : null,
138
- amountOffCents: isPercentage ? null : toCents(input.amountOff, formatters),
145
+ // Exactly one value column, chosen by the TYPE, with the other three
146
+ // forced to null. The four are mutually exclusive at the database
147
+ // (`discounts_value_check`), so carrying a leftover from a branch the
148
+ // operator flipped away from is a 500 on a form they filled in correctly —
149
+ // the same reason the server's own folder nulls them rather than passing
150
+ // them through.
151
+ percentOffBp:
152
+ input.type === "PERCENTAGE" ? toBasisPoints(input.percentOff, formatters) : null,
153
+ amountOffCents: input.type === "FIXED_AMOUNT" ? toCents(input.amountOff, formatters) : null,
154
+ bundlePriceCents:
155
+ input.type === "BUNDLE_PRICE" ? toCents(input.bundlePrice, formatters) : null,
156
+ freeUnits: input.type === "FREE_UNITS" ? toCount(input.freeUnits, formatters) : null,
157
+ // The combo SPEC, narrowed to the scope for the reason the id lists below
158
+ // are: a rule whose scope moved off COMBO keeps neither its groups nor its
159
+ // cap, or it would go on being matched as a combo by a screen that no
160
+ // longer shows one.
161
+ comboRequirements: isCombo ? [...input.comboRequirements] : [],
162
+ maxComboApplications: isCombo ? toCount(input.maxComboApplications, formatters) : null,
139
163
  scope: input.scope,
140
164
  trigger: input.trigger,
141
165
  code: input.trigger === "CODE" ? toNullable(input.code) : null,
@@ -0,0 +1,213 @@
1
+ "use client";
2
+
3
+ import type { JSX } from "react";
4
+
5
+ import { Button } from "@12-apps/ui/form/Button";
6
+ import { FormControl, FormLabel, FormMessage } from "@12-apps/ui/form/Form";
7
+ import { Input } from "@12-apps/ui/form/Input";
8
+ import { Box } from "@12-apps/ui/mui/Box";
9
+ import { Stack } from "@12-apps/ui/mui/Stack";
10
+ import { Text } from "@12-apps/ui/typography/Text";
11
+
12
+ import type { ComboRequirement } from "../engine/types";
13
+
14
+ import type { WireTargetGroup } from "./api";
15
+ import { fill, type DiscountsWebCopy } from "./copy";
16
+ import { TargetPickerField } from "./target-picker-field";
17
+
18
+ /**
19
+ * The combo builder — how an operator says "2 refrigerantes, 2 hambúrgueres e
20
+ * 2 batatas" (FUT-268).
21
+ *
22
+ * A combo is a list of GROUPS. Each group is a quantity and the rows that can
23
+ * fill it, and a cart matches the combo when every group is satisfiable at
24
+ * once. That shape is already the engine's (`ComboRequirement`), the
25
+ * validator's and the schema's — `discount_combo_slots` with its own targets —
26
+ * so this file adds no concept. It adds the only thing that was missing: a way
27
+ * to author one.
28
+ *
29
+ * Two decisions worth stating, because both are visible in the markup:
30
+ *
31
+ * **A group can name categories AND items at once.** "2 refrigerantes" is a
32
+ * category; "2 batatas médias" is an item; and a real menu mixes them inside
33
+ * one group ("any drink, or specifically the large fries"). So each group gets
34
+ * every registered collection's picker rather than a scope toggle — the union
35
+ * is what `ComboRequirement` already stores, and forcing a choice would make
36
+ * the common offer unexpressible.
37
+ *
38
+ * **Position is the array index, and it is meaningful.** The list an operator
39
+ * builds is the list a card reads back; two groups of the same size are
40
+ * otherwise indistinguishable once stored. Removing a group therefore
41
+ * renumbers the ones after it, which is what an operator expects from a list
42
+ * they are editing — and `toTargetRows` stamps the index as `position` on the
43
+ * way to the database.
44
+ */
45
+
46
+ /** How many units one application of the combo takes out of the cart. */
47
+ export function comboUnits(slots: readonly ComboRequirement[]): number {
48
+ return slots.reduce((total, slot) => total + (Number.isFinite(slot.quantity) ? slot.quantity : 0), 0);
49
+ }
50
+
51
+ /** An empty group: one unit, nothing chosen yet. */
52
+ export function blankSlot(): ComboRequirement {
53
+ return { quantity: 1, categoryIds: [], menuItemIds: [] };
54
+ }
55
+
56
+ /** Replace one group, leaving the rest (and their order) alone. */
57
+ function replaceAt(
58
+ slots: readonly ComboRequirement[],
59
+ index: number,
60
+ next: ComboRequirement,
61
+ ): ComboRequirement[] {
62
+ return slots.map((slot, position) => (position === index ? next : slot));
63
+ }
64
+
65
+ /** Which id list of a group a collection reads and writes. */
66
+ function idsFor(slot: ComboRequirement, targetType: string): readonly string[] {
67
+ return targetType === "CATEGORY" ? slot.categoryIds : slot.menuItemIds;
68
+ }
69
+
70
+ function withIds(slot: ComboRequirement, targetType: string, ids: string[]): ComboRequirement {
71
+ return targetType === "CATEGORY" ? { ...slot, categoryIds: ids } : { ...slot, menuItemIds: ids };
72
+ }
73
+
74
+ /**
75
+ * One group: its quantity, one picker per registered collection, and the
76
+ * control that removes it.
77
+ *
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
80
+ * rather than as the form's string, because the summary line above adds them up
81
+ * and the validator compares them against `freeUnits`; parsing on every read
82
+ * would put the same `Number()` in three places.
83
+ */
84
+ function ComboSlotRow({
85
+ slot,
86
+ index,
87
+ groups,
88
+ copy,
89
+ onChange,
90
+ onRemove,
91
+ }: {
92
+ slot: ComboRequirement;
93
+ index: number;
94
+ groups: readonly WireTargetGroup[];
95
+ copy: DiscountsWebCopy;
96
+ onChange: (next: ComboRequirement) => void;
97
+ onRemove: () => void;
98
+ }): JSX.Element {
99
+ const position = index + 1;
100
+ const empty = slot.categoryIds.length === 0 && slot.menuItemIds.length === 0;
101
+ return (
102
+ <Box
103
+ data-testid={`combo-slot-${index}`}
104
+ sx={{ border: 1, borderColor: "divider", borderRadius: 1, p: 2 }}
105
+ >
106
+ <Stack direction="row" alignItems="center" justifyContent="space-between" spacing={1}>
107
+ <Text weight="medium">{fill(copy.combo.slot, { position })}</Text>
108
+ <Button
109
+ variant="text"
110
+ onClick={onRemove}
111
+ // The label carries the POSITION, so a screen reader announces which
112
+ // group this removes — "remove" alone is three identical buttons.
113
+ aria-label={fill(copy.combo.removeSlot, { position })}
114
+ data-testid={`combo-slot-${index}-remove`}
115
+ >
116
+ ✕
117
+ </Button>
118
+ </Stack>
119
+ <Stack spacing={2} sx={{ mt: 1 }}>
120
+ <Input
121
+ type="number"
122
+ 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`}
126
+ />
127
+ {groups.map((group) => (
128
+ <TargetPickerField
129
+ key={group.targetType}
130
+ group={group}
131
+ label={fill(copy.combo.pick, { collection: group.label })}
132
+ placeholder={fill(copy.targets.search, { collection: group.label })}
133
+ // The refusal belongs to the GROUP, not to either picker: a group
134
+ // naming a category is complete even with no items in it. So the
135
+ // message hangs below both, once, and only when both are empty.
136
+ requiredMessage={undefined}
137
+ ids={idsFor(slot, group.targetType)}
138
+ onChange={(ids) => onChange(withIds(slot, group.targetType, ids))}
139
+ dataTestId={`combo-slot-${index}-${group.slug}`}
140
+ />
141
+ ))}
142
+ {empty && (
143
+ <FormMessage error dataTestId={`combo-slot-${index}-message`}>
144
+ {copy.form.comboSlotTargetRequired}
145
+ </FormMessage>
146
+ )}
147
+ </Stack>
148
+ </Box>
149
+ );
150
+ }
151
+
152
+ /**
153
+ * The whole builder: the heading, the groups, the add button and the read-back.
154
+ *
155
+ * The read-back line is not decoration. A combo is the one promotion whose
156
+ * meaning is spread across several controls, and "the combo takes 6 items in 3
157
+ * groups" is the sentence that catches a mistyped quantity before a save — the
158
+ * same number `freeUnits` is validated against, computed once and shown.
159
+ */
160
+ export function ComboSlotBuilder({
161
+ slots,
162
+ groups,
163
+ copy,
164
+ onChange,
165
+ }: {
166
+ slots: readonly ComboRequirement[];
167
+ groups: readonly WireTargetGroup[];
168
+ copy: DiscountsWebCopy;
169
+ onChange: (next: ComboRequirement[]) => void;
170
+ }): JSX.Element {
171
+ return (
172
+ <FormControl fullWidth>
173
+ <FormLabel>{copy.combo.title}</FormLabel>
174
+ <Text variant="caption">{copy.combo.hint}</Text>
175
+ <Stack spacing={2} sx={{ mt: 1 }} data-testid="combo-slots">
176
+ {slots.map((slot, index) => (
177
+ <ComboSlotRow
178
+ // The index IS the identity here: `position` is the array index,
179
+ // and a group carries no id of its own until the database gives it
180
+ // one. Removing a group renumbers the rest, which is exactly what
181
+ // the operator sees happen.
182
+ key={index}
183
+ slot={slot}
184
+ index={index}
185
+ groups={groups}
186
+ copy={copy}
187
+ onChange={(next) => onChange(replaceAt(slots, index, next))}
188
+ onRemove={() => onChange(slots.filter((_, position) => position !== index))}
189
+ />
190
+ ))}
191
+ {slots.length === 0 && (
192
+ <Text variant="caption" data-testid="combo-slots-empty">
193
+ {copy.combo.empty}
194
+ </Text>
195
+ )}
196
+ <Stack direction="row" alignItems="center" justifyContent="space-between" spacing={2}>
197
+ <Button
198
+ variant="outline"
199
+ onClick={() => onChange([...slots, blankSlot()])}
200
+ data-testid="combo-add-slot"
201
+ >
202
+ {copy.combo.addSlot}
203
+ </Button>
204
+ {slots.length > 0 && (
205
+ <Text variant="caption" data-testid="combo-summary">
206
+ {fill(copy.combo.summary, { units: comboUnits(slots), groups: slots.length })}
207
+ </Text>
208
+ )}
209
+ </Stack>
210
+ </Stack>
211
+ </FormControl>
212
+ );
213
+ }
package/src/react/copy.ts CHANGED
@@ -101,6 +101,12 @@ export interface DiscountsFormCopy {
101
101
  /** The example inside the empty percentage input. */
102
102
  readonly percentPlaceholder: string;
103
103
  readonly amountOff: string;
104
+ /** The combo's total price, shown only for a BUNDLE_PRICE rule. */
105
+ readonly bundlePrice: string;
106
+ /** How many of the matched units are given away, for a FREE_UNITS rule. */
107
+ readonly freeUnits: string;
108
+ /** Under it: what "leve 3 pague 2" means in this rule's own numbers. */
109
+ readonly freeUnitsHint: string;
104
110
  readonly trigger: string;
105
111
  readonly code: string;
106
112
  /** The example inside the empty coupon input. */
@@ -111,6 +117,9 @@ export interface DiscountsFormCopy {
111
117
  readonly minSubtotal: string;
112
118
  readonly usageLimit: string;
113
119
  readonly perBuyerLimit: string;
120
+ /** How many times ONE cart may claim the combo. Blank = as often as it fits. */
121
+ readonly maxComboApplications: string;
122
+ readonly maxComboApplicationsHint: string;
114
123
  readonly active: string;
115
124
  readonly activeHint: string;
116
125
  readonly stackable: string;
@@ -123,12 +132,59 @@ export interface DiscountsFormCopy {
123
132
  readonly nameRequired: string;
124
133
  readonly invalidPercent: string;
125
134
  readonly invalidAmount: string;
135
+ readonly invalidBundlePrice: string;
136
+ readonly invalidFreeUnits: string;
137
+ /**
138
+ * "Take 3, three free" is a giveaway, not a promotion — the free count has to
139
+ * be smaller than what one application takes out of the cart. `{units}` is
140
+ * that number, so the operator is told the ceiling rather than just refused.
141
+ */
142
+ readonly freeUnitsExceedCombo: string;
126
143
  readonly codeRequired: string;
127
144
  readonly categoryTargetRequired: string;
128
145
  readonly itemTargetRequired: string;
146
+ /** COMBO scope with no groups: there is nothing for the reward to apply to. */
147
+ readonly comboSlotsRequired: string;
148
+ /** A group naming nothing can never be filled, so the combo never fires. */
149
+ readonly comboSlotTargetRequired: string;
150
+ readonly invalidComboQuantity: string;
151
+ readonly invalidMaxComboApplications: string;
129
152
  readonly endsBeforeStarts: string;
130
153
  }
131
154
 
155
+ /**
156
+ * The combo builder — the "2 refrigerantes + 2 hambúrgueres + 2 batatas" half
157
+ * of a promotion (FUT-268).
158
+ *
159
+ * A combo is a list of GROUPS, each a quantity and the rows that can fill it,
160
+ * and the reward is whichever value the type asks for. So this group covers the
161
+ * builder's chrome; the reward's own input is a `form` key beside the other
162
+ * value fields, because to the operator it is one.
163
+ */
164
+ export interface DiscountsComboCopy {
165
+ /** The section heading over the group list. */
166
+ readonly title: string;
167
+ /** One sentence saying what a group is, in the merchant's terms. */
168
+ readonly hint: string;
169
+ /** The button that appends a group. */
170
+ readonly addSlot: string;
171
+ /** The accessible name of one group's remove control. `{position}`. */
172
+ readonly removeSlot: string;
173
+ /** One group's heading. `{position}` is 1-based — an operator counts from 1. */
174
+ readonly slot: string;
175
+ /** The units this group takes. */
176
+ readonly quantity: string;
177
+ /** The group's picker label, `{collection}` — the registration's own label. */
178
+ readonly pick: string;
179
+ /** Shown when COMBO is chosen and no group exists yet. */
180
+ readonly empty: string;
181
+ /**
182
+ * The offer read back in the operator's own numbers, so a combo can be
183
+ * checked without saving it. `{units}` and `{groups}`.
184
+ */
185
+ readonly summary: string;
186
+ }
187
+
132
188
  /** The target pickers, and what an empty selection says. */
133
189
  export interface DiscountsTargetCopy {
134
190
  /** The picker's label, `{collection}` — the registration's own label. */
@@ -185,6 +241,7 @@ export interface DiscountsWebCopy {
185
241
  readonly labels: DiscountsVocabularyCopy;
186
242
  readonly window: DiscountsWindowCopy;
187
243
  readonly form: DiscountsFormCopy;
244
+ readonly combo: DiscountsComboCopy;
188
245
  readonly targets: DiscountsTargetCopy;
189
246
  readonly actions: DiscountsActionsCopy;
190
247
  readonly card: DiscountsCardCopy;
@@ -6,6 +6,7 @@ import { Switch } from "@12-apps/ui/form/Switch";
6
6
  import { Fields, useFormContext } from "@12-apps/ui/form/total-form";
7
7
  import { Box } from "@12-apps/ui/mui/Box";
8
8
  import { Stack } from "@12-apps/ui/mui/Stack";
9
+ import { Text } from "@12-apps/ui/typography/Text";
9
10
 
10
11
  import { DISCOUNT_SCOPES, DISCOUNT_TRIGGERS, DISCOUNT_TYPES } from "../engine/kinds";
11
12
 
@@ -21,6 +22,32 @@ import type { DiscountsWebCopy } from "./copy";
21
22
  * hand-written list is how a form offers a fifth type the database refuses.
22
23
  */
23
24
 
25
+ /**
26
+ * A field with a sentence under it.
27
+ *
28
+ * `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.
33
+ */
34
+ function HintedField({
35
+ name,
36
+ label,
37
+ hint,
38
+ }: {
39
+ name: string;
40
+ label: string;
41
+ hint: string;
42
+ }): JSX.Element {
43
+ return (
44
+ <Stack spacing={0.5}>
45
+ <Fields.TextField name={name} label={label} type="number" />
46
+ <Text variant="caption">{hint}</Text>
47
+ </Stack>
48
+ );
49
+ }
50
+
24
51
  /** One closed set as a toggle's options, labelled by the pack. */
25
52
  function optionsFor<TKey extends string>(
26
53
  values: readonly TKey[],
@@ -29,7 +56,22 @@ function optionsFor<TKey extends string>(
29
56
  return values.map((value) => ({ value, label: labels[value] }));
30
57
  }
31
58
 
32
- /** Percentage or amount — whichever the chosen type actually stores. */
59
+ /**
60
+ * The reward, in whichever unit the chosen type actually stores.
61
+ *
62
+ * One input per type, and the wrong one must never be mounted: the four value
63
+ * columns are mutually exclusive at the database (`discounts_value_check`), so
64
+ * 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.
67
+ *
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.
74
+ */
33
75
  function ValueField({
34
76
  copy,
35
77
  currencyField,
@@ -42,6 +84,14 @@ function ValueField({
42
84
  if (values.type === "FIXED_AMOUNT") {
43
85
  return <Currency name="amountOff" label={copy.form.amountOff} />;
44
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
+ }
45
95
  return (
46
96
  <Fields.TextField
47
97
  name="percentOff"
@@ -111,6 +161,26 @@ export function ScopeField({ copy }: { copy: DiscountsWebCopy }): JSX.Element {
111
161
  );
112
162
  }
113
163
 
164
+ /**
165
+ * How many times one cart may claim the combo.
166
+ *
167
+ * Only at COMBO scope, because it is the only scope where the question means
168
+ * anything: an order-wide or category discount applies once by construction. A
169
+ * blank field is "as often as it fits", which is the merchant's usual answer
170
+ * and therefore the default rather than a number they have to type.
171
+ */
172
+ function ComboCapField({ copy }: { copy: DiscountsWebCopy }): JSX.Element | null {
173
+ const { values } = useFormContext();
174
+ if (values.scope !== "COMBO") return null;
175
+ return (
176
+ <HintedField
177
+ name="maxComboApplications"
178
+ label={copy.form.maxComboApplications}
179
+ hint={copy.form.maxComboApplicationsHint}
180
+ />
181
+ );
182
+ }
183
+
114
184
  /** The active window. Both sides optional; the end date is EXCLUSIVE. */
115
185
  export function WindowFields({ copy }: { copy: DiscountsWebCopy }): JSX.Element {
116
186
  return (
@@ -135,6 +205,7 @@ export function LimitFields({
135
205
  <Currency name="minSubtotal" label={copy.form.minSubtotal} />
136
206
  <Fields.TextField name="usageLimit" label={copy.form.usageLimit} type="number" />
137
207
  <Fields.TextField name="perBuyerLimit" label={copy.form.perBuyerLimit} type="number" />
208
+ <ComboCapField copy={copy} />
138
209
  </Box>
139
210
  );
140
211
  }
@@ -12,8 +12,10 @@ import {
12
12
  } from "@12-apps/ui/form/total-form";
13
13
  import { Stack } from "@12-apps/ui/mui/Stack";
14
14
 
15
+ import type { ComboRequirement } from "../engine/types";
16
+
15
17
  import type { DiscountFormPayload, DiscountsApiClient, DiscountWireRecord, WireTargetGroup } from "./api";
16
- import type { DiscountsWebCopy } from "./copy";
18
+ import { fill, type DiscountsWebCopy } from "./copy";
17
19
  import {
18
20
  DiscountSwitches,
19
21
  LimitFields,
@@ -22,6 +24,7 @@ import {
22
24
  WindowFields,
23
25
  type CurrencyFieldComponent,
24
26
  } from "./discount-form-fields";
27
+ import { comboUnits } from "./combo-slot-builder";
25
28
  import { DiscountTargetPicker } from "./discount-target-picker";
26
29
  import type { DiscountsFormatters } from "./format";
27
30
 
@@ -46,6 +49,9 @@ interface DiscountFormValues extends Record<string, string> {
46
49
  type: string;
47
50
  percentOff: string;
48
51
  amountOff: string;
52
+ bundlePrice: string;
53
+ freeUnits: string;
54
+ maxComboApplications: string;
49
55
  scope: string;
50
56
  trigger: string;
51
57
  code: string;
@@ -67,6 +73,9 @@ const BLANK: DiscountFormValues = {
67
73
  type: "PERCENTAGE",
68
74
  percentOff: "",
69
75
  amountOff: "",
76
+ bundlePrice: "",
77
+ freeUnits: "",
78
+ maxComboApplications: "",
70
79
  scope: "ORDER",
71
80
  trigger: "AUTOMATIC",
72
81
  code: "",
@@ -89,6 +98,10 @@ function initialValues(
89
98
  type: editing.type,
90
99
  percentOff: editing.percentOffBp === null ? "" : formatters.toInput(editing.percentOffBp / 100),
91
100
  amountOff: cents(editing.amountOffCents),
101
+ bundlePrice: cents(editing.bundlePriceCents ?? null),
102
+ freeUnits: editing.freeUnits == null ? "" : String(editing.freeUnits),
103
+ maxComboApplications:
104
+ editing.maxComboApplications == null ? "" : String(editing.maxComboApplications),
92
105
  scope: editing.scope,
93
106
  trigger: editing.trigger,
94
107
  code: editing.code ?? "",
@@ -100,31 +113,125 @@ function initialValues(
100
113
  };
101
114
  }
102
115
 
103
- /** The value half: a rate in (0, 100], or an amount above zero. */
116
+ /** A whole number above zero, as typed. `null` when it is neither. */
117
+ function positiveCount(typed: string): number | null {
118
+ const trimmed = typed.trim();
119
+ if (trimmed === "") return null;
120
+ const value = Number(trimmed);
121
+ return Number.isInteger(value) && value > 0 ? value : null;
122
+ }
123
+
124
+ /** A money field that has to hold something above zero. */
125
+ function checkMoney(
126
+ typed: string,
127
+ field: string,
128
+ message: string,
129
+ formatters: DiscountsFormatters,
130
+ ): Record<string, string> {
131
+ const amount = formatters.parseDecimal(typed);
132
+ return amount === null || amount <= 0 ? { [field]: message } : {};
133
+ }
134
+
135
+ /**
136
+ * The free count, which is the one reward bounded by another field.
137
+ *
138
+ * "Take 3, three free" is a giveaway rather than a promotion, so it is compared
139
+ * against what ONE application of the combo takes out of the cart. The ceiling
140
+ * is named in the message: an operator told only "too many" has to guess the
141
+ * number.
142
+ */
143
+ function checkFreeUnits(
144
+ typed: string,
145
+ slots: readonly ComboRequirement[],
146
+ copy: DiscountsWebCopy,
147
+ ): Record<string, string> {
148
+ const free = positiveCount(typed);
149
+ if (free === null) return { freeUnits: copy.form.invalidFreeUnits };
150
+ const units = comboUnits(slots);
151
+ if (free >= units) {
152
+ return {
153
+ freeUnits: fill(copy.form.freeUnitsExceedCombo, { units, max: Math.max(units - 1, 0) }),
154
+ };
155
+ }
156
+ return {};
157
+ }
158
+
159
+ /** The rate, in the 0–100 an operator thinks in rather than basis points. */
160
+ function checkPercent(
161
+ typed: string,
162
+ formatters: DiscountsFormatters,
163
+ copy: DiscountsWebCopy,
164
+ ): Record<string, string> {
165
+ const percent = formatters.parseDecimal(typed);
166
+ return percent === null || percent <= 0 || percent > 100
167
+ ? { percentOff: copy.form.invalidPercent }
168
+ : {};
169
+ }
170
+
171
+ /** The reward, checked against the input the chosen type actually mounted. */
104
172
  function checkValue(
105
173
  values: DiscountFormValues,
174
+ slots: readonly ComboRequirement[],
106
175
  formatters: DiscountsFormatters,
107
176
  copy: DiscountsWebCopy,
108
177
  ): Record<string, string> {
109
- if (values.type === "PERCENTAGE") {
110
- const percent = formatters.parseDecimal(values.percentOff);
111
- if (percent === null || percent <= 0 || percent > 100) {
112
- return { percentOff: copy.form.invalidPercent };
113
- }
114
- return {};
178
+ if (values.type === "FIXED_AMOUNT") {
179
+ return checkMoney(values.amountOff, "amountOff", copy.form.invalidAmount, formatters);
180
+ }
181
+ if (values.type === "BUNDLE_PRICE") {
182
+ return checkMoney(values.bundlePrice, "bundlePrice", copy.form.invalidBundlePrice, formatters);
115
183
  }
116
- const amount = formatters.parseDecimal(values.amountOff);
117
- return amount === null || amount <= 0 ? { amountOff: copy.form.invalidAmount } : {};
184
+ if (values.type === "FREE_UNITS") return checkFreeUnits(values.freeUnits, slots, copy);
185
+ return checkPercent(values.percentOff, formatters, copy);
186
+ }
187
+
188
+ /**
189
+ * The combo half: the groups themselves, and the per-cart cap.
190
+ *
191
+ * Every rule here is also the server's (`validate-combo.ts`), and deliberately
192
+ * so — this half exists to attach the refusal to the input the operator is
193
+ * looking at, never to be the authority.
194
+ */
195
+ function checkCombo(
196
+ values: DiscountFormValues,
197
+ slots: readonly ComboRequirement[],
198
+ copy: DiscountsWebCopy,
199
+ ): Record<string, string> {
200
+ if (values.scope !== "COMBO") return {};
201
+ if (slots.length === 0) return { scope: copy.form.comboSlotsRequired };
202
+ const errors: Record<string, string> = {};
203
+ if (slots.some((slot) => !Number.isInteger(slot.quantity) || slot.quantity <= 0)) {
204
+ errors.scope = copy.form.invalidComboQuantity;
205
+ } else if (
206
+ slots.some((slot) => slot.categoryIds.length === 0 && slot.menuItemIds.length === 0)
207
+ ) {
208
+ errors.scope = copy.form.comboSlotTargetRequired;
209
+ }
210
+ if (values.maxComboApplications.trim() !== "" && positiveCount(values.maxComboApplications) === null) {
211
+ errors.maxComboApplications = copy.form.invalidMaxComboApplications;
212
+ }
213
+ return errors;
214
+ }
215
+
216
+ /** What the pickers and the builder currently hold, in one argument. */
217
+ interface FormTargets {
218
+ categoryIds: string[];
219
+ menuItemIds: string[];
220
+ comboRequirements: ComboRequirement[];
118
221
  }
119
222
 
120
223
  /** The cross-field rules a per-input schema cannot express. */
121
224
  function validate(
122
225
  values: DiscountFormValues,
123
- targets: { categoryIds: string[]; menuItemIds: string[] },
226
+ targets: FormTargets,
124
227
  formatters: DiscountsFormatters,
125
228
  copy: DiscountsWebCopy,
126
229
  ): Record<string, string> {
127
- const errors: Record<string, string> = { ...checkValue(values, formatters, copy) };
230
+ const slots = targets.comboRequirements;
231
+ const errors: Record<string, string> = {
232
+ ...checkValue(values, slots, formatters, copy),
233
+ ...checkCombo(values, slots, copy),
234
+ };
128
235
  if (values.trigger === "CODE" && values.code.trim() === "") {
129
236
  errors.code = copy.form.codeRequired;
130
237
  }
@@ -148,15 +255,23 @@ function useNonStringState(editing: DiscountWireRecord | null) {
148
255
  const [stackable, setStackable] = useState<boolean>(editing?.stackable ?? true);
149
256
  const [categoryIds, setCategoryIds] = useState<string[]>(editing?.categoryIds ?? []);
150
257
  const [menuItemIds, setMenuItemIds] = useState<string[]>(editing?.menuItemIds ?? []);
258
+ // The combo's groups are the third array, and the one whose ORDER matters:
259
+ // the list the operator built is the list a card reads back, and the server
260
+ // stamps each group's index as its stored `position`.
261
+ const [comboRequirements, setComboRequirements] = useState<ComboRequirement[]>(() =>
262
+ editing?.comboRequirements ? [...editing.comboRequirements] : [],
263
+ );
151
264
  return {
152
265
  active,
153
266
  stackable,
154
267
  categoryIds,
155
268
  menuItemIds,
269
+ comboRequirements,
156
270
  setActive,
157
271
  setStackable,
158
272
  setCategoryIds,
159
273
  setMenuItemIds,
274
+ setComboRequirements,
160
275
  };
161
276
  }
162
277
 
@@ -190,7 +305,11 @@ function makeSubmit(
190
305
  const { api, copy, formatters, editing, onSaved, onError } = props;
191
306
  return async function handleSubmit(values, { setFieldErrors }): Promise<void> {
192
307
  setError(null);
193
- const targets = { categoryIds: extra.categoryIds, menuItemIds: extra.menuItemIds };
308
+ const targets: FormTargets = {
309
+ categoryIds: extra.categoryIds,
310
+ menuItemIds: extra.menuItemIds,
311
+ comboRequirements: extra.comboRequirements,
312
+ };
194
313
  const invalid = validate(values, targets, formatters, copy);
195
314
  if (Object.keys(invalid).length > 0) {
196
315
  setFieldErrors(invalid);
@@ -241,8 +360,10 @@ export function DiscountForm(props: DiscountFormProps): JSX.Element {
241
360
  selection={{
242
361
  categoryIds: extra.categoryIds,
243
362
  menuItemIds: extra.menuItemIds,
363
+ comboRequirements: extra.comboRequirements,
244
364
  onCategoryIdsChange: extra.setCategoryIds,
245
365
  onMenuItemIdsChange: extra.setMenuItemIds,
366
+ onComboRequirementsChange: extra.setComboRequirements,
246
367
  }}
247
368
  />
248
369
  <WindowFields copy={copy} />
@@ -1,23 +1,24 @@
1
1
  "use client";
2
2
 
3
- import { useState, type JSX } from "react";
3
+ import type { JSX } from "react";
4
4
 
5
- import { Autocomplete } from "@12-apps/ui/form/Autocomplete";
6
- import { CategorySelect } from "@12-apps/ui/form/CategorySelect";
7
- import { FormControl, FormLabel, FormMessage } from "@12-apps/ui/form/Form";
8
5
  import { useFormContext } from "@12-apps/ui/form/total-form";
9
6
 
7
+ import type { ComboRequirement } from "../engine/types";
8
+
9
+ import type { WireTargetGroup } from "./api";
10
+ import { ComboSlotBuilder } from "./combo-slot-builder";
10
11
  import { fill, type DiscountsWebCopy } from "./copy";
11
- import type { WireTarget, WireTargetGroup } from "./api";
12
+ import { TargetPickerField } from "./target-picker-field";
12
13
 
13
14
  /**
14
15
  * What a scoped discount points at, picked from the host's own collections.
15
16
  *
16
- * There is no shared multi-select in `@12-apps/ui`, so this composes
17
- * `Autocomplete` in multiple mode — chips plus type-to-filter, the same control
18
- * `AddressAutocomplete` is built on. A collection that NESTS gets
19
- * `CategorySelect` instead: a flat combobox would put a subcategory beside its
20
- * parent with nothing to tell them apart.
17
+ * This file is the ROUTER: it reads the scope and decides which shape the
18
+ * targets take. The controls themselves live next door — one collection's
19
+ * picker in `target-picker-field`, the quantified group list in
20
+ * `combo-slot-builder` — because the combo builder needs the former per group
21
+ * and a file cannot import its own importer.
21
22
  *
22
23
  * Which collections exist is not decided here. The surface reads them from
23
24
  * `GET /discounts/targets`, which answers whatever the host registered — so
@@ -32,62 +33,15 @@ import type { WireTarget, WireTargetGroup } from "./api";
32
33
  * leaving a stale one on screen would suggest otherwise.
33
34
  */
34
35
 
35
- /** One labelled multi-select of targets, with its own input-text state. */
36
- function TargetField({
37
- label,
38
- placeholder,
39
- requiredMessage,
40
- options,
41
- ids,
42
- onChange,
43
- dataTestId,
44
- }: {
45
- label: string;
46
- placeholder: string;
47
- requiredMessage: string;
48
- options: WireTarget[];
49
- ids: readonly string[];
50
- onChange: (next: string[]) => void;
51
- dataTestId: string;
52
- }): JSX.Element {
53
- const [query, setQuery] = useState("");
54
- const byId = new Map(options.map((option) => [option.id, option]));
55
- // An id whose option is missing — a target archived since the rule was saved
56
- // — still renders as a chip, so saving cannot silently drop it.
57
- const selected = ids.map((id) => byId.get(id) ?? { id, name: id });
58
-
59
- return (
60
- <FormControl fullWidth>
61
- <FormLabel error={ids.length === 0}>{label}</FormLabel>
62
- <div data-testid={dataTestId}>
63
- <Autocomplete<WireTarget>
64
- multiple
65
- value={query}
66
- onChange={setQuery}
67
- suggestions={options}
68
- selectedItems={selected}
69
- onSelectedItemsChange={(items) => onChange(items.map((item) => item.id))}
70
- getKey={(option) => option.id}
71
- getLabel={(option) => option.name}
72
- placeholder={placeholder}
73
- inputAriaLabel={label}
74
- />
75
- </div>
76
- {ids.length === 0 && (
77
- <FormMessage error dataTestId={`${dataTestId}-message`}>
78
- {requiredMessage}
79
- </FormMessage>
80
- )}
81
- </FormControl>
82
- );
83
- }
84
-
85
- /** The ids currently chosen for one dimension. */
36
+ /** What the form currently has chosen, in every shape a scope can need. */
86
37
  interface DiscountTargetSelection {
87
38
  categoryIds: string[];
88
39
  menuItemIds: string[];
40
+ /** The combo's groups, in the operator's own order. Empty at every other scope. */
41
+ comboRequirements: readonly ComboRequirement[];
89
42
  onCategoryIdsChange: (next: string[]) => void;
90
43
  onMenuItemIdsChange: (next: string[]) => void;
44
+ onComboRequirementsChange: (next: ComboRequirement[]) => void;
91
45
  }
92
46
 
93
47
  /** Which selection a dimension reads and writes. */
@@ -112,6 +66,12 @@ function scopeFor(targetType: string): string {
112
66
  * Reads the LIVE `scope` from the form context, so flipping the toggle swaps
113
67
  * the picker immediately without the parent form having to mirror the value
114
68
  * into its own state.
69
+ *
70
+ * COMBO is the third branch (FUT-268) and it is a different SHAPE, not another
71
+ * picker: a combo is a list of quantified groups, so it gets the builder and
72
+ * every collection at once, while CATEGORY and ITEM each get exactly one
73
+ * collection's picker. ORDER still renders nothing — an order-wide discount
74
+ * covers everything, so a target list there is not empty, it is meaningless.
115
75
  */
116
76
  export function DiscountTargetPicker({
117
77
  groups,
@@ -124,36 +84,31 @@ export function DiscountTargetPicker({
124
84
  }): JSX.Element | null {
125
85
  const { values } = useFormContext();
126
86
  const scope = values.scope ?? "ORDER";
127
- const group = groups.find((entry) => scopeFor(entry.targetType) === scope);
128
- if (!group) return null;
129
-
130
- const { ids, onChange } = bind(group.targetType, selection);
131
- const label = fill(copy.targets.pick, { collection: group.label });
132
- const dataTestId = `discount-${group.slug}-targets`;
133
87
 
134
- if (group.nests) {
88
+ if (scope === "COMBO") {
135
89
  return (
136
- <CategorySelect
137
- label={label}
138
- fullWidth
139
- allowParentSelection
140
- options={group.targets}
141
- value={ids}
142
- onChange={onChange}
143
- error={ids.length === 0 ? copy.targets.required : undefined}
144
- dataTestId={dataTestId}
90
+ <ComboSlotBuilder
91
+ slots={selection.comboRequirements}
92
+ groups={groups}
93
+ copy={copy}
94
+ onChange={selection.onComboRequirementsChange}
145
95
  />
146
96
  );
147
97
  }
98
+
99
+ const group = groups.find((entry) => scopeFor(entry.targetType) === scope);
100
+ if (!group) return null;
101
+
102
+ const { ids, onChange } = bind(group.targetType, selection);
148
103
  return (
149
- <TargetField
150
- label={label}
104
+ <TargetPickerField
105
+ group={group}
106
+ label={fill(copy.targets.pick, { collection: group.label })}
151
107
  placeholder={fill(copy.targets.search, { collection: group.label })}
152
108
  requiredMessage={copy.targets.required}
153
- options={group.targets}
154
109
  ids={ids}
155
110
  onChange={onChange}
156
- dataTestId={dataTestId}
111
+ dataTestId={`discount-${group.slug}-targets`}
157
112
  />
158
113
  );
159
114
  }
@@ -75,6 +75,10 @@ export const PT_BR_DISCOUNTS_WEB_COPY: DiscountsWebCopy = {
75
75
  percentOff: "Desconto (%)",
76
76
  percentPlaceholder: "10",
77
77
  amountOff: "Valor do desconto",
78
+ bundlePrice: "Preço do combo",
79
+ freeUnits: "Itens grátis",
80
+ freeUnitsHint:
81
+ "Quantas unidades do combo saem de graça. Em um grupo de 3, deixar 1 grátis é o clássico \"leve 3, pague 2\".",
78
82
  trigger: "Ativação",
79
83
  code: "Código do cupom",
80
84
  codePlaceholder: "BEMVINDO10",
@@ -84,6 +88,8 @@ export const PT_BR_DISCOUNTS_WEB_COPY: DiscountsWebCopy = {
84
88
  minSubtotal: "Pedido mínimo",
85
89
  usageLimit: "Limite de usos",
86
90
  perBuyerLimit: "Limite por cliente",
91
+ maxComboApplications: "Combos por pedido",
92
+ maxComboApplicationsHint: "Deixe em branco para valer quantas vezes couber no carrinho.",
87
93
  active: "Ativo",
88
94
  activeHint: "Desligue para pausar a promoção sem excluí-la.",
89
95
  stackable: "Acumulável",
@@ -94,11 +100,30 @@ export const PT_BR_DISCOUNTS_WEB_COPY: DiscountsWebCopy = {
94
100
  nameRequired: "Informe o nome da promoção.",
95
101
  invalidPercent: "Informe uma porcentagem maior que 0 e no máximo 100.",
96
102
  invalidAmount: "Informe um valor de desconto maior que zero.",
103
+ invalidBundlePrice: "Informe o preço do combo, maior que zero.",
104
+ invalidFreeUnits: "Informe quantos itens saem de graça, a partir de 1.",
105
+ freeUnitsExceedCombo:
106
+ "O combo tem {units} itens; deixe grátis no máximo {max} para ainda ser uma promoção.",
97
107
  codeRequired: "Informe o código do cupom que o cliente vai digitar.",
98
108
  categoryTargetRequired: "Selecione ao menos uma categoria para este desconto.",
99
109
  itemTargetRequired: "Selecione ao menos um produto para este desconto.",
110
+ comboSlotsRequired: "Monte o combo: adicione ao menos um grupo de itens.",
111
+ comboSlotTargetRequired: "Escolha o que pode entrar em cada grupo do combo.",
112
+ invalidComboQuantity: "Informe quantas unidades cada grupo leva, a partir de 1.",
113
+ invalidMaxComboApplications: "Informe quantos combos cabem por pedido, a partir de 1.",
100
114
  endsBeforeStarts: "A data de término deve ser posterior à data de início.",
101
115
  },
116
+ combo: {
117
+ title: "Monte o combo",
118
+ hint: "Cada grupo é uma parte do combo: quantas unidades leva e o que pode entrar nela.",
119
+ addSlot: "Adicionar grupo",
120
+ removeSlot: "Remover o grupo {position}",
121
+ slot: "Grupo {position}",
122
+ quantity: "Quantidade",
123
+ pick: "{collection} deste grupo",
124
+ empty: "Nenhum grupo ainda. Adicione o primeiro para montar o combo.",
125
+ summary: "O combo leva {units} itens em {groups} grupos.",
126
+ },
102
127
  targets: {
103
128
  pick: "{collection} com desconto",
104
129
  search: "Buscar em {collection}…",
@@ -0,0 +1,138 @@
1
+ "use client";
2
+
3
+ import { useState, type JSX } from "react";
4
+
5
+ import { Autocomplete } from "@12-apps/ui/form/Autocomplete";
6
+ import { CategorySelect } from "@12-apps/ui/form/CategorySelect";
7
+ import { FormControl, FormLabel, FormMessage } from "@12-apps/ui/form/Form";
8
+
9
+ import type { WireTarget, WireTargetGroup } from "./api";
10
+
11
+ /**
12
+ * One collection's picker, in the two shapes a collection can take.
13
+ *
14
+ * Its own module because BOTH callers need it and they are not each other's
15
+ * parent: the scope-driven block renders one per discount, and the combo
16
+ * builder renders one per collection per GROUP. Importing it from either would
17
+ * make the two files a cycle, and copying it is how a nesting collection ends
18
+ * up a flat combobox in one of the two places — the exact bug this seam exists
19
+ * to prevent.
20
+ *
21
+ * There is no shared multi-select in `@12-apps/ui`, so the flat case composes
22
+ * `Autocomplete` in multiple mode — chips plus type-to-filter, the same control
23
+ * `AddressAutocomplete` is built on. A collection that NESTS gets
24
+ * `CategorySelect` instead: a flat combobox would put a subcategory beside its
25
+ * parent with nothing to tell them apart.
26
+ */
27
+
28
+ /** One labelled multi-select of targets, with its own input-text state. */
29
+ function TargetField({
30
+ label,
31
+ placeholder,
32
+ requiredMessage,
33
+ options,
34
+ ids,
35
+ onChange,
36
+ dataTestId,
37
+ }: {
38
+ label: string;
39
+ placeholder: string;
40
+ requiredMessage: string | undefined;
41
+ options: readonly WireTarget[];
42
+ ids: readonly string[];
43
+ onChange: (next: string[]) => void;
44
+ dataTestId: string;
45
+ }): JSX.Element {
46
+ const [query, setQuery] = useState("");
47
+ const byId = new Map(options.map((option) => [option.id, option]));
48
+ // An id whose option is missing — a target archived since the rule was saved
49
+ // — still renders as a chip, so saving cannot silently drop it.
50
+ const selected = ids.map((id) => byId.get(id) ?? { id, name: id });
51
+
52
+ const missing = requiredMessage !== undefined && ids.length === 0;
53
+
54
+ return (
55
+ <FormControl fullWidth>
56
+ <FormLabel error={missing}>{label}</FormLabel>
57
+ <div data-testid={dataTestId}>
58
+ <Autocomplete<WireTarget>
59
+ multiple
60
+ value={query}
61
+ onChange={setQuery}
62
+ suggestions={[...options]}
63
+ selectedItems={selected}
64
+ onSelectedItemsChange={(items) => onChange(items.map((item) => item.id))}
65
+ getKey={(option) => option.id}
66
+ getLabel={(option) => option.name}
67
+ placeholder={placeholder}
68
+ inputAriaLabel={label}
69
+ />
70
+ </div>
71
+ {missing && (
72
+ <FormMessage error dataTestId={`${dataTestId}-message`}>
73
+ {requiredMessage}
74
+ </FormMessage>
75
+ )}
76
+ </FormControl>
77
+ );
78
+ }
79
+
80
+ /**
81
+ * One collection's picker, for whoever is asking.
82
+ *
83
+ * Extracted because the COMBO builder needs the same control per GROUP, and a
84
+ * second copy is how a nesting collection ends up rendered as a flat combobox
85
+ * in one of the two places — the bug this seam exists to prevent. The caller
86
+ * supplies the label and the test id, because "Categorias com desconto" and
87
+ * "Categorias deste grupo" are different sentences about the same control.
88
+ *
89
+ * `requiredMessage` is OPTIONAL, and that is the difference between the two
90
+ * callers rather than an oversight. At CATEGORY/ITEM scope an empty picker IS
91
+ * the error, so the message hangs on it. Inside a combo group it is not: a
92
+ * group naming two categories and no items is complete, so the group as a whole
93
+ * carries the refusal and neither picker does.
94
+ */
95
+ export function TargetPickerField({
96
+ group,
97
+ label,
98
+ placeholder,
99
+ requiredMessage,
100
+ ids,
101
+ onChange,
102
+ dataTestId,
103
+ }: {
104
+ group: WireTargetGroup;
105
+ label: string;
106
+ placeholder: string;
107
+ requiredMessage: string | undefined;
108
+ ids: readonly string[];
109
+ onChange: (next: string[]) => void;
110
+ dataTestId: string;
111
+ }): JSX.Element {
112
+ if (group.nests) {
113
+ return (
114
+ <CategorySelect
115
+ label={label}
116
+ fullWidth
117
+ allowParentSelection
118
+ options={[...group.targets]}
119
+ value={[...ids]}
120
+ onChange={onChange}
121
+ error={requiredMessage !== undefined && ids.length === 0 ? requiredMessage : undefined}
122
+ dataTestId={dataTestId}
123
+ />
124
+ );
125
+ }
126
+ return (
127
+ <TargetField
128
+ label={label}
129
+ placeholder={placeholder}
130
+ requiredMessage={requiredMessage}
131
+ options={group.targets}
132
+ ids={ids}
133
+ onChange={onChange}
134
+ dataTestId={dataTestId}
135
+ />
136
+ );
137
+ }
138
+