@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.
- package/README.md +231 -16
- package/package.json +55 -6
- package/prisma/discounts.prisma +181 -0
- package/prisma/migrations/20260821140000_discounts_package_owned/migration.sql +286 -0
- package/scripts/sync-discounts-schema.mjs +53 -0
- package/src/engine/kinds.ts +62 -0
- package/src/index.ts +5 -0
- package/src/manifest/index.ts +45 -17
- package/src/manifest/web.ts +55 -0
- package/src/react/api.ts +209 -0
- package/src/react/combo-slot-builder.tsx +213 -0
- package/src/react/copy.ts +296 -0
- package/src/react/create-web-discounts.tsx +162 -0
- package/src/react/discount-actions-menu.tsx +127 -0
- package/src/react/discount-card.tsx +92 -0
- package/src/react/discount-form-fields.tsx +245 -0
- package/src/react/discount-form.tsx +393 -0
- package/src/react/discount-list-card.tsx +199 -0
- package/src/react/discount-target-picker.tsx +114 -0
- package/src/react/discounts-screen.tsx +314 -0
- package/src/react/format.ts +126 -0
- package/src/react/index.ts +72 -0
- package/src/react/pt-BR.ts +157 -0
- package/src/react/row.ts +66 -0
- package/src/react/table-config.ts +238 -0
- package/src/react/target-picker-field.tsx +138 -0
- package/src/react/transport.ts +115 -0
- package/src/react/use-discounts-data.ts +103 -0
- package/src/server/collections.ts +290 -0
- package/src/server/copy.ts +12 -0
- package/src/server/index.ts +23 -0
- package/src/server/logging.ts +20 -3
- package/src/server/mcp.ts +33 -0
- package/src/server/pt-BR.ts +1 -0
- package/src/server/routes.ts +74 -9
- 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
|
+
}
|