@12-apps/discounts 1.2.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.
Files changed (36) hide show
  1. package/README.md +231 -16
  2. package/package.json +55 -6
  3. package/prisma/discounts.prisma +181 -0
  4. package/prisma/migrations/20260821140000_discounts_package_owned/migration.sql +286 -0
  5. package/scripts/sync-discounts-schema.mjs +53 -0
  6. package/src/engine/kinds.ts +62 -0
  7. package/src/index.ts +5 -0
  8. package/src/manifest/index.ts +45 -17
  9. package/src/manifest/web.ts +55 -0
  10. package/src/react/api.ts +209 -0
  11. package/src/react/combo-slot-builder.tsx +213 -0
  12. package/src/react/copy.ts +296 -0
  13. package/src/react/create-web-discounts.tsx +162 -0
  14. package/src/react/discount-actions-menu.tsx +127 -0
  15. package/src/react/discount-card.tsx +92 -0
  16. package/src/react/discount-form-fields.tsx +245 -0
  17. package/src/react/discount-form.tsx +393 -0
  18. package/src/react/discount-list-card.tsx +199 -0
  19. package/src/react/discount-target-picker.tsx +114 -0
  20. package/src/react/discounts-screen.tsx +314 -0
  21. package/src/react/format.ts +126 -0
  22. package/src/react/index.ts +72 -0
  23. package/src/react/pt-BR.ts +157 -0
  24. package/src/react/row.ts +66 -0
  25. package/src/react/table-config.ts +238 -0
  26. package/src/react/target-picker-field.tsx +138 -0
  27. package/src/react/transport.ts +115 -0
  28. package/src/react/use-discounts-data.ts +103 -0
  29. package/src/server/collections.ts +290 -0
  30. package/src/server/copy.ts +12 -0
  31. package/src/server/index.ts +23 -0
  32. package/src/server/logging.ts +20 -3
  33. package/src/server/mcp.ts +33 -0
  34. package/src/server/pt-BR.ts +1 -0
  35. package/src/server/routes.ts +74 -9
  36. package/src/server/targets.ts +102 -0
