@12-apps/discounts 1.4.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.
@@ -0,0 +1,172 @@
1
+ import type { ComboRequirement } from "../engine/types";
2
+
3
+ import { comboUnits } from "./combo-slot-builder";
4
+ import { fill, type DiscountsWebCopy } from "./copy";
5
+ import type { DiscountFormValues } from "./discount-form-values";
6
+ import { isComboKind, typeAndScopeFor } from "./form-kind";
7
+ import type { DiscountsFormatters } from "./format";
8
+
9
+ /**
10
+ * Everything the create/edit form refuses locally, split from the form itself
11
+ * for the 400-line file gate — comments count toward it, and the container plus
12
+ * these would not fit.
13
+ *
14
+ * Every rule here is ALSO the server's (`../server/validate-combo.ts` and the
15
+ * route's own schema). This half exists to attach the refusal to the input the
16
+ * operator is looking at, never to be the authority: the surface re-validates,
17
+ * and a server refusal comes back through `setFieldErrors` and paints the same
18
+ * inputs red.
19
+ */
20
+
21
+ /** A whole number above zero, as typed. `null` when it is neither. */
22
+ function positiveCount(typed: string): number | null {
23
+ const trimmed = typed.trim();
24
+ if (trimmed === "") return null;
25
+ const value = Number(trimmed);
26
+ return Number.isInteger(value) && value > 0 ? value : null;
27
+ }
28
+
29
+ /** A money field that has to hold something above zero. */
30
+ function checkMoney(
31
+ typed: string,
32
+ field: string,
33
+ message: string,
34
+ formatters: DiscountsFormatters,
35
+ ): Record<string, string> {
36
+ const amount = formatters.parseDecimal(typed);
37
+ return amount === null || amount <= 0 ? { [field]: message } : {};
38
+ }
39
+
40
+ /**
41
+ * The free count, which is the one reward bounded by another field.
42
+ *
43
+ * "Take 3, three free" is a giveaway rather than a promotion, so it is compared
44
+ * against what ONE application of the combo takes out of the cart. The ceiling
45
+ * is named in the message: an operator told only "too many" has to guess the
46
+ * number.
47
+ */
48
+ function checkFreeUnits(
49
+ typed: string,
50
+ slots: readonly ComboRequirement[],
51
+ copy: DiscountsWebCopy,
52
+ ): Record<string, string> {
53
+ const free = positiveCount(typed);
54
+ if (free === null) return { freeUnits: copy.form.invalidFreeUnits };
55
+ const units = comboUnits(slots);
56
+ if (free >= units) {
57
+ return {
58
+ freeUnits: fill(copy.form.freeUnitsExceedCombo, { units, max: Math.max(units - 1, 0) }),
59
+ };
60
+ }
61
+ return {};
62
+ }
63
+
64
+ /** The rate, in the 0–100 an operator thinks in rather than basis points. */
65
+ function checkPercent(
66
+ typed: string,
67
+ formatters: DiscountsFormatters,
68
+ copy: DiscountsWebCopy,
69
+ ): Record<string, string> {
70
+ const percent = formatters.parseDecimal(typed);
71
+ return percent === null || percent <= 0 || percent > 100
72
+ ? { percentOff: copy.form.invalidPercent }
73
+ : {};
74
+ }
75
+
76
+ /**
77
+ * The reward, checked against the input the chosen KIND actually mounted.
78
+ *
79
+ * `type` is derived rather than read: a combo's reward is a percentage or an
80
+ * amount, and which one it is lives in `comboReward` — so the check runs off
81
+ * the same mapping the submit does, and the two cannot disagree about which
82
+ * input holds the number.
83
+ */
84
+ function checkValue(
85
+ values: DiscountFormValues,
86
+ slots: readonly ComboRequirement[],
87
+ formatters: DiscountsFormatters,
88
+ copy: DiscountsWebCopy,
89
+ ): Record<string, string> {
90
+ const { type } = typeAndScopeFor(values);
91
+ if (type === "FIXED_AMOUNT") {
92
+ return checkMoney(values.amountOff, "amountOff", copy.form.invalidAmount, formatters);
93
+ }
94
+ if (type === "BUNDLE_PRICE") {
95
+ return checkMoney(values.bundlePrice, "bundlePrice", copy.form.invalidBundlePrice, formatters);
96
+ }
97
+ if (type === "FREE_UNITS") return checkFreeUnits(values.freeUnits, slots, copy);
98
+ return checkPercent(values.percentOff, formatters, copy);
99
+ }
100
+
101
+ /**
102
+ * The combo half: the groups themselves, and the per-cart cap.
103
+ *
104
+ * Every rule here is also the server's (`validate-combo.ts`), and deliberately
105
+ * so — this half exists to attach the refusal to the input the operator is
106
+ * looking at, never to be the authority.
107
+ */
108
+ function checkCombo(
109
+ values: DiscountFormValues,
110
+ slots: readonly ComboRequirement[],
111
+ copy: DiscountsWebCopy,
112
+ ): Record<string, string> {
113
+ if (!isComboKind(values.kind)) return {};
114
+ // "Leve 3, pague 2" is one group of products, so it is refused in those words
115
+ // rather than in the multi-group builder's — same stored shape, different
116
+ // sentence, because the operator is looking at a different control.
117
+ const free = values.kind === "FREE_UNITS";
118
+ const empty = free ? copy.form.freeUnitsTargetRequired : copy.form.comboSlotsRequired;
119
+ // The refusal hangs on `kind` rather than on `scope`: a combo kind does not
120
+ // mount a scope toggle, so an error attributed there would paint nothing.
121
+ if (slots.length === 0) return { kind: empty };
122
+ const errors: Record<string, string> = {};
123
+ if (slots.some((slot) => !Number.isInteger(slot.quantity) || slot.quantity <= 0)) {
124
+ errors.kind = copy.form.invalidComboQuantity;
125
+ } else if (
126
+ slots.some((slot) => slot.categoryIds.length === 0 && slot.menuItemIds.length === 0)
127
+ ) {
128
+ errors.kind = free ? copy.form.freeUnitsTargetRequired : copy.form.comboSlotTargetRequired;
129
+ }
130
+ if (values.maxComboApplications.trim() !== "" && positiveCount(values.maxComboApplications) === null) {
131
+ errors.maxComboApplications = copy.form.invalidMaxComboApplications;
132
+ }
133
+ return errors;
134
+ }
135
+
136
+ /** What the pickers and the builder currently hold, in one argument. */
137
+ export interface FormTargets {
138
+ categoryIds: string[];
139
+ menuItemIds: string[];
140
+ comboRequirements: ComboRequirement[];
141
+ }
142
+
143
+ /** The cross-field rules a per-input schema cannot express. */
144
+ export function validate(
145
+ values: DiscountFormValues,
146
+ targets: FormTargets,
147
+ formatters: DiscountsFormatters,
148
+ copy: DiscountsWebCopy,
149
+ ): Record<string, string> {
150
+ const slots = targets.comboRequirements;
151
+ const errors: Record<string, string> = {
152
+ ...checkValue(values, slots, formatters, copy),
153
+ ...checkCombo(values, slots, copy),
154
+ };
155
+ if (values.trigger === "CODE" && values.code.trim() === "") {
156
+ errors.code = copy.form.codeRequired;
157
+ }
158
+ const { scope } = typeAndScopeFor(values);
159
+ if (scope === "CATEGORY" && targets.categoryIds.length === 0) {
160
+ errors.scope = copy.form.categoryTargetRequired;
161
+ }
162
+ if (scope === "ITEM" && targets.menuItemIds.length === 0) {
163
+ errors.scope = copy.form.itemTargetRequired;
164
+ }
165
+ // The window is half-open [start, end): equal dates are an EMPTY window, so
166
+ // the rule is "after", not "not before".
167
+ if (values.startsAt !== "" && values.endsAt !== "" && values.endsAt <= values.startsAt) {
168
+ errors.endsAt = copy.form.endsBeforeStarts;
169
+ }
170
+ return errors;
171
+ }
172
+
@@ -0,0 +1,90 @@
1
+ import type { DiscountWireRecord } from "./api";
2
+ import { comboRewardOf, kindOf } from "./form-kind";
3
+ import type { DiscountsFormatters } from "./format";
4
+
5
+ /**
6
+ * What the create/edit form HOLDS, and what an existing rule opens as.
7
+ *
8
+ * Its own module because the form and its validators both need the shape, and
9
+ * a type imported back out of the component file would make the two a cycle.
10
+ *
11
+ * Everything here is a STRING: `total-form` holds strings, because every value
12
+ * in a text input is one. The two switches (booleans) and the target id lists
13
+ * (arrays) are therefore not form values at all — they sit in local state
14
+ * beside the container and are merged in at submit.
15
+ */
16
+
17
+ export interface DiscountFormValues extends Record<string, string> {
18
+ name: string;
19
+ /** What kind of promotion this is. Decides `type` and `scope` — see `./form-kind`. */
20
+ kind: string;
21
+ /** Which of the two rewards a COMBO gives. Ignored by every other kind. */
22
+ comboReward: string;
23
+ percentOff: string;
24
+ amountOff: string;
25
+ bundlePrice: string;
26
+ freeUnits: string;
27
+ maxComboApplications: string;
28
+ scope: string;
29
+ trigger: string;
30
+ code: string;
31
+ startsAt: string;
32
+ endsAt: string;
33
+ minSubtotal: string;
34
+ usageLimit: string;
35
+ perBuyerLimit: string;
36
+ }
37
+
38
+ /** `YYYY-MM-DD` for a native date input, from the wire's ISO instant. */
39
+ function toDateInput(iso: string | null): string {
40
+ return iso === null ? "" : iso.slice(0, 10);
41
+ }
42
+
43
+ /** A new rule's defaults: the most common promotion an operator creates. */
44
+ const BLANK: DiscountFormValues = {
45
+ name: "",
46
+ kind: "PERCENTAGE",
47
+ comboReward: "PERCENTAGE",
48
+ percentOff: "",
49
+ amountOff: "",
50
+ bundlePrice: "",
51
+ freeUnits: "",
52
+ maxComboApplications: "",
53
+ scope: "ORDER",
54
+ trigger: "AUTOMATIC",
55
+ code: "",
56
+ startsAt: "",
57
+ endsAt: "",
58
+ minSubtotal: "",
59
+ usageLimit: "",
60
+ perBuyerLimit: "",
61
+ };
62
+
63
+ export function initialValues(
64
+ editing: DiscountWireRecord | null,
65
+ formatters: DiscountsFormatters,
66
+ ): DiscountFormValues {
67
+ if (editing === null) return { ...BLANK };
68
+ const cents = (value: number | null): string =>
69
+ value === null ? "" : formatters.toInput(Number((value / 100).toFixed(2)));
70
+ return {
71
+ name: editing.name,
72
+ kind: kindOf(editing),
73
+ comboReward: comboRewardOf(editing),
74
+ percentOff: editing.percentOffBp === null ? "" : formatters.toInput(editing.percentOffBp / 100),
75
+ amountOff: cents(editing.amountOffCents),
76
+ bundlePrice: cents(editing.bundlePriceCents ?? null),
77
+ freeUnits: editing.freeUnits == null ? "" : String(editing.freeUnits),
78
+ maxComboApplications:
79
+ editing.maxComboApplications == null ? "" : String(editing.maxComboApplications),
80
+ scope: editing.scope,
81
+ trigger: editing.trigger,
82
+ code: editing.code ?? "",
83
+ startsAt: toDateInput(editing.startsAt),
84
+ endsAt: toDateInput(editing.endsAt),
85
+ minSubtotal: cents(editing.minSubtotalCents),
86
+ usageLimit: editing.usageLimit === null ? "" : String(editing.usageLimit),
87
+ perBuyerLimit: editing.perBuyerLimit === null ? "" : String(editing.perBuyerLimit),
88
+ };
89
+ }
90
+
@@ -15,17 +15,22 @@ import { Stack } from "@12-apps/ui/mui/Stack";
15
15
  import type { ComboRequirement } from "../engine/types";
