@12-apps/discounts 1.1.0 → 1.3.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,127 @@
1
+ "use client";
2
+
3
+ import { useState, type JSX } from "react";
4
+
5
+ import { CardKebab, useCardActions, useRemoveConfirm } from "@12-apps/ui/data-display/CardKit";
6
+ import { Dialog, DialogContent } from "@12-apps/ui/feedback/Dialog";
7
+ import type { DropdownMenuItem } from "@12-apps/ui/navigation/DropdownMenu";
8
+
9
+ import type { DiscountsApiClient, WireTargetGroup } from "./api";
10
+ import type { DiscountsWebCopy } from "./copy";
11
+ import { DiscountForm } from "./discount-form";
12
+ import type { CurrencyFieldComponent } from "./discount-form-fields";
13
+ import type { DiscountsFormatters } from "./format";
14
+ import type { DiscountListItem } from "./row";
15
+
16
+ /**
17
+ * One rule's self-contained "⋮" menu: it owns its edit dialog inline and its
18
+ * delete write, reading the refresh and the error channel from
19
+ * `CardActionsProvider`.
20
+ *
21
+ * Reused by BOTH the grid row and the card, so the two can never offer a
22
+ * different set of actions — which is the whole reason it is one component
23
+ * rather than a menu per surface.
24
+ *
25
+ * There is deliberately no version-history entry. A discount is not versioned
26
+ * catalog content: restoring "version 3" of a coupon whose redemption counter
27
+ * has since moved is meaningless, so the surface does not offer it.
28
+ */
29
+
30
+ export interface DiscountActionsMenuProps {
31
+ row: DiscountListItem;
32
+ api: DiscountsApiClient;
33
+ copy: DiscountsWebCopy;
34
+ formatters: DiscountsFormatters;
35
+ currencyField: CurrencyFieldComponent;
36
+ /**
37
+ * The registered collections, for the edit form's target pickers.
38
+ *
39
+ * Optional, and the absence is a real state rather than an oversight: while
40
+ * they are still loading, only DELETE is offered — an edit form opened
41
+ * without them would show empty pickers, and saving would silently clear a
42
+ * target list it never had the chance to display.
43
+ */
44
+ groups?: readonly WireTargetGroup[];
45
+ onError: (error: unknown, context: string) => void;
46
+ }
47
+
48
+ export function DiscountActionsMenu({
49
+ row,
50
+ api,
51
+ copy,
52
+ formatters,
53
+ currencyField,
54
+ groups,
55
+ onError,
56
+ }: DiscountActionsMenuProps): JSX.Element {
57
+ const { onRefresh } = useCardActions();
58
+ const [editOpen, setEditOpen] = useState(false);
59
+ const canEdit = groups !== undefined;
60
+
61
+ const remove = useRemoveConfirm({
62
+ write: async () => {
63
+ const result = await api.remove(row.id);
64
+ if (!result.ok) onError(result, "discounts.delete");
65
+ return result;
66
+ },
67
+ title: copy.actions.deleteTitle,
68
+ entityName: row.name,
69
+ description: copy.actions.deleteDescription,
70
+ confirmText: copy.actions.delete,
71
+ fallbackError: copy.actions.deleteFailed,
72
+ dataTestId: "discount-delete-confirm",
73
+ });
74
+
75
+ const items: DropdownMenuItem[] = [];
76
+ if (canEdit) {
77
+ items.push({ id: "edit", label: copy.actions.edit, onClick: () => setEditOpen(true) });
78
+ }
79
+ items.push({
80
+ id: "delete",
81
+ label: copy.actions.delete,
82
+ color: "danger",
83
+ onClick: remove.request,
84
+ });
85
+
86
+ return (
87
+ <>
88
+ <CardKebab
89
+ items={items}
90
+ menuLabel={copy.actions.menu}
91
+ dataTestId={`discount-actions-${row.id}`}
92
+ />
93
+ {remove.dialog}
94
+ {canEdit && (
95
+ <Dialog
96
+ open={editOpen}
97
+ onClose={() => setEditOpen(false)}
98
+ title={copy.form.editTitle}
99
+ size="md"
100
+ showCloseButton
101
+ dataTestId="discount-dialog"
102
+ >
103
+ {/* Mounted only while open, so the next open re-seeds from the row
104
+ instead of keeping the last attempt's values. */}
105
+ {editOpen && (
106
+ <DialogContent>
107
+ <DiscountForm
108
+ key={row.id}
109
+ api={api}
110
+ copy={copy}
111
+ formatters={formatters}
112
+ currencyField={currencyField}
113
+ groups={groups}
114
+ editing={row.record}
115
+ onError={onError}
116
+ onSaved={() => {
117
+ setEditOpen(false);
118
+ onRefresh();
119
+ }}
120
+ />
121
+ </DialogContent>
122
+ )}
123
+ </Dialog>
124
+ )}
125
+ </>
126
+ );
127
+ }
@@ -0,0 +1,92 @@
1
+ "use client";
2
+
3
+ import type { JSX } from "react";
4
+
5
+ import LocalOfferOutlinedIcon from "@mui/icons-material/LocalOfferOutlined";
6
+
7
+ import type { KindCardProps } from "@12-apps/ui/data-display/CardKit";
8
+ import { BaseCard } from "@12-apps/ui/data-display/DataViews";
9
+ import { Chip } from "@12-apps/ui/data-display/Chip";
10
+ import { Box } from "@12-apps/ui/mui/Box";
11
+
12
+ import type { DiscountsWebCopy } from "./copy";
13
+ import { DiscountActionsMenu, type DiscountActionsMenuProps } from "./discount-actions-menu";
14
+ import type { DiscountListItem } from "./row";
15
+
16
+ /**
17
+ * One rule as a tile for the grid's card layout.
18
+ *
19
+ * The subtitle carries the two numbers an operator scans a promotions board
20
+ * for: what it takes off, and how long it runs. The chips carry the two facts
21
+ * that decide WHERE and WHEN it fires. "Paused" is shown only when true —
22
+ * a card shouting "Active" on every tile would make the one paused promotion
23
+ * harder to spot, not easier.
24
+ */
25
+
26
+ /** The semantic colour a scope's chip carries. Not copy: it is a token. */
27
+ const SCOPE_COLOR: Record<string, "neutral" | "warning" | "success" | "info"> = {
28
+ ORDER: "neutral",
29
+ CATEGORY: "warning",
30
+ ITEM: "success",
31
+ COMBO: "info",
32
+ };
33
+
34
+ const TRIGGER_COLOR: Record<string, "success" | "info"> = {
35
+ AUTOMATIC: "success",
36
+ CODE: "info",
37
+ };
38
+
39
+ type DiscountCardProps = KindCardProps &
40
+ Omit<DiscountActionsMenuProps, "row"> & { row: DiscountListItem };
41
+
42
+ export function DiscountCard({
43
+ row,
44
+ selection,
45
+ aspectRatio,
46
+ copy,
47
+ ...menu
48
+ }: DiscountCardProps & { copy: DiscountsWebCopy }): JSX.Element {
49
+ const scopeLabel = copy.labels.scope[row.scope as keyof typeof copy.labels.scope] ?? row.scope;
50
+ const triggerLabel =
51
+ copy.labels.trigger[row.trigger as keyof typeof copy.labels.trigger] ?? row.trigger;
52
+ return (
53
+ <BaseCard
54
+ aspectRatio={aspectRatio ?? "4:3"}
55
+ scale={selection.scale}
56
+ selected={selection.selected}
57
+ onToggleSelect={selection.onToggleSelect}
58
+ testId={`discount-card-${row.id}`}
59
+ title={row.name}
60
+ subtitle={`${row.valueLabel} · ${row.windowLabel}`}
61
+ imageFallback={<LocalOfferOutlinedIcon sx={{ fontSize: 40, opacity: 0.35 }} />}
62
+ menu={<DiscountActionsMenu row={row} copy={copy} {...menu} />}
63
+ >
64
+ <Box sx={{ display: "flex", gap: 0.5, flexWrap: "wrap" }}>
65
+ <Chip
66
+ label={scopeLabel}
67
+ size="sm"
68
+ variant="filled"
69
+ color={SCOPE_COLOR[row.scope] ?? "neutral"}
70
+ dataTestId={`discount-card-scope-${row.id}`}
71
+ />
72
+ <Chip
73
+ // A coupon identifies itself; the word "code" beside a code says less.
74
+ label={row.trigger === "CODE" ? (row.code ?? triggerLabel) : triggerLabel}
75
+ size="sm"
76
+ variant="outlined"
77
+ color={TRIGGER_COLOR[row.trigger] ?? "neutral"}
78
+ dataTestId={`discount-card-trigger-${row.id}`}
79
+ />
80
+ {!row.active && (
81
+ <Chip
82
+ label={copy.card.paused}
83
+ size="sm"
84
+ variant="outlined"
85
+ color="neutral"
86
+ dataTestId={`discount-card-paused-${row.id}`}
87
+ />
88
+ )}
89
+ </Box>
90
+ </BaseCard>
91
+ );
92
+ }
@@ -0,0 +1,174 @@
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
+
10
+ import { DISCOUNT_SCOPES, DISCOUNT_TRIGGERS, DISCOUNT_TYPES } from "../engine/kinds";
11
+
12
+ import type { DiscountsWebCopy } from "./copy";
13
+
14
+ /**
15
+ * The form's inputs, split from the form itself for the 400-line file gate —
16
+ * comments count toward it, and the container plus these would not fit.
17
+ *
18
+ * Every option list is built FROM the vocabulary arrays rather than spelled out
19
+ * again: those arrays back the database's CHECK constraints, so an option an
20
+ * operator can pick is by construction a value the column will store. A
21
+ * hand-written list is how a form offers a fifth type the database refuses.
22
+ */
23
+
24
+ /** One closed set as a toggle's options, labelled by the pack. */
25
+ function optionsFor<TKey extends string>(
26
+ values: readonly TKey[],
27
+ labels: Readonly<Record<TKey, string>>,
28
+ ): { value: string; label: string }[] {
29
+ return values.map((value) => ({ value, label: labels[value] }));
30
+ }
31
+
32
+ /** Percentage or amount — whichever the chosen type actually stores. */
33
+ function ValueField({
34
+ copy,
35
+ currencyField,
36
+ }: {
37
+ copy: DiscountsWebCopy;
38
+ currencyField: CurrencyFieldComponent;
39
+ }): JSX.Element {
40
+ const { values } = useFormContext();
41
+ const Currency = currencyField;
42
+ if (values.type === "FIXED_AMOUNT") {
43
+ return <Currency name="amountOff" label={copy.form.amountOff} />;
44
+ }
45
+ return (
46
+ <Fields.TextField
47
+ name="percentOff"
48
+ label={copy.form.percentOff}
49
+ placeholder={copy.form.percentPlaceholder}
50
+ />
51
+ );
52
+ }
53
+
54
+ /**
55
+ * A money input, supplied by the host.
56
+ *
57
+ * Currency entry is a host decision — masking, the symbol's side, whether cents
58
+ * are typed or implied — and `@12-apps/ui` has no opinionated one. So the
59
+ * surface takes the component rather than shipping a guess; a host with nothing
60
+ * special passes a plain text field.
61
+ */
62
+ export type CurrencyFieldComponent = (props: { name: string; label: string }) => JSX.Element;
63
+
64
+ /** The coupon input, shown only for a CODE-triggered rule. */
65
+ function CodeField({ copy }: { copy: DiscountsWebCopy }): JSX.Element | null {
66
+ const { values } = useFormContext();
67
+ if (values.trigger !== "CODE") return null;
68
+ return (
69
+ <Fields.TextField
70
+ name="code"
71
+ label={copy.form.code}
72
+ placeholder={copy.form.codePlaceholder}
73
+ />
74
+ );
75
+ }
76
+
77
+ /** Type + value + trigger + code, the four that decide WHAT and HOW. */
78
+ export function RuleFields({
79
+ copy,
80
+ currencyField,
81
+ }: {
82
+ copy: DiscountsWebCopy;
83
+ currencyField: CurrencyFieldComponent;
84
+ }): JSX.Element {
85
+ return (
86
+ <Box sx={{ display: "grid", gap: 2, gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" } }}>
87
+ <Fields.ToggleField
88
+ name="type"
89
+ label={copy.form.type}
90
+ options={optionsFor(DISCOUNT_TYPES, copy.labels.type)}
91
+ />
92
+ <ValueField copy={copy} currencyField={currencyField} />
93
+ <Fields.ToggleField
94
+ name="trigger"
95
+ label={copy.form.trigger}
96
+ options={optionsFor(DISCOUNT_TRIGGERS, copy.labels.trigger)}
97
+ />
98
+ <CodeField copy={copy} />
99
+ </Box>
100
+ );
101
+ }
102
+
103
+ /** The scope toggle, which decides which target picker is shown below it. */
104
+ export function ScopeField({ copy }: { copy: DiscountsWebCopy }): JSX.Element {
105
+ return (
106
+ <Fields.ToggleField
107
+ name="scope"
108
+ label={copy.form.scope}
109
+ options={optionsFor(DISCOUNT_SCOPES, copy.labels.scope)}
110
+ />
111
+ );
112
+ }
113
+
114
+ /** The active window. Both sides optional; the end date is EXCLUSIVE. */
115
+ export function WindowFields({ copy }: { copy: DiscountsWebCopy }): JSX.Element {
116
+ return (
117
+ <Box sx={{ display: "grid", gap: 2, gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" } }}>
118
+ <Fields.TextField name="startsAt" label={copy.form.startsAt} type="date" />
119
+ <Fields.TextField name="endsAt" label={copy.form.endsAt} type="date" />
120
+ </Box>
121
+ );
122
+ }
123
+
124
+ /** The minimum basket and the two redemption caps. */
125
+ export function LimitFields({
126
+ copy,
127
+ currencyField,
128
+ }: {
129
+ copy: DiscountsWebCopy;
130
+ currencyField: CurrencyFieldComponent;
131
+ }): JSX.Element {
132
+ const Currency = currencyField;
133
+ return (
134
+ <Box sx={{ display: "grid", gap: 2, gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr 1fr" } }}>
135
+ <Currency name="minSubtotal" label={copy.form.minSubtotal} />
136
+ <Fields.TextField name="usageLimit" label={copy.form.usageLimit} type="number" />
137
+ <Fields.TextField name="perBuyerLimit" label={copy.form.perBuyerLimit} type="number" />
138
+ </Box>
139
+ );
140
+ }
141
+
142
+ /** The two booleans, owned by the parent so submit can send them. */
143
+ export function DiscountSwitches({
144
+ copy,
145
+ active,
146
+ stackable,
147
+ onActive,
148
+ onStackable,
149
+ }: {
150
+ copy: DiscountsWebCopy;
151
+ active: boolean;
152
+ stackable: boolean;
153
+ onActive: (next: boolean) => void;
154
+ onStackable: (next: boolean) => void;
155
+ }): JSX.Element {
156
+ return (
157
+ <Stack spacing={1}>
158
+ <Switch
159
+ checked={active}
160
+ onChange={(event) => onActive(event.target.checked)}
161
+ label={copy.form.active}
162
+ description={copy.form.activeHint}
163
+ dataTestId="discount-active"
164
+ />
165
+ <Switch
166
+ checked={stackable}
167
+ onChange={(event) => onStackable(event.target.checked)}
168
+ label={copy.form.stackable}
169
+ description={copy.form.stackableHint}
170
+ dataTestId="discount-stackable"
171
+ />
172
+ </Stack>
173
+ );
174
+ }
@@ -0,0 +1,272 @@
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 { DiscountFormPayload, DiscountsApiClient, DiscountWireRecord, WireTargetGroup } from "./api";
16
+ import type { DiscountsWebCopy } from "./copy";
17
+ import {
18
+ DiscountSwitches,
19
+ LimitFields,
20
+ RuleFields,
21
+ ScopeField,
22
+ WindowFields,
23
+ type CurrencyFieldComponent,
24
+ } from "./discount-form-fields";
25
+ import { DiscountTargetPicker } from "./discount-target-picker";
26
+ import type { DiscountsFormatters } from "./format";
27
+
28
+ /**
29
+ * The create/edit form — shared by the header's create dialog and the row
30
+ * menu's edit dialog, so the fields and the submit live in one place.
31
+ * `editing === null` creates; otherwise it re-states the rule whole.
32
+ *
33
+ * Two kinds of value are deliberately NOT `total-form` values, for one reason:
34
+ * `total-form` holds STRINGS. The two switches (booleans) and the target id
35
+ * lists (arrays) sit in local state beside the container and are merged in at
36
+ * submit.
37
+ *
38
+ * Every rule checked here is ALSO checked on the server. This half exists for
39
+ * the operator's sake — instant, field-attached feedback — never as the
40
+ * authority: the surface re-validates, and a server refusal comes back through
41
+ * `setFieldErrors` and paints the same inputs red.
42
+ */
43
+
44
+ interface DiscountFormValues extends Record<string, string> {
45
+ name: string;
46
+ type: string;
47
+ percentOff: string;
48
+ amountOff: string;
49
+ scope: string;
50
+ trigger: string;
51
+ code: string;
52
+ startsAt: string;
53
+ endsAt: string;
54
+ minSubtotal: string;
55
+ usageLimit: string;
56
+ perBuyerLimit: string;
57
+ }
58
+
59
+ /** `YYYY-MM-DD` for a native date input, from the wire's ISO instant. */
60
+ function toDateInput(iso: string | null): string {
61
+ return iso === null ? "" : iso.slice(0, 10);
62
+ }
63
+
64
+ /** A new rule's defaults: the most common promotion an operator creates. */
65
+ const BLANK: DiscountFormValues = {
66
+ name: "",
67
+ type: "PERCENTAGE",
68
+ percentOff: "",
69
+ amountOff: "",
70
+ scope: "ORDER",
71
+ trigger: "AUTOMATIC",
72
+ code: "",
73
+ startsAt: "",
74
+ endsAt: "",
75
+ minSubtotal: "",
76
+ usageLimit: "",
77
+ perBuyerLimit: "",
78
+ };
79
+
80
+ function initialValues(
81
+ editing: DiscountWireRecord | null,
82
+ formatters: DiscountsFormatters,
83
+ ): DiscountFormValues {
84
+ if (editing === null) return { ...BLANK };
85
+ const cents = (value: number | null): string =>
86
+ value === null ? "" : formatters.toInput(Number((value / 100).toFixed(2)));
87
+ return {
88
+ name: editing.name,
89
+ type: editing.type,
90
+ percentOff: editing.percentOffBp === null ? "" : formatters.toInput(editing.percentOffBp / 100),
91
+ amountOff: cents(editing.amountOffCents),
92
+ scope: editing.scope,
93
+ trigger: editing.trigger,
94
+ code: editing.code ?? "",
95
+ startsAt: toDateInput(editing.startsAt),
96
+ endsAt: toDateInput(editing.endsAt),
97
+ minSubtotal: cents(editing.minSubtotalCents),
98
+ usageLimit: editing.usageLimit === null ? "" : String(editing.usageLimit),
99
+ perBuyerLimit: editing.perBuyerLimit === null ? "" : String(editing.perBuyerLimit),
100
+ };
101
+ }
102
+
103
+ /** The value half: a rate in (0, 100], or an amount above zero. */
104
+ function checkValue(
105
+ values: DiscountFormValues,
106
+ formatters: DiscountsFormatters,
107
+ copy: DiscountsWebCopy,
108
+ ): 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 {};
115
+ }
116
+ const amount = formatters.parseDecimal(values.amountOff);
117
+ return amount === null || amount <= 0 ? { amountOff: copy.form.invalidAmount } : {};
118
+ }
119
+
120
+ /** The cross-field rules a per-input schema cannot express. */
121
+ function validate(
122
+ values: DiscountFormValues,
123
+ targets: { categoryIds: string[]; menuItemIds: string[] },
124
+ formatters: DiscountsFormatters,
125
+ copy: DiscountsWebCopy,
126
+ ): Record<string, string> {
127
+ const errors: Record<string, string> = { ...checkValue(values, formatters, copy) };
128
+ if (values.trigger === "CODE" && values.code.trim() === "") {
129
+ errors.code = copy.form.codeRequired;
130
+ }
131
+ if (values.scope === "CATEGORY" && targets.categoryIds.length === 0) {
132
+ errors.scope = copy.form.categoryTargetRequired;
133
+ }
134
+ if (values.scope === "ITEM" && targets.menuItemIds.length === 0) {
135
+ errors.scope = copy.form.itemTargetRequired;
136
+ }
137
+ // The window is half-open [start, end): equal dates are an EMPTY window, so
138
+ // the rule is "after", not "not before".
139
+ if (values.startsAt !== "" && values.endsAt !== "" && values.endsAt <= values.startsAt) {
140
+ errors.endsAt = copy.form.endsBeforeStarts;
141
+ }
142
+ return errors;
143
+ }
144
+
145
+ /** The values `total-form` cannot hold, kept beside it and merged at submit. */
146
+ function useNonStringState(editing: DiscountWireRecord | null) {
147
+ const [active, setActive] = useState<boolean>(editing?.active ?? true);
148
+ const [stackable, setStackable] = useState<boolean>(editing?.stackable ?? true);
149
+ const [categoryIds, setCategoryIds] = useState<string[]>(editing?.categoryIds ?? []);
150
+ const [menuItemIds, setMenuItemIds] = useState<string[]>(editing?.menuItemIds ?? []);
151
+ return {
152
+ active,
153
+ stackable,
154
+ categoryIds,
155
+ menuItemIds,
156
+ setActive,
157
+ setStackable,
158
+ setCategoryIds,
159
+ setMenuItemIds,
160
+ };
161
+ }
162
+
163
+ type NonStringState = ReturnType<typeof useNonStringState>;
164
+ type SubmitHelpers = { setFieldErrors: (errors: Record<string, string | undefined>) => void };
165
+
166
+ export interface DiscountFormProps {
167
+ api: DiscountsApiClient;
168
+ copy: DiscountsWebCopy;
169
+ formatters: DiscountsFormatters;
170
+ currencyField: CurrencyFieldComponent;
171
+ groups: readonly WireTargetGroup[];
172
+ editing: DiscountWireRecord | null;
173
+ onSaved: () => void;
174
+ /** Where a refused or failed write is reported, beyond the operator's screen. */
175
+ onError: (error: unknown, context: string) => void;
176
+ }
177
+
178
+ /**
179
+ * Build the submit handler.
180
+ *
181
+ * A factory rather than a closure inside the component, so the component stays
182
+ * inside the size gate and the validate → send → attribute-the-failure sequence
183
+ * reads as one block.
184
+ */
185
+ function makeSubmit(
186
+ props: DiscountFormProps,
187
+ extra: NonStringState,
188
+ setError: (message: string | null) => void,
189
+ ): (values: DiscountFormValues, helpers: SubmitHelpers) => Promise<void> {
190
+ const { api, copy, formatters, editing, onSaved, onError } = props;
191
+ return async function handleSubmit(values, { setFieldErrors }): Promise<void> {
192
+ setError(null);
193
+ const targets = { categoryIds: extra.categoryIds, menuItemIds: extra.menuItemIds };
194
+ const invalid = validate(values, targets, formatters, copy);
195
+ if (Object.keys(invalid).length > 0) {
196
+ setFieldErrors(invalid);
197
+ setError(copy.form.reviewFields);
198
+ return;
199
+ }
200
+ const payload: DiscountFormPayload = {
201
+ ...values,
202
+ ...targets,
203
+ stackable: extra.stackable,
204
+ active: extra.active,
205
+ };
206
+ const result = editing
207
+ ? await api.update(editing.id, payload)
208
+ : await api.create(payload);
209
+ if (result.ok) {
210
+ onSaved();
211
+ return;
212
+ }
213
+ setError(result.error);
214
+ // The server attributes what it can — a duplicate code, a rate out of range
215
+ // — so highlight those inputs instead of only showing the banner.
216
+ if (result.fieldErrors) setFieldErrors(result.fieldErrors);
217
+ onError(result, editing ? "discounts.update" : "discounts.create");
218
+ };
219
+ }
220
+
221
+ export function DiscountForm(props: DiscountFormProps): JSX.Element {
222
+ const { copy, formatters, currencyField, groups, editing } = props;
223
+ const [error, setError] = useState<string | null>(null);
224
+ const extra = useNonStringState(editing);
225
+ const handleSubmit = makeSubmit(props, extra, setError);
226
+
227
+ return (
228
+ <FormContainer<DiscountFormValues>
229
+ initialValues={initialValues(editing, formatters)}
230
+ schema={{ name: [required(copy.form.nameRequired), minLength(2)] }}
231
+ onSubmit={handleSubmit}
232
+ dataTestId="discount-form"
233
+ >
234
+ <Stack spacing={2}>
235
+ <Fields.TextField name="name" label={copy.form.name} />
236
+ <RuleFields copy={copy} currencyField={currencyField} />
237
+ <ScopeField copy={copy} />
238
+ <DiscountTargetPicker
239
+ groups={groups}
240
+ copy={copy}
241
+ selection={{
242
+ categoryIds: extra.categoryIds,
243
+ menuItemIds: extra.menuItemIds,
244
+ onCategoryIdsChange: extra.setCategoryIds,
245
+ onMenuItemIdsChange: extra.setMenuItemIds,
246
+ }}
247
+ />
248
+ <WindowFields copy={copy} />
249
+ <LimitFields copy={copy} currencyField={currencyField} />
250
+ <DiscountSwitches
251
+ copy={copy}
252
+ active={extra.active}
253
+ stackable={extra.stackable}
254
+ onActive={extra.setActive}
255
+ onStackable={extra.setStackable}
256
+ />
257
+ {error && (
258
+ <Alert
259
+ variant="danger"
260
+ title={copy.form.saveFailed}
261
+ description={error}
262
+ data-testid="discount-form-error"
263
+ />
264
+ )}
265
+ <SubmitButton dataTestId="discount-form-submit">
266
+ {editing ? copy.form.submitEdit : copy.form.submitCreate}
267
+ </SubmitButton>
268
+ </Stack>
269
+ <FormErrorSnackbar />
270
+ </FormContainer>
271
+ );
272
+ }