@12-apps/discounts 1.16.0 → 1.17.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/package.json +5 -5
- package/src/react/clock-input.tsx +107 -0
- package/src/react/combo-slot-builder.tsx +73 -27
- package/src/react/copy.ts +37 -2
- package/src/react/date-field.tsx +141 -0
- package/src/react/discount-form-fields.tsx +45 -46
- package/src/react/discount-form-validate.ts +75 -15
- package/src/react/discount-form.tsx +7 -5
- package/src/react/en-US.ts +16 -1
- package/src/react/free-units-builder.tsx +20 -13
- package/src/react/kind-field.tsx +67 -0
- package/src/react/number-fields.tsx +142 -0
- package/src/react/pt-BR.ts +17 -2
- package/src/react/schedule-builder.tsx +181 -37
- package/src/react/schedule-copy.ts +4 -0
|
@@ -13,7 +13,9 @@ import { DISCOUNT_TRIGGERS } from "../engine/kinds";
|
|
|
13
13
|
import type { DiscountSchedule } from "../engine/schedule";
|
|
14
14
|
|
|
15
15
|
import type { DiscountsWebCopy } from "./copy";
|
|
16
|
-
import { COMBO_REWARDS, isComboKind, SELECTABLE_DISCOUNT_SCOPES
|
|
16
|
+
import { COMBO_REWARDS, isComboKind, SELECTABLE_DISCOUNT_SCOPES } from "./form-kind";
|
|
17
|
+
import { DateField } from "./date-field";
|
|
18
|
+
import { PercentField, WholeNumberField } from "./number-fields";
|
|
17
19
|
import { ScheduleBuilder } from "./schedule-builder";
|
|
18
20
|
|
|
19
21
|
/**
|
|
@@ -49,14 +51,14 @@ function HintedField({
|
|
|
49
51
|
}): JSX.Element {
|
|
50
52
|
return (
|
|
51
53
|
<Stack spacing={0.5}>
|
|
52
|
-
<
|
|
54
|
+
<WholeNumberField name={name} label={label} />
|
|
53
55
|
<Text variant="caption">{hint}</Text>
|
|
54
56
|
</Stack>
|
|
55
57
|
);
|
|
56
58
|
}
|
|
57
59
|
|
|
58
60
|
/** One closed set as a toggle's options, labelled by the pack. */
|
|
59
|
-
function optionsFor<TKey extends string>(
|
|
61
|
+
export function optionsFor<TKey extends string>(
|
|
60
62
|
values: readonly TKey[],
|
|
61
63
|
labels: Readonly<Record<TKey, string>>,
|
|
62
64
|
): { value: string; label: string }[] {
|
|
@@ -73,29 +75,6 @@ function optionsFor<TKey extends string>(
|
|
|
73
75
|
*/
|
|
74
76
|
export type CurrencyFieldComponent = (props: { name: string; label: string }) => JSX.Element;
|
|
75
77
|
|
|
76
|
-
/**
|
|
77
|
-
* What kind of promotion this is — the form's first and most consequential
|
|
78
|
-
* question, and the only one that decides what the rest of it looks like.
|
|
79
|
-
*
|
|
80
|
-
* The option list comes from the caller rather than from the constant, because
|
|
81
|
-
* it is one longer when a legacy `BUNDLE_PRICE` rule is being edited.
|
|
82
|
-
*/
|
|
83
|
-
export function KindField({
|
|
84
|
-
copy,
|
|
85
|
-
kinds,
|
|
86
|
-
}: {
|
|
87
|
-
copy: DiscountsWebCopy;
|
|
88
|
-
kinds: readonly DiscountKind[];
|
|
89
|
-
}): JSX.Element {
|
|
90
|
-
return (
|
|
91
|
-
<Fields.ToggleField
|
|
92
|
-
name="kind"
|
|
93
|
-
label={copy.form.kind}
|
|
94
|
-
options={optionsFor(kinds, copy.labels.kind)}
|
|
95
|
-
/>
|
|
96
|
-
);
|
|
97
|
-
}
|
|
98
|
-
|
|
99
78
|
/**
|
|
100
79
|
* A combo's reward: which of the two, and how much.
|
|
101
80
|
*
|
|
@@ -125,11 +104,7 @@ function ComboRewardFields({
|
|
|
125
104
|
{values.comboReward === "FIXED_AMOUNT" ? (
|
|
126
105
|
<Currency name="amountOff" label={copy.form.amountOff} />
|
|
127
106
|
) : (
|
|
128
|
-
<
|
|
129
|
-
name="percentOff"
|
|
130
|
-
label={copy.form.percentOff}
|
|
131
|
-
placeholder={copy.form.percentPlaceholder}
|
|
132
|
-
/>
|
|
107
|
+
<PercentField copy={copy} />
|
|
133
108
|
)}
|
|
134
109
|
</Box>
|
|
135
110
|
<Text variant="caption">{copy.form.comboRewardHint}</Text>
|
|
@@ -164,13 +139,7 @@ export function RewardFields({
|
|
|
164
139
|
if (kind === "FREE_UNITS") return null;
|
|
165
140
|
if (kind === "BUNDLE_PRICE") return <Currency name="bundlePrice" label={copy.form.bundlePrice} />;
|
|
166
141
|
if (kind === "FIXED_AMOUNT") return <Currency name="amountOff" label={copy.form.amountOff} />;
|
|
167
|
-
return
|
|
168
|
-
<Fields.TextField
|
|
169
|
-
name="percentOff"
|
|
170
|
-
label={copy.form.percentOff}
|
|
171
|
-
placeholder={copy.form.percentPlaceholder}
|
|
172
|
-
/>
|
|
173
|
-
);
|
|
142
|
+
return <PercentField copy={copy} />;
|
|
174
143
|
}
|
|
175
144
|
|
|
176
145
|
/** The coupon input, shown only for a CODE-triggered rule. */
|
|
@@ -252,6 +221,24 @@ export function ComboCapField({ copy }: { copy: DiscountsWebCopy }): JSX.Element
|
|
|
252
221
|
);
|
|
253
222
|
}
|
|
254
223
|
|
|
224
|
+
/** Início and Término, day-first on every browser (FUT-4156). */
|
|
225
|
+
function PeriodFields({ copy }: { copy: DiscountsWebCopy }): JSX.Element {
|
|
226
|
+
return (
|
|
227
|
+
<Box sx={{ display: "grid", gap: 2, gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" } }}>
|
|
228
|
+
{(["startsAt", "endsAt"] as const).map((name) => (
|
|
229
|
+
<DateField
|
|
230
|
+
key={name}
|
|
231
|
+
name={name}
|
|
232
|
+
label={copy.form[name]}
|
|
233
|
+
placeholder={copy.form.datePlaceholder}
|
|
234
|
+
invalidMessage={copy.form.invalidDate}
|
|
235
|
+
incompleteMessage={copy.form.dateIncomplete}
|
|
236
|
+
/>
|
|
237
|
+
))}
|
|
238
|
+
</Box>
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
255
242
|
/**
|
|
256
243
|
* "Quando vale" — the campaign PERIOD, and the weekly schedule inside it
|
|
257
244
|
* (FUT-996).
|
|
@@ -281,14 +268,19 @@ export function WindowFields({
|
|
|
281
268
|
onScheduleChange: (next: DiscountSchedule) => void;
|
|
282
269
|
onScheduleEnabledChange: (next: boolean) => void;
|
|
283
270
|
}): JSX.Element {
|
|
271
|
+
// The schedule is not a form value, so nothing paints `errors.schedule` for
|
|
272
|
+
// it: it is handed to the builder, and cleared on the next edit the way a
|
|
273
|
+
// field's own error is (FUT-4155).
|
|
274
|
+
const { errors, setFieldValue } = useFormContext();
|
|
275
|
+
const scheduleError = errors.schedule;
|
|
276
|
+
const clearScheduleError = (): void => {
|
|
277
|
+
if (scheduleError !== undefined) setFieldValue("schedule", "");
|
|
278
|
+
};
|
|
284
279
|
return (
|
|
285
280
|
<Stack spacing={1.5}>
|
|
286
281
|
<Text variant="heading">{copy.schedule.sectionTitle}</Text>
|
|
287
282
|
<Stack spacing={0.5}>
|
|
288
|
-
<
|
|
289
|
-
<Fields.TextField name="startsAt" label={copy.form.startsAt} type="date" />
|
|
290
|
-
<Fields.TextField name="endsAt" label={copy.form.endsAt} type="date" />
|
|
291
|
-
</Box>
|
|
283
|
+
<PeriodFields copy={copy} />
|
|
292
284
|
<Text variant="caption">{copy.schedule.periodHint}</Text>
|
|
293
285
|
</Stack>
|
|
294
286
|
<FormControl>
|
|
@@ -303,7 +295,10 @@ export function WindowFields({
|
|
|
303
295
|
type="radio"
|
|
304
296
|
name="discount-repetition"
|
|
305
297
|
checked={scheduleEnabled === value}
|
|
306
|
-
onChange={() =>
|
|
298
|
+
onChange={() => {
|
|
299
|
+
clearScheduleError();
|
|
300
|
+
onScheduleEnabledChange(value);
|
|
301
|
+
}}
|
|
307
302
|
data-testid={value ? "repetition-specific" : "repetition-always"}
|
|
308
303
|
/>
|
|
309
304
|
<Text variant="body">{label}</Text>
|
|
@@ -317,7 +312,11 @@ export function WindowFields({
|
|
|
317
312
|
schedule={schedule}
|
|
318
313
|
copy={copy}
|
|
319
314
|
timezone={timezoneLabel ?? ""}
|
|
320
|
-
|
|
315
|
+
error={scheduleError}
|
|
316
|
+
onChange={(next) => {
|
|
317
|
+
clearScheduleError();
|
|
318
|
+
onScheduleChange(next);
|
|
319
|
+
}}
|
|
321
320
|
/>
|
|
322
321
|
<OrderScopeScheduleNote copy={copy} />
|
|
323
322
|
</>
|
|
@@ -355,8 +354,8 @@ export function LimitFields({
|
|
|
355
354
|
return (
|
|
356
355
|
<Box sx={{ display: "grid", gap: 2, gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr 1fr" } }}>
|
|
357
356
|
<Currency name="minSubtotal" label={copy.form.minSubtotal} />
|
|
358
|
-
<
|
|
359
|
-
<
|
|
357
|
+
<WholeNumberField name="usageLimit" label={copy.form.usageLimit} />
|
|
358
|
+
<WholeNumberField name="perBuyerLimit" label={copy.form.perBuyerLimit} />
|
|
360
359
|
</Box>
|
|
361
360
|
);
|
|
362
361
|
}
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import { minLength, required } from "@12-apps/forms-core";
|
|
2
|
+
|
|
1
3
|
import { toMinutes, type DiscountSchedule } from "../engine/schedule";
|
|
2
4
|
import type { ComboRequirement } from "../engine/types";
|
|
3
5
|
|
|
4
6
|
import { comboUnits } from "./combo-slot-builder";
|
|
5
7
|
import { fill, type DiscountsWebCopy } from "./copy";
|
|
8
|
+
import { isCompleteMaskedDay, isIsoDay } from "./date-field";
|
|
6
9
|
import type { DiscountFormValues } from "./discount-form-values";
|
|
7
10
|
import { isComboKind, typeAndScopeFor } from "./form-kind";
|
|
8
11
|
import type { DiscountsFormatters } from "./format";
|
|
@@ -19,12 +22,40 @@ import type { DiscountsFormatters } from "./format";
|
|
|
19
22
|
* inputs red.
|
|
20
23
|
*/
|
|
21
24
|
|
|
22
|
-
/**
|
|
25
|
+
/**
|
|
26
|
+
* A whole number above zero, as typed. `null` when it is neither.
|
|
27
|
+
*
|
|
28
|
+
* Digits only (FUT-4143): `Number` reads "2.0" and "1e1" as whole numbers, and
|
|
29
|
+
* a count an operator typed with a separator in it was meant as something else.
|
|
30
|
+
*/
|
|
23
31
|
function positiveCount(typed: string): number | null {
|
|
24
32
|
const trimmed = typed.trim();
|
|
25
|
-
if (trimmed
|
|
33
|
+
if (!/^\d+$/u.test(trimmed)) return null;
|
|
26
34
|
const value = Number(trimmed);
|
|
27
|
-
return
|
|
35
|
+
return value > 0 ? value : null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The name: present, and at least two characters, in the pack's words. */
|
|
39
|
+
function checkName(typed: string, copy: DiscountsWebCopy): Record<string, string> {
|
|
40
|
+
const refused =
|
|
41
|
+
required(copy.form.nameRequired)(typed) ?? minLength(2, copy.form.nameTooShort)(typed.trim());
|
|
42
|
+
return refused === undefined ? {} : { name: refused };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The two redemption caps. Blank is "no cap"; anything else is a whole number
|
|
47
|
+
* from 1, so "2,5" is refused here instead of reaching the wire as 2 — or, in a
|
|
48
|
+
* browser that read the comma away, as 25.
|
|
49
|
+
*/
|
|
50
|
+
function checkLimits(values: DiscountFormValues, copy: DiscountsWebCopy): Record<string, string> {
|
|
51
|
+
const errors: Record<string, string> = {};
|
|
52
|
+
if (values.usageLimit.trim() !== "" && positiveCount(values.usageLimit) === null) {
|
|
53
|
+
errors.usageLimit = copy.form.invalidUsageLimit;
|
|
54
|
+
}
|
|
55
|
+
if (values.perBuyerLimit.trim() !== "" && positiveCount(values.perBuyerLimit) === null) {
|
|
56
|
+
errors.perBuyerLimit = copy.form.invalidPerBuyerLimit;
|
|
57
|
+
}
|
|
58
|
+
return errors;
|
|
28
59
|
}
|
|
29
60
|
|
|
30
61
|
/** A money field that has to hold something above zero. */
|
|
@@ -134,6 +165,28 @@ function checkCombo(
|
|
|
134
165
|
return errors;
|
|
135
166
|
}
|
|
136
167
|
|
|
168
|
+
/**
|
|
169
|
+
* Início and Término. Each is blank or a whole real ISO day — the date box
|
|
170
|
+
* holds its typed text until it is one (FUT-4156) — and only two real days are
|
|
171
|
+
* compared. The window is half-open [start, end): equal dates are an EMPTY
|
|
172
|
+
* window, so the rule is "after", not "not before".
|
|
173
|
+
*/
|
|
174
|
+
function checkPeriod(values: DiscountFormValues, copy: DiscountsWebCopy): Record<string, string> {
|
|
175
|
+
const errors: Record<string, string> = {};
|
|
176
|
+
for (const name of ["startsAt", "endsAt"] as const) {
|
|
177
|
+
if (values[name] === "" || isIsoDay(values[name])) continue;
|
|
178
|
+
// A whole DD/MM/AAAA that is no day does not exist; anything shorter is
|
|
179
|
+
// simply not finished, and is told so.
|
|
180
|
+
errors[name] = isCompleteMaskedDay(values[name])
|
|
181
|
+
? copy.form.invalidDate
|
|
182
|
+
: copy.form.dateIncomplete;
|
|
183
|
+
}
|
|
184
|
+
if (isIsoDay(values.startsAt) && isIsoDay(values.endsAt) && values.endsAt <= values.startsAt) {
|
|
185
|
+
errors.endsAt = copy.form.endsBeforeStarts;
|
|
186
|
+
}
|
|
187
|
+
return errors;
|
|
188
|
+
}
|
|
189
|
+
|
|
137
190
|
/** What the pickers and the builder currently hold, in one argument. */
|
|
138
191
|
export interface FormTargets {
|
|
139
192
|
categoryIds: string[];
|
|
@@ -164,13 +217,19 @@ function checkSchedule(
|
|
|
164
217
|
if (windows.some((window) => window.days.length === 0)) {
|
|
165
218
|
return { schedule: copy.schedule.daysRequired };
|
|
166
219
|
}
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
220
|
+
const ends = windows.flatMap((window) => [window.from, window.to]);
|
|
221
|
+
// Unfinished first: "1:00" mid-edit is not a pair of equal ends, it is a
|
|
222
|
+
// time nobody has finished typing.
|
|
223
|
+
if (ends.some((end) => !/^\d{2}:\d{2}$/u.test(end))) {
|
|
224
|
+
return { schedule: copy.schedule.timeIncomplete };
|
|
225
|
+
}
|
|
226
|
+
// Then a whole HH:MM that no day has (25:00), in the clock's own words —
|
|
227
|
+
// "start and end must differ" would be untrue of it.
|
|
228
|
+
if (ends.some((end) => toMinutes(end) === null)) {
|
|
229
|
+
return { schedule: copy.schedule.invalidTime };
|
|
230
|
+
}
|
|
231
|
+
const sameEnds = windows.some((window) => window.from === window.to);
|
|
232
|
+
return sameEnds ? { schedule: copy.schedule.timesRequired } : {};
|
|
174
233
|
}
|
|
175
234
|
|
|
176
235
|
/** The schedule editor's state: its switch and its rows, kept separate. */
|
|
@@ -187,8 +246,13 @@ export function validate(
|
|
|
187
246
|
scheduleState: ScheduleState,
|
|
188
247
|
): Record<string, string> {
|
|
189
248
|
const slots = targets.comboRequirements;
|
|
249
|
+
// Every rule in ONE pass, the name's included (FUT-4143): the name used to be
|
|
250
|
+
// the container's schema, which stops before this function runs, so a 150%
|
|
251
|
+
// sat unmarked beside a red name until the name was fixed.
|
|
190
252
|
const errors: Record<string, string> = {
|
|
253
|
+
...checkName(values.name, copy),
|
|
191
254
|
...checkValue(values, slots, formatters, copy),
|
|
255
|
+
...checkLimits(values, copy),
|
|
192
256
|
...checkCombo(values, slots, copy),
|
|
193
257
|
...checkSchedule(scheduleState, copy),
|
|
194
258
|
};
|
|
@@ -202,11 +266,7 @@ export function validate(
|
|
|
202
266
|
if (scope === "ITEM" && targets.menuItemIds.length === 0) {
|
|
203
267
|
errors.scope = copy.form.itemTargetRequired;
|
|
204
268
|
}
|
|
205
|
-
|
|
206
|
-
// the rule is "after", not "not before".
|
|
207
|
-
if (values.startsAt !== "" && values.endsAt !== "" && values.endsAt <= values.startsAt) {
|
|
208
|
-
errors.endsAt = copy.form.endsBeforeStarts;
|
|
209
|
-
}
|
|
269
|
+
Object.assign(errors, checkPeriod(values, copy));
|
|
210
270
|
return errors;
|
|
211
271
|
}
|
|
212
272
|
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
import { useState, type JSX } from "react";
|
|
4
4
|
|
|
5
|
-
import { minLength, required } from "@12-apps/forms-core";
|
|
6
5
|
import { Alert } from "@12-apps/ui/data-display/Alert";
|
|
7
6
|
import {
|
|
8
7
|
Fields,
|
|
@@ -21,7 +20,6 @@ import type { DiscountsWebCopy } from "./copy";
|
|
|
21
20
|
import {
|
|
22
21
|
ComboCapField,
|
|
23
22
|
DiscountSwitches,
|
|
24
|
-
KindField,
|
|
25
23
|
LimitFields,
|
|
26
24
|
RewardFields,
|
|
27
25
|
ScopeField,
|
|
@@ -30,6 +28,7 @@ import {
|
|
|
30
28
|
type CurrencyFieldComponent,
|
|
31
29
|
} from "./discount-form-fields";
|
|
32
30
|
import { DiscountTargetPicker } from "./discount-target-picker";
|
|
31
|
+
import { KindField } from "./kind-field";
|
|
33
32
|
import { blankWindow } from "./schedule-builder";
|
|
34
33
|
import { validate, type FormTargets } from "./discount-form-validate";
|
|
35
34
|
import { initialValues, type DiscountFormValues } from "./discount-form-values";
|
|
@@ -147,8 +146,9 @@ function makeSubmit(
|
|
|
147
146
|
schedule: extra.schedule,
|
|
148
147
|
});
|
|
149
148
|
if (Object.keys(invalid).length > 0) {
|
|
149
|
+
// ONE message for a client refusal: the red fields and the toast
|
|
150
|
+
// (FUT-4143). The `saveFailed` alert is the SERVER's, below.
|
|
150
151
|
setFieldErrors(invalid);
|
|
151
|
-
setError(copy.form.reviewFields);
|
|
152
152
|
return;
|
|
153
153
|
}
|
|
154
154
|
const payload: DiscountFormPayload = {
|
|
@@ -188,8 +188,10 @@ export function DiscountForm(props: DiscountFormProps): JSX.Element {
|
|
|
188
188
|
|
|
189
189
|
return (
|
|
190
190
|
<FormContainer<DiscountFormValues>
|
|
191
|
+
// No `schema`: the container runs one before `onSubmit` and stops when
|
|
192
|
+
// it fails, which judged the name alone. Every rule, the name's too,
|
|
193
|
+
// lives in `validate` so one Criar marks every field (FUT-4143).
|
|
191
194
|
initialValues={initialValues(editing, formatters)}
|
|
192
|
-
schema={{ name: [required(copy.form.nameRequired), minLength(2)] }}
|
|
193
195
|
onSubmit={handleSubmit}
|
|
194
196
|
dataTestId="discount-form"
|
|
195
197
|
>
|
|
@@ -245,7 +247,7 @@ export function DiscountForm(props: DiscountFormProps): JSX.Element {
|
|
|
245
247
|
{editing ? copy.form.submitEdit : copy.form.submitCreate}
|
|
246
248
|
</SubmitButton>
|
|
247
249
|
</Stack>
|
|
248
|
-
<FormErrorSnackbar />
|
|
250
|
+
<FormErrorSnackbar message={copy.form.errorSnackbar} />
|
|
249
251
|
</FormContainer>
|
|
250
252
|
);
|
|
251
253
|
}
|
package/src/react/en-US.ts
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
EN_US_CATEGORY_SELECT_COPY,
|
|
4
4
|
EN_US_CONFIRM_ACTION_COPY,
|
|
5
5
|
EN_US_DATA_VIEWS_COPY,
|
|
6
|
+
EN_US_UI_LABELS,
|
|
6
7
|
} from "@12-apps/ui/en-US";
|
|
7
8
|
|
|
8
9
|
import type { DiscountsWebCopy } from "./copy";
|
|
@@ -131,6 +132,8 @@ export const EN_US_DISCOUNTS_WEB_COPY: DiscountsWebCopy = {
|
|
|
131
132
|
activeNow: "Running now",
|
|
132
133
|
daysRequired: "Pick at least one day.",
|
|
133
134
|
timesRequired: "Enter a start and end time that differ.",
|
|
135
|
+
invalidTime: "That time does not exist. Use 00:00 to 23:59.",
|
|
136
|
+
timeIncomplete: "Finish the time: HH:MM.",
|
|
134
137
|
windowsRequired: "Add at least one time.",
|
|
135
138
|
},
|
|
136
139
|
form: {
|
|
@@ -172,8 +175,13 @@ export const EN_US_DISCOUNTS_WEB_COPY: DiscountsWebCopy = {
|
|
|
172
175
|
firstOrderOnlyHint:
|
|
173
176
|
"Only for customers who have never bought here. Menu prices do not change: the discount shows in the cart of whoever can use it.",
|
|
174
177
|
reviewFields: "Check the highlighted fields.",
|
|
178
|
+
errorSnackbar: EN_US_UI_LABELS.formErrorSnackbar.message,
|
|
175
179
|
saveFailed: "Could not save the discount",
|
|
176
180
|
nameRequired: "Give the promotion a name.",
|
|
181
|
+
nameTooShort: "The name needs at least 2 characters.",
|
|
182
|
+
datePlaceholder: "DD/MM/YYYY",
|
|
183
|
+
invalidDate: "That date does not exist. Use DD/MM/YYYY, such as 25/12/2026.",
|
|
184
|
+
dateIncomplete: "Finish the date: DD/MM/YYYY.",
|
|
177
185
|
invalidPercent: "Enter a percentage greater than 0 and at most 100.",
|
|
178
186
|
invalidAmount: "Enter a discount amount greater than zero.",
|
|
179
187
|
invalidBundlePrice: "Enter the bundle price, greater than zero.",
|
|
@@ -188,6 +196,9 @@ export const EN_US_DISCOUNTS_WEB_COPY: DiscountsWebCopy = {
|
|
|
188
196
|
comboSlotTargetRequired: "Choose what can go in each group of the bundle.",
|
|
189
197
|
invalidComboQuantity: "Enter how many units each group takes, from 1 upwards.",
|
|
190
198
|
invalidMaxComboApplications: "Enter how many bundles fit in one order, from 1 upwards.",
|
|
199
|
+
invalidUsageLimit: "Enter a whole number of uses from 1 upwards, or leave it blank.",
|
|
200
|
+
invalidPerBuyerLimit: "Enter a whole number per customer from 1 upwards, or leave it blank.",
|
|
201
|
+
wholeNumberOnly: "Whole numbers only, no decimals.",
|
|
191
202
|
endsBeforeStarts: "The end date must be after the start date.",
|
|
192
203
|
},
|
|
193
204
|
combo: {
|
|
@@ -201,7 +212,11 @@ export const EN_US_DISCOUNTS_WEB_COPY: DiscountsWebCopy = {
|
|
|
201
212
|
// sentence is built around it rather than naming a noun of its own.
|
|
202
213
|
pick: "{collection} in this group",
|
|
203
214
|
empty: "No groups yet. Add the first one to build the bundle.",
|
|
204
|
-
summary: "The bundle takes {units}
|
|
215
|
+
summary: "The bundle takes {units} across {groups}.",
|
|
216
|
+
unitsOne: "{count} item",
|
|
217
|
+
unitsOther: "{count} items",
|
|
218
|
+
groupsOne: "{count} group",
|
|
219
|
+
groupsOther: "{count} groups",
|
|
205
220
|
freeUnitsTitle: "Buy more, pay less",
|
|
206
221
|
freeUnitsHint:
|
|
207
222
|
'Choose the products, how many the customer takes and how many are free — the classic "buy 3, pay for 2".',
|
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
import type { JSX } from "react";
|
|
4
4
|
|
|
5
5
|
import { FormControl, FormLabel, FormMessage } from "@12-apps/ui/form/Form";
|
|
6
|
-
import { Input } from "@12-apps/ui/form/Input";
|
|
7
6
|
import { Box } from "@12-apps/ui/mui/Box";
|
|
8
7
|
import { Stack } from "@12-apps/ui/mui/Stack";
|
|
9
8
|
import { Text } from "@12-apps/ui/typography/Text";
|
|
@@ -13,6 +12,7 @@ import type { ComboRequirement } from "../engine/types";
|
|
|
13
12
|
import type { WireTargetGroup } from "./api";
|
|
14
13
|
import { blankSlot } from "./combo-slot-builder";
|
|
15
14
|
import { fill, type DiscountsWebCopy } from "./copy";
|
|
15
|
+
import { CountInput, WholeNumberInput } from "./number-fields";
|
|
16
16
|
import { TargetPickerField } from "./target-picker-field";
|
|
17
17
|
|
|
18
18
|
/**
|
|
@@ -41,6 +41,11 @@ function onlySlot(slots: readonly ComboRequirement[]): ComboRequirement {
|
|
|
41
41
|
return slots[0] ?? blankSlot();
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
/** Typed with something besides digits: marked as it is typed, like the other counts. */
|
|
45
|
+
function notDigits(typed: string): boolean {
|
|
46
|
+
return typed.trim() !== "" && !/^\d+$/u.test(typed.trim());
|
|
47
|
+
}
|
|
48
|
+
|
|
44
49
|
/** How many the customer pays for: what they take, minus what is free. */
|
|
45
50
|
function paidUnits(taken: number, free: number): number {
|
|
46
51
|
return Math.max(taken - free, 0);
|
|
@@ -84,20 +89,22 @@ function FreeUnitsCounts({
|
|
|
84
89
|
}): JSX.Element {
|
|
85
90
|
return (
|
|
86
91
|
<Box sx={{ display: "grid", gap: 2, gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" } }}>
|
|
87
|
-
<
|
|
88
|
-
type="number"
|
|
92
|
+
<CountInput
|
|
89
93
|
label={copy.combo.buyQuantity}
|
|
90
|
-
value={
|
|
91
|
-
|
|
92
|
-
|
|
94
|
+
value={quantity}
|
|
95
|
+
copy={copy}
|
|
96
|
+
invalidMessage={copy.form.invalidComboQuantity}
|
|
97
|
+
dataTestId="free-units-quantity"
|
|
98
|
+
onChange={onQuantity}
|
|
93
99
|
/>
|
|
94
|
-
|
|
95
|
-
|
|
100
|
+
{/* Held as the form's string, so "2,5" stays as typed and the validator
|
|
101
|
+
refuses it with `invalidFreeUnits` below the builder. */}
|
|
102
|
+
<WholeNumberInput
|
|
96
103
|
label={copy.form.freeUnits}
|
|
97
104
|
value={freeUnits}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
105
|
+
error={error !== undefined || notDigits(freeUnits)}
|
|
106
|
+
dataTestId="free-units-free"
|
|
107
|
+
onChange={onFreeUnits}
|
|
101
108
|
/>
|
|
102
109
|
</Box>
|
|
103
110
|
);
|
|
@@ -160,9 +167,9 @@ export function FreeUnitsBuilder({
|
|
|
160
167
|
autocompleteCopy={copy.autocomplete}
|
|
161
168
|
dataTestId="free-units-items"
|
|
162
169
|
/>
|
|
163
|
-
{error !== undefined && (
|
|
170
|
+
{(error !== undefined || notDigits(freeUnits)) && (
|
|
164
171
|
<FormMessage error dataTestId="free-units-free-message">
|
|
165
|
-
{error}
|
|
172
|
+
{error ?? copy.form.wholeNumberOnly}
|
|
166
173
|
</FormMessage>
|
|
167
174
|
)}
|
|
168
175
|
{empty ? (
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useLayoutEffect, useRef, useState, type JSX, type RefObject } from "react";
|
|
4
|
+
|
|
5
|
+
import { Fields } from "@12-apps/ui/form/total-form";
|
|
6
|
+
import { Box } from "@12-apps/ui/mui/Box";
|
|
7
|
+
|
|
8
|
+
import type { DiscountsWebCopy } from "./copy";
|
|
9
|
+
import { optionsFor } from "./discount-form-fields";
|
|
10
|
+
import type { DiscountKind } from "./form-kind";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* What kind of promotion this is — the form's first and most consequential
|
|
14
|
+
* question, and the only one that decides what the rest of it looks like.
|
|
15
|
+
*
|
|
16
|
+
* The option list comes from the caller rather than from the constant, because
|
|
17
|
+
* it is one longer when a legacy `BUNDLE_PRICE` rule is being edited.
|
|
18
|
+
*
|
|
19
|
+
* **One line, at every width** (FUT-4157, ADR 0190 in the host). Four equal
|
|
20
|
+
* parts need about 530 px before "Porcentagem" and "Itens grátis" wrap inside
|
|
21
|
+
* their buttons; at 320 and 390 the toggle wrapped every label AND pushed the
|
|
22
|
+
* page to 422 px. Where the FORM is narrower than its parts need, the same
|
|
23
|
+
* question is asked with a select: same field, same options in the same order,
|
|
24
|
+
* same refusal painted under it. What is measured is the form's own width, not
|
|
25
|
+
* the window's — the form also lives in a dialog narrower than the screen.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** The widest pt-BR kind label at the toggle's type size (132 px measured), plus its padding. */
|
|
29
|
+
const PART_MIN_PX = 136;
|
|
30
|
+
|
|
31
|
+
/** The element's own width as it changes; `null` until measured, or where nothing can measure. */
|
|
32
|
+
function useElementWidth(ref: RefObject<HTMLElement | null>): number | null {
|
|
33
|
+
const [width, setWidth] = useState<number | null>(null);
|
|
34
|
+
useLayoutEffect(() => {
|
|
35
|
+
const element = ref.current;
|
|
36
|
+
if (element === null || typeof ResizeObserver === "undefined") return undefined;
|
|
37
|
+
const observer = new ResizeObserver((entries) => {
|
|
38
|
+
const measured = entries[0]?.contentRect.width;
|
|
39
|
+
if (measured !== undefined && measured > 0) setWidth(measured);
|
|
40
|
+
});
|
|
41
|
+
observer.observe(element);
|
|
42
|
+
return () => observer.disconnect();
|
|
43
|
+
}, [ref]);
|
|
44
|
+
return width;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function KindField({
|
|
48
|
+
copy,
|
|
49
|
+
kinds,
|
|
50
|
+
}: {
|
|
51
|
+
copy: DiscountsWebCopy;
|
|
52
|
+
kinds: readonly DiscountKind[];
|
|
53
|
+
}): JSX.Element {
|
|
54
|
+
const box = useRef<HTMLDivElement>(null);
|
|
55
|
+
const width = useElementWidth(box);
|
|
56
|
+
const options = optionsFor(kinds, copy.labels.kind);
|
|
57
|
+
const fits = width === null || width >= kinds.length * PART_MIN_PX;
|
|
58
|
+
return (
|
|
59
|
+
<Box ref={box} sx={{ minWidth: 0 }}>
|
|
60
|
+
{fits ? (
|
|
61
|
+
<Fields.ToggleField name="kind" label={copy.form.kind} options={options} />
|
|
62
|
+
) : (
|
|
63
|
+
<Fields.SelectField name="kind" label={copy.form.kind} options={options} />
|
|
64
|
+
)}
|
|
65
|
+
</Box>
|
|
66
|
+
);
|
|
67
|
+
}
|