16
16
 
17
17
  import type { DiscountFormPayload, DiscountsApiClient, DiscountWireRecord, WireTargetGroup } from "./api";
18
- import { fill, type DiscountsWebCopy } from "./copy";
18
+ import type { DiscountsWebCopy } from "./copy";
19
19
  import {
20
+ ComboCapField,
20
21
  DiscountSwitches,
22
+ KindField,
21
23
  LimitFields,
22
- RuleFields,
24
+ RewardFields,
23
25
  ScopeField,
26
+ TriggerFields,
24
27
  WindowFields,
25
28
  type CurrencyFieldComponent,
26
29
  } from "./discount-form-fields";
27
- import { comboUnits } from "./combo-slot-builder";
28
30
  import { DiscountTargetPicker } from "./discount-target-picker";
31
+ import { validate, type FormTargets } from "./discount-form-validate";
32
+ import { initialValues, type DiscountFormValues } from "./discount-form-values";
33
+ import { kindOptions, typeAndScopeFor } from "./form-kind";
29
34
  import type { DiscountsFormatters } from "./format";
30
35
 
31
36
  /**
@@ -44,211 +49,6 @@ import type { DiscountsFormatters } from "./format";
44
49
  * `setFieldErrors` and paints the same inputs red.
45
50
  */