@@ -0,0 +1,245 @@
1
+ "use client";
2
+
3
+ import type { JSX } from "react";
4
+
5
+ import { Switch } from "@12-apps/ui/form/Switch";
6
+ import { Fields, useFormContext } from "@12-apps/ui/form/total-form";
7
+ import { Box } from "@12-apps/ui/mui/Box";
8
+ import { Stack } from "@12-apps/ui/mui/Stack";
9
+ import { Text } from "@12-apps/ui/typography/Text";
10
+
11
+ import { DISCOUNT_SCOPES, DISCOUNT_TRIGGERS, DISCOUNT_TYPES } from "../engine/kinds";
12
+
13
+ import type { DiscountsWebCopy } from "./copy";
14
+
15
+ /**
16
+ * The form's inputs, split from the form itself for the 400-line file gate —
17
+ * comments count toward it, and the container plus these would not fit.
18
+ *
19
+ * Every option list is built FROM the vocabulary arrays rather than spelled out
20
+ * again: those arrays back the database's CHECK constraints, so an option an
21
+ * operator can pick is by construction a value the column will store. A
22
+ * hand-written list is how a form offers a fifth type the database refuses.
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
+
51
+ /** One closed set as a toggle's options, labelled by the pack. */
52
+ function optionsFor<TKey extends string>(
53
+ values: readonly TKey[],
54
+ labels: Readonly<Record<TKey, string>>,
55
+ ): { value: string; label: string }[] {
56
+ return values.map((value) => ({ value, label: labels[value] }));
57
+ }
58
+
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
+ */
75
+ function ValueField({
76
+ copy,
77
+ currencyField,
78
+ }: {
79
+ copy: DiscountsWebCopy;
80
+ currencyField: CurrencyFieldComponent;
81
+ }): JSX.Element {
82
+ const { values } = useFormContext();
83
+ 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
+ }
95
+ return (
96
+ <Fields.TextField
97
+ name="percentOff"
98
+ label={copy.form.percentOff}
99
+ placeholder={copy.form.percentPlaceholder}
100
+ />
101
+ );
102
+ }
103
+
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
+ /** The coupon input, shown only for a CODE-triggered rule. */
115
+ function CodeField({ copy }: { copy: DiscountsWebCopy }): JSX.Element | null {
116
+ const { values } = useFormContext();
117
+ if (values.trigger !== "CODE") return null;
118
+ return (
119
+ <Fields.TextField
120
+ name="code"
121
+ label={copy.form.code}
122
+ placeholder={copy.form.codePlaceholder}
123
+ />
124
+ );
125
+ }
126
+
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 {
135
+ return (
136
+ <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
+ <Fields.ToggleField
144
+ name="trigger"
145
+ label={copy.form.trigger}
146
+ options={optionsFor(DISCOUNT_TRIGGERS, copy.labels.trigger)}
147
+ />
148
+ <CodeField copy={copy} />
149
+ </Box>
150
+ );
151
+ }
152
+
153
+ /** The scope toggle, which decides which target picker is shown below it. */
154
+ export function ScopeField({ copy }: { copy: DiscountsWebCopy }): JSX.Element {
155
+ return (
156
+ <Fields.ToggleField
157
+ name="scope"
158
+ label={copy.form.scope}
159
+ options={optionsFor(DISCOUNT_SCOPES, copy.labels.scope)}
160
+ />
161
+ );
162
+ }
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
+
184
+ /** The active window. Both sides optional; the end date is EXCLUSIVE. */
185
+ export function WindowFields({ copy }: { copy: DiscountsWebCopy }): JSX.Element {
186
+ return (
187
+ <Box sx={{ display: "grid", gap: 2, gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" } }}>
188
+ <Fields.TextField name="startsAt" label={copy.form.startsAt} type="date" />
189
+ <Fields.TextField name="endsAt" label={copy.form.endsAt} type="date" />
190
+ </Box>
191
+ );
192
+ }
193
+
194
+ /** The minimum basket and the two redemption caps. */
195
+ export function LimitFields({
196
+ copy,
197
+ currencyField,
198
+ }: {
199
+ copy: DiscountsWebCopy;
200
+ currencyField: CurrencyFieldComponent;
201
+ }): JSX.Element {
202
+ const Currency = currencyField;
203
+ return (
204
+ <Box sx={{ display: "grid", gap: 2, gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr 1fr" } }}>
205
+ <Currency name="minSubtotal" label={copy.form.minSubtotal} />
206
+ <Fields.TextField name="usageLimit" label={copy.form.usageLimit} type="number" />
207
+ <Fields.TextField name="perBuyerLimit" label={copy.form.perBuyerLimit} type="number" />
208
+ <ComboCapField copy={copy} />
209
+ </Box>
210
+ );
211
+ }
212
+
213
+ /** The two booleans, owned by the parent so submit can send them. */
214
+ export function DiscountSwitches({
215
+ copy,
216
+ active,
217
+ stackable,
218
+ onActive,
219
+ onStackable,
220
+ }: {
221
+ copy: DiscountsWebCopy;
222
+ active: boolean;
223
+ stackable: boolean;
224
+ onActive: (next: boolean) => void;
225
+ onStackable: (next: boolean) => void;
226
+ }): JSX.Element {
227
+ return (
228
+ <Stack spacing={1}>
229
+ <Switch
230
+ checked={active}
231
+ onChange={(event) => onActive(event.target.checked)}
232
+ label={copy.form.active}
233
+ description={copy.form.activeHint}
234
+ dataTestId="discount-active"
235
+ />
236
+ <Switch
237
+ checked={stackable}
238
+ onChange={(event) => onStackable(event.target.checked)}
239
+ label={copy.form.stackable}
240
+ description={copy.form.stackableHint}
241
+ dataTestId="discount-stackable"
242
+ />
243
+ </Stack>
244
+ );
245
+ }
@@ -0,0 +1,393 @@
1
+ "use client";
2
+
3
+ import { useState, type JSX } from "react";
4
+
5
+ import { minLength, required } from "@12-apps/forms-core";
6
+ import { Alert } from "@12-apps/ui/data-display/Alert";
7
+ import {
8
+ Fields,
9
+ FormContainer,
10
+ FormErrorSnackbar,
11
+ SubmitButton,
12
+ } from "@12-apps/ui/form/total-form";
13
+ import { Stack } from "@12-apps/ui/mui/Stack";
14
+
15
+ import type { ComboRequirement } from "../engine/types";
16
+
17
+ import type { DiscountFormPayload, DiscountsApiClient, DiscountWireRecord, WireTargetGroup } from "./api";
18
+ import { fill, type DiscountsWebCopy } from "./copy";
19
+ import {
20
+ DiscountSwitches,
21
+ LimitFields,
22
+ RuleFields,
23
+ ScopeField,
24
+ WindowFields,
25
+ type CurrencyFieldComponent,
26
+ } from "./discount-form-fields";
27
+ import { comboUnits } from "./combo-slot-builder";
28
+ import { DiscountTargetPicker } from "./discount-target-picker";
29
+ import type { DiscountsFormatters } from "./format";
30
+
31
+ /**
32
+ * The create/edit form — shared by the header's create dialog and the row
33
+ * menu's edit dialog, so the fields and the submit live in one place.
34
+ * `editing === null` creates; otherwise it re-states the rule whole.
35
+ *
36
+ * Two kinds of value are deliberately NOT `total-form` values, for one reason:
37
+ * `total-form` holds STRINGS. The two switches (booleans) and the target id
38
+ * lists (arrays) sit in local state beside the container and are merged in at
39
+ * submit.
40
+ *
41
+ * Every rule checked here is ALSO checked on the server. This half exists for
42
+ * the operator's sake — instant, field-attached feedback — never as the
43
+ * authority: the surface re-validates, and a server refusal comes back through
44
+ * `setFieldErrors` and paints the same inputs red.
45
+ */
46
+
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
+ /** The values `total-form` cannot hold, kept beside it and merged at submit. */
253
+ function useNonStringState(editing: DiscountWireRecord | null) {
254
+ const [active, setActive] = useState<boolean>(editing?.active ?? true);
255
+ const [stackable, setStackable] = useState<boolean>(editing?.stackable ?? true);
256
+ const [categoryIds, setCategoryIds] = useState<string[]>(editing?.categoryIds ?? []);
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
+ );
264
+ return {
265
+ active,
266
+ stackable,
267
+ categoryIds,
268
+ menuItemIds,
269
+ comboRequirements,
270
+ setActive,
271
+ setStackable,
272
+ setCategoryIds,
273
+ setMenuItemIds,
274
+ setComboRequirements,
275
+ };
276
+ }
277
+
278
+ type NonStringState = ReturnType<typeof useNonStringState>;
279
+ type SubmitHelpers = { setFieldErrors: (errors: Record<string, string | undefined>) => void };
280
+
281
+ export interface DiscountFormProps {
282
+ api: DiscountsApiClient;
283
+ copy: DiscountsWebCopy;
284
+ formatters: DiscountsFormatters;
285
+ currencyField: CurrencyFieldComponent;
286
+ groups: readonly WireTargetGroup[];
287
+ editing: DiscountWireRecord | null;
288
+ onSaved: () => void;
289
+ /** Where a refused or failed write is reported, beyond the operator's screen. */
290
+ onError: (error: unknown, context: string) => void;
291
+ }
292
+
293
+ /**
294
+ * Build the submit handler.
295
+ *
296
+ * A factory rather than a closure inside the component, so the component stays
297
+ * inside the size gate and the validate → send → attribute-the-failure sequence
298
+ * reads as one block.
299
+ */
300
+ function makeSubmit(
301
+ props: DiscountFormProps,
302
+ extra: NonStringState,
303
+ setError: (message: string | null) => void,
304
+ ): (values: DiscountFormValues, helpers: SubmitHelpers) => Promise<void> {
305
+ const { api, copy, formatters, editing, onSaved, onError } = props;
306
+ return async function handleSubmit(values, { setFieldErrors }): Promise<void> {
307
+ setError(null);
308
+ const targets: FormTargets = {
309
+ categoryIds: extra.categoryIds,
310
+ menuItemIds: extra.menuItemIds,
311
+ comboRequirements: extra.comboRequirements,
312
+ };
313
+ const invalid = validate(values, targets, formatters, copy);
314
+ if (Object.keys(invalid).length > 0) {
315
+ setFieldErrors(invalid);
316
+ setError(copy.form.reviewFields);
317
+ return;
318
+ }
319
+ const payload: DiscountFormPayload = {
320
+ ...values,
321
+ ...targets,
322
+ stackable: extra.stackable,
323
+ active: extra.active,
324
+ };
325
+ const result = editing
326
+ ? await api.update(editing.id, payload)
327
+ : await api.create(payload);
328
+ if (result.ok) {
329
+ onSaved();
330
+ return;
331
+ }
332
+ setError(result.error);
333
+ // The server attributes what it can — a duplicate code, a rate out of range
334
+ // — so highlight those inputs instead of only showing the banner.
335
+ if (result.fieldErrors) setFieldErrors(result.fieldErrors);
336
+ onError(result, editing ? "discounts.update" : "discounts.create");
337
+ };
338
+ }
339
+
340
+ export function DiscountForm(props: DiscountFormProps): JSX.Element {
341
+ const { copy, formatters, currencyField, groups, editing } = props;
342
+ const [error, setError] = useState<string | null>(null);
343
+ const extra = useNonStringState(editing);
344
+ const handleSubmit = makeSubmit(props, extra, setError);
345
+
346
+ return (
347
+ <FormContainer<DiscountFormValues>
348
+ initialValues={initialValues(editing, formatters)}
349
+ schema={{ name: [required(copy.form.nameRequired), minLength(2)] }}
350
+ onSubmit={handleSubmit}
351
+ dataTestId="discount-form"
352
+ >
353
+ <Stack spacing={2}>
354
+ <Fields.TextField name="name" label={copy.form.name} />
355
+ <RuleFields copy={copy} currencyField={currencyField} />
356
+ <ScopeField copy={copy} />
357
+ <DiscountTargetPicker
358
+ groups={groups}
359
+ copy={copy}
360
+ selection={{
361
+ categoryIds: extra.categoryIds,
362
+ menuItemIds: extra.menuItemIds,
363
+ comboRequirements: extra.comboRequirements,
364
+ onCategoryIdsChange: extra.setCategoryIds,
365
+ onMenuItemIdsChange: extra.setMenuItemIds,
366
+ onComboRequirementsChange: extra.setComboRequirements,
367
+ }}
368
+ />
369
+ <WindowFields copy={copy} />
370
+ <LimitFields copy={copy} currencyField={currencyField} />
371
+ <DiscountSwitches
372
+ copy={copy}
373
+ active={extra.active}
374
+ stackable={extra.stackable}
375
+ onActive={extra.setActive}
376
+ onStackable={extra.setStackable}
377
+ />
378
+ {error && (
379
+ <Alert
380
+ variant="danger"
381
+ title={copy.form.saveFailed}
382
+ description={error}
383
+ data-testid="discount-form-error"
384
+ />
385
+ )}
386
+ <SubmitButton dataTestId="discount-form-submit">
387
+ {editing ? copy.form.submitEdit : copy.form.submitCreate}
388
+ </SubmitButton>
389
+ </Stack>
390
+ <FormErrorSnackbar />
391
+ </FormContainer>
392
+ );
393
+ }