46
51
 
47
- interface DiscountFormValues extends Record<string, string> {
48
- name: string;
49
- type: string;
50
- percentOff: string;
51
- amountOff: string;
52
- bundlePrice: string;
53
- freeUnits: string;
54
- maxComboApplications: string;
55
- scope: string;
56
- trigger: string;
57
- code: string;
58
- startsAt: string;
59
- endsAt: string;
60
- minSubtotal: string;
61
- usageLimit: string;
62
- perBuyerLimit: string;
63
- }
64
-
65
- /** `YYYY-MM-DD` for a native date input, from the wire's ISO instant. */
66
- function toDateInput(iso: string | null): string {
67
- return iso === null ? "" : iso.slice(0, 10);
68
- }
69
-
70
- /** A new rule's defaults: the most common promotion an operator creates. */
71
- const BLANK: DiscountFormValues = {
72
- name: "",
73
- type: "PERCENTAGE",
74
- percentOff: "",
75
- amountOff: "",
76
- bundlePrice: "",
77
- freeUnits: "",
78
- maxComboApplications: "",
79
- scope: "ORDER",
80
- trigger: "AUTOMATIC",
81
- code: "",
82
- startsAt: "",
83
- endsAt: "",
84
- minSubtotal: "",
85
- usageLimit: "",
86
- perBuyerLimit: "",
87
- };
88
-
89
- function initialValues(
90
- editing: DiscountWireRecord | null,
91
- formatters: DiscountsFormatters,
92
- ): DiscountFormValues {
93
- if (editing === null) return { ...BLANK };
94
- const cents = (value: number | null): string =>
95
- value === null ? "" : formatters.toInput(Number((value / 100).toFixed(2)));
96
- return {
97
- name: editing.name,
98
- type: editing.type,
99
- percentOff: editing.percentOffBp === null ? "" : formatters.toInput(editing.percentOffBp / 100),
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),
105
- scope: editing.scope,
106
- trigger: editing.trigger,
107
- code: editing.code ?? "",
108
- startsAt: toDateInput(editing.startsAt),
109
- endsAt: toDateInput(editing.endsAt),
110
- minSubtotal: cents(editing.minSubtotalCents),
111
- usageLimit: editing.usageLimit === null ? "" : String(editing.usageLimit),
112
- perBuyerLimit: editing.perBuyerLimit === null ? "" : String(editing.perBuyerLimit),
113
- };
114
- }
115
-
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. */
172
- function checkValue(
173
- values: DiscountFormValues,
174
- slots: readonly ComboRequirement[],
175
- formatters: DiscountsFormatters,
176
- copy: DiscountsWebCopy,
177
- ): Record<string, string> {
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);
183
- }
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[];
221
- }
222
-
223
- /** The cross-field rules a per-input schema cannot express. */
224
- function validate(
225
- values: DiscountFormValues,
226
- targets: FormTargets,
227
- formatters: DiscountsFormatters,
228
- copy: DiscountsWebCopy,
229
- ): Record<string, string> {
230
- const slots = targets.comboRequirements;
231
- const errors: Record<string, string> = {
232
- ...checkValue(values, slots, formatters, copy),
233
- ...checkCombo(values, slots, copy),
234
- };
235
- if (values.trigger === "CODE" && values.code.trim() === "") {
236
- errors.code = copy.form.codeRequired;
237
- }
238
- if (values.scope === "CATEGORY" && targets.categoryIds.length === 0) {
239
- errors.scope = copy.form.categoryTargetRequired;
240
- }
241
- if (values.scope === "ITEM" && targets.menuItemIds.length === 0) {
242
- errors.scope = copy.form.itemTargetRequired;
243
- }
244
- // The window is half-open [start, end): equal dates are an EMPTY window, so
245
- // the rule is "after", not "not before".
246
- if (values.startsAt !== "" && values.endsAt !== "" && values.endsAt <= values.startsAt) {
247
- errors.endsAt = copy.form.endsBeforeStarts;
248
- }
249
- return errors;
250
- }
251
-
252
52
  /** The values `total-form` cannot hold, kept beside it and merged at submit. */
253
53
  function useNonStringState(editing: DiscountWireRecord | null) {
254
54
  const [active, setActive] = useState<boolean>(editing?.active ?? true);
@@ -318,6 +118,10 @@ function makeSubmit(
318
118
  }
319
119
  const payload: DiscountFormPayload = {
320
120
  ...values,
121
+ // The one place the form's question becomes the API's two columns. Every
122
+ // other consumer — the write path, the evaluator, the CHECK constraints —
123
+ // still sees the `type`/`scope` pair it always did.
124
+ ...typeAndScopeFor(values),
321
125
  ...targets,
322
126
  stackable: extra.stackable,
323
127
  active: extra.active,
@@ -352,7 +156,8 @@ export function DiscountForm(props: DiscountFormProps): JSX.Element {
352
156
  >
353
157
  <Stack spacing={2}>
354
158
  <Fields.TextField name="name" label={copy.form.name} />
355
- <RuleFields copy={copy} currencyField={currencyField} />
159
+ <KindField copy={copy} kinds={kindOptions(editing)} />
160
+ <RewardFields copy={copy} currencyField={currencyField} />
356
161
  <ScopeField copy={copy} />
357
162
  <DiscountTargetPicker
358
163
  groups={groups}
@@ -366,6 +171,8 @@ export function DiscountForm(props: DiscountFormProps): JSX.Element {
366
171
  onComboRequirementsChange: extra.setComboRequirements,
367
172
  }}
368
173
  />
174
+ <ComboCapField copy={copy} />
175
+ <TriggerFields copy={copy} />
369
176
  <WindowFields copy={copy} />
370
177
  <LimitFields copy={copy} currencyField={currencyField} />
371
178
  <DiscountSwitches
@@ -9,16 +9,18 @@ import type { ComboRequirement } from "../engine/types";
9
9
  import type { WireTargetGroup } from "./api";
10
10
  import { ComboSlotBuilder } from "./combo-slot-builder";
11
11
  import { fill, type DiscountsWebCopy } from "./copy";
12
+ import { FreeUnitsBuilder } from "./free-units-builder";
12
13
  import { TargetPickerField } from "./target-picker-field";
13
14
 
14
15
  /**
15
16
  * What a scoped discount points at, picked from the host's own collections.
16
17
  *
17
- * This file is the ROUTER: it reads the scope and decides which shape the
18
+ * This file is the ROUTER: it reads the KIND and decides which shape the
18
19
  * targets take. The controls themselves live next door — one collection's
19
20
  * 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
+ * `combo-slot-builder`, the one-group "leve 3, pague 2" in
22
+ * `free-units-builder` — because the builders need the picker per group and a
23
+ * file cannot import its own importer.
22
24
  *
23
25
  * Which collections exist is not decided here. The surface reads them from
24
26
  * `GET /discounts/targets`, which answers whatever the host registered — so
@@ -61,17 +63,17 @@ function scopeFor(targetType: string): string {
61
63
  }
62
64
 
63
65
  /**
64
- * The scope-driven target block.
66
+ * The kind-driven target block.
65
67
  *
66
- * Reads the LIVE `scope` from the form context, so flipping the toggle swaps
67
- * the picker immediately without the parent form having to mirror the value
68
- * into its own state.
68
+ * Reads the LIVE `kind` and `scope` from the form context, so flipping either
69
+ * toggle swaps the control immediately without the parent form having to mirror
70
+ * the value into its own state.
69
71
  *
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.
72
+ * The two combo kinds are a different SHAPE, not another picker (FUT-268): a
73
+ * combo is a list of quantified groups, so it gets a builder and every
74
+ * collection at once, while CATEGORY and ITEM each get exactly one collection's
75
+ * picker. ORDER renders nothing — an order-wide discount covers everything, so
76
+ * a target list there is not empty, it is meaningless.
75
77
  */
76
78
  export function DiscountTargetPicker({
77
79
  groups,
@@ -82,10 +84,25 @@ export function DiscountTargetPicker({
82
84
  copy: DiscountsWebCopy;
83
85
  selection: DiscountTargetSelection;
84
86
  }): JSX.Element | null {
85
- const { values } = useFormContext();
87
+ const { values, errors, setFieldValue } = useFormContext();
88
+ const kind = values.kind ?? "PERCENTAGE";
86
89
  const scope = values.scope ?? "ORDER";
87
90
 
88
- if (scope === "COMBO") {
91
+ if (kind === "FREE_UNITS") {
92
+ return (
93
+ <FreeUnitsBuilder
94
+ slots={selection.comboRequirements}
95
+ groups={groups}
96
+ copy={copy}
97
+ freeUnits={values.freeUnits ?? ""}
98
+ error={errors.freeUnits}
99
+ onChange={selection.onComboRequirementsChange}
100
+ onFreeUnitsChange={(next) => setFieldValue("freeUnits", next)}
101
+ />
102
+ );
103
+ }
104
+
105
+ if (kind === "COMBO" || kind === "BUNDLE_PRICE") {
89
106
  return (
90
107
  <ComboSlotBuilder
91
108
  slots={selection.comboRequirements}
@@ -108,6 +125,7 @@ export function DiscountTargetPicker({
108
125
  requiredMessage={copy.targets.required}
109
126
  ids={ids}
110
127
  onChange={onChange}
128
+ copy={copy.categorySelect}
111
129
  dataTestId={`discount-${group.slug}-targets`}
112
130
  />
113
131
  );