@12-apps/discounts 1.16.0 → 1.18.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/src/react/copy.ts CHANGED
@@ -7,6 +7,7 @@ import type {
7
7
  DiscountWindowState,
8
8
  } from "../engine/kinds";
9
9
 
10
+ import type { DiscountsComboRewardsCopy } from "./combo-reward-copy";
10
11
  import type { DiscountsScheduleCopy } from "./schedule-copy";
11
12
 
12
13
  /**
@@ -80,8 +81,8 @@ export interface DiscountsVocabularyCopy {
80
81
  /**
81
82
  * The KIND toggle's words — the one question the form actually asks (see
82
83
  * `./form-kind`). It is keyed by `DiscountType` because every kind is named
83
- * after the type it stands for, `COMBO` included: a combo IS a discount, and
84
- * `BUNDLE_PRICE`'s label survives for the legacy rules that still carry it.
84
+ * after the type it stands for. `BUNDLE_PRICE` is no longer a kind on offer
85
+ * (FUT-4417: a combo reward, `comboRewards.labels`); its entry keeps the shape.
85
86
  */
86
87
  readonly kind: LabelsFor<DiscountType | "COMBO">;
87
88
  readonly type: LabelsFor<DiscountType>;
@@ -106,20 +107,21 @@ export interface DiscountsWindowCopy {
106
107
  export interface DiscountsFormCopy {
107
108
  readonly createTitle: string;
108
109
  readonly editTitle: string;
110
+ readonly dialog: { readonly close: string; readonly back: string };
109
111
  readonly submitCreate: string;
110
112
  readonly submitEdit: string;
111
113
  readonly name: string;
112
114
  readonly type: string;
113
115
  /** The kind toggle's own label — the form's first real question. */
114
116
  readonly kind: string;
115
- /** Which of the two rewards a COMBO gives, and the sentence under it. */
117
+ /** The combo reward's label; its choices and hints are `comboRewards` (FUT-4417). */
116
118
  readonly comboReward: string;
117
119
  readonly comboRewardHint: string;
118
120
  readonly percentOff: string;
119
121
  /** The example inside the empty percentage input. */
120
122
  readonly percentPlaceholder: string;
121
123
  readonly amountOff: string;
122
- /** The combo's total price, shown only for a BUNDLE_PRICE rule. */
124
+ /** The combo's closed price, under the "Preço fechado" reward (FUT-4417). */
123
125
  readonly bundlePrice: string;
124
126
  /** How many of the matched units are given away, for a FREE_UNITS rule. */
125
127
  readonly freeUnits: string;
@@ -132,6 +134,8 @@ export interface DiscountsFormCopy {
132
134
  readonly scope: string;
133
135
  readonly startsAt: string;
134
136
  readonly endsAt: string;
137
+ /** Inside an empty Início / Término: the day-first order the box reads (FUT-4156). */
138
+ readonly datePlaceholder: string;
135
139
  readonly minSubtotal: string;
136
140
  readonly usageLimit: string;
137
141
  readonly perBuyerLimit: string;
@@ -147,12 +151,24 @@ export interface DiscountsFormCopy {
147
151
  /** The first-purchase condition (FUT-2825), and what it does to the menu. */
148
152
  readonly firstOrderOnly: string;
149
153
  readonly firstOrderOnlyHint: string;
150
- /** The banner over a form the operator must fix before it will send. */
154
+ /**
155
+ * The banner over a form the operator must fix before it will send. No
156
+ * longer rendered (FUT-4143): a client refusal says it once, in the toast
157
+ * (`errorSnackbar`). Kept so a host's own pack still type-checks.
158
+ */
151
159
  readonly reviewFields: string;
160
+ /**
161
+ * The toast a failed submit raises over the whole form (FUT-4143). The packs
162
+ * take it from `@12-apps/ui`'s own labels; `FormErrorSnackbar`'s fallback is
163
+ * English, so a form that does not pass one shows English on any screen.
164
+ */
165
+ readonly errorSnackbar: string;
152
166
  /** The banner over a write the SERVER refused. */
153
167
  readonly saveFailed: string;
154
168
  /** Per-input refusals, each attached to the input it names. */
155
169
  readonly nameRequired: string;
170
+ /** A name of one character — the two-character floor this form has always held. */
171
+ readonly nameTooShort: string;
156
172
  readonly invalidPercent: string;
157
173
  readonly invalidAmount: string;
158
174
  readonly invalidBundlePrice: string;
@@ -174,7 +190,20 @@ export interface DiscountsFormCopy {
174
190
  readonly comboSlotTargetRequired: string;
175
191
  readonly invalidComboQuantity: string;
176
192
  readonly invalidMaxComboApplications: string;
193
+ /** The two redemption caps: whole numbers from 1, or blank for "no cap". */
194
+ readonly invalidUsageLimit: string;
195
+ readonly invalidPerBuyerLimit: string;
196
+ /**
197
+ * Under a unit count typed with anything but digits ("2,5"), said while it is
198
+ * typed: a count of units has no decimals, and a browser that read the comma
199
+ * away stored 25.
200
+ */
201
+ readonly wholeNumberOnly: string;
177
202
  readonly endsBeforeStarts: string;
203
+ /** A date that is not a whole, real day — "31/02/2026", or "12/0" at Criar. */
204
+ readonly invalidDate: string;
205
+ /** A date left half-typed ("25/10/202"): shown once the box is left, or at Criar. */
206
+ readonly dateIncomplete: string;
178
207
  }
179
208
 
180
209
  /**
@@ -205,9 +234,17 @@ export interface DiscountsComboCopy {
205
234
  readonly empty: string;
206
235
  /**
207
236
  * The offer read back in the operator's own numbers, so a combo can be
208
- * checked without saving it. `{units}` and `{groups}`.
237
+ * checked without saving it. `{units}` and `{groups}` are the two counted
238
+ * nouns below, already filled — "4 itens", "1 grupo" (FUT-4143: the sentence
239
+ * used to say "1 grupos").
209
240
  */
210
241
  readonly summary: string;
242
+ /** "1 item" / "{count} itens": one for exactly 1, the other for every other count. */
243
+ readonly unitsOne: string;
244
+ readonly unitsOther: string;
245
+ /** "1 grupo" / "{count} grupos", the same rule. */
246
+ readonly groupsOne: string;
247
+ readonly groupsOther: string;
211
248
  /**
212
249
  * The "leve 3, pague 2" builder (`./free-units-builder`), which is the same
213
250
  * stored shape wearing the sentence an operator would actually say. Its own
@@ -284,6 +321,7 @@ export interface DiscountsWebCopy {
284
321
  readonly schedule: DiscountsScheduleCopy;
285
322
  readonly form: DiscountsFormCopy;
286
323
  readonly combo: DiscountsComboCopy;
324
+ readonly comboRewards: DiscountsComboRewardsCopy;
287
325
  readonly targets: DiscountsTargetCopy;
288
326
  readonly actions: DiscountsActionsCopy;
289
327
  readonly card: DiscountsCardCopy;
@@ -0,0 +1,141 @@
1
+ "use client";
2
+
3
+ import { useState, type JSX } from "react";
4
+
5
+ import { FormControl, FormLabel, FormMessage } from "@12-apps/ui/form/Form";
6
+ import { Input } from "@12-apps/ui/form/Input";
7
+ import { useFormContext } from "@12-apps/ui/form/total-form";
8
+
9
+ /**
10
+ * A day-first `DD/MM/AAAA` box that is NOT `<input type="date">` (FUT-4156).
11
+ *
12
+ * The native control follows the BROWSER's language: an English Chromium shows
13
+ * Início as `mm/dd/yyyy` on a Portuguese screen, the same trap `ClockInput`
14
+ * avoids for the hours. This is the same family: text, masked, numeric keypad,
15
+ * a complete day that does not exist (31/02/2026) marked as it is typed.
16
+ *
17
+ * **The form value stays ISO** (`YYYY-MM-DD`), exactly what the native input
18
+ * held, so the wire and the validator's date comparison are unchanged. While
19
+ * the box holds anything that is not a whole real day the value IS that text,
20
+ * and `validate` refuses it at Criar — a half-typed date is never sent, and
21
+ * never silently dropped to "no date" either.
22
+ */
23
+
24
+ const ISO_DAY = /^(\d{4})-(\d{2})-(\d{2})$/u;
25
+ const MASKED_DAY = /^(\d{2})\/(\d{2})\/(\d{4})$/u;
26
+ const PART_LIMITS = [2, 2, 4];
27
+
28
+ /** `YYYY-MM-DD` that names a real calendar day. */
29
+ export function isIsoDay(value: string): boolean {
30
+ const match = ISO_DAY.exec(value);
31
+ if (match === null) return false;
32
+ const [year, month, day] = [Number(match[1]), Number(match[2]), Number(match[3])];
33
+ const date = new Date(Date.UTC(year, month - 1, day));
34
+ return (
35
+ date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day
36
+ );
37
+ }
38
+
39
+ /** A whole `DD/MM/AAAA`, real or not. */
40
+ export function isCompleteMaskedDay(value: string): boolean {
41
+ return MASKED_DAY.test(value);
42
+ }
43
+
44
+ /** `DD/MM/AAAA` → `YYYY-MM-DD`, or `null` when it is not a complete real day. */
45
+ function maskedToIso(masked: string): string | null {
46
+ const match = MASKED_DAY.exec(masked);
47
+ if (match === null) return null;
48
+ const iso = `${match[3]}-${match[2]}-${match[1]}`;
49
+ return isIsoDay(iso) ? iso : null;
50
+ }
51
+
52
+ function isoToMasked(iso: string): string {
53
+ const match = ISO_DAY.exec(iso);
54
+ return match === null ? iso : `${match[3]}/${match[2]}/${match[1]}`;
55
+ }
56
+
57
+ /** Up to eight digits, day first, with the slashes put in as they are typed. */
58
+ function maskDigits(raw: string): string {
59
+ const digits = raw.replace(/\D/gu, "").slice(0, 8);
60
+ if (digits.length <= 2) return digits;
61
+ if (digits.length <= 4) return `${digits.slice(0, 2)}/${digits.slice(2)}`;
62
+ return `${digits.slice(0, 2)}/${digits.slice(2, 4)}/${digits.slice(4)}`;
63
+ }
64
+
65
+ /**
66
+ * The typed text, masked.
67
+ *
68
+ * - A pasted ISO day reads as that day, not as eight digits split day-first.
69
+ * - Editing a date that already has its slashes keeps each part's own digits:
70
+ * select "25" in "25/10/2026", type "0" and the box holds "0/10/2026", then
71
+ * "05/10/2026" — re-masking the digits would read "01/02/026".
72
+ * - A part typed past its length moves on to the next part ("12/032" →
73
+ * "12/03/2"), which is plain typing, not an edit.
74
+ */
75
+ function maskDate(raw: string, previous: string): string {
76
+ if (ISO_DAY.test(raw.trim())) return isoToMasked(raw.trim());
77
+ if (previous.includes("/") && /\D/u.test(raw)) {
78
+ const parts = raw.split(/\D/u);
79
+ const fits =
80
+ parts.length <= 3 && parts.every((part, index) => part.length <= (PART_LIMITS[index] ?? 0));
81
+ if (fits) return parts.join("/");
82
+ }
83
+ return maskDigits(raw);
84
+ }
85
+
86
+ /** A form-bound day: the value is ISO when whole and real, the typed text otherwise. */
87
+ export function DateField({
88
+ name,
89
+ label,
90
+ placeholder,
91
+ invalidMessage,
92
+ incompleteMessage,
93
+ }: {
94
+ name: string;
95
+ label: string;
96
+ placeholder: string;
97
+ invalidMessage: string;
98
+ /** Under a half-typed date once the box is left — never while typing. */
99
+ incompleteMessage: string;
100
+ }): JSX.Element {
101
+ const { values, errors, setFieldValue } = useFormContext();
102
+ const [left, setLeft] = useState(false);
103
+ const value = values[name] ?? "";
104
+ const shown = isIsoDay(value) ? isoToMasked(value) : value;
105
+ // A complete day that no calendar has is marked while typed; anything still
106
+ // being typed waits for Criar, like every other field.
107
+ const impossible = MASKED_DAY.test(shown) && maskedToIso(shown) === null;
108
+ const unfinished = left && shown !== "" && !MASKED_DAY.test(shown);
109
+ const message =
110
+ errors[name] ??
111
+ (impossible ? invalidMessage : unfinished ? incompleteMessage : undefined);
112
+ const testId = `total-form-field-${name}`;
113
+ return (
114
+ <FormControl fullWidth>
115
+ <FormLabel htmlFor={name} error={message !== undefined}>
116
+ {label}
117
+ </FormLabel>
118
+ <Input
119
+ id={name}
120
+ name={name}
121
+ type="text"
122
+ placeholder={placeholder}
123
+ value={shown}
124
+ error={message !== undefined}
125
+ inputProps={{ inputMode: "numeric", maxLength: 10, autoComplete: "off", "data-testid": testId }}
126
+ data-testid={testId}
127
+ onBlur={() => setLeft(true)}
128
+ onChange={(event) => {
129
+ setLeft(false);
130
+ const masked = maskDate(event.target.value, shown);
131
+ setFieldValue(name, maskedToIso(masked) ?? masked);
132
+ }}
133
+ />
134
+ {message !== undefined && (
135
+ <FormMessage error dataTestId={`${testId}-message`}>
136
+ {message}
137
+ </FormMessage>
138
+ )}
139
+ </FormControl>
140
+ );
141
+ }
@@ -7,11 +7,11 @@ import {
7
7
  useCardActions,
8
8
  useRemoveConfirm,
9
9
  } from "@12-apps/ui/data-display/CardKit";
10
- import { Dialog, DialogContent } from "@12-apps/ui/feedback/Dialog";
11
10
  import type { DropdownMenuItem } from "@12-apps/ui/navigation/DropdownMenu";
12
11
 
13
12
  import type { DiscountsApiClient, WireTargetGroup } from "./api";
14
13
  import type { DiscountsWebCopy } from "./copy";
14
+ import { DiscountDialog } from "./discount-dialog";
15
15
  import { DiscountForm } from "./discount-form";
16
16
  import type { CurrencyFieldComponent } from "./discount-form-fields";
17
17
  import type { DiscountsFormatters } from "./format";
@@ -125,36 +125,30 @@ export function DiscountActionsMenu({
125
125
  />
126
126
  {remove.dialog}
127
127
  {canEdit && (
128
- <Dialog
128
+ <DiscountDialog
129
129
  open={editOpen}
130
130
  onClose={() => setEditOpen(false)}
131
131
  title={copy.form.editTitle}
132
- size="md"
133
- showCloseButton
134
- dataTestId="discount-dialog"
132
+ copy={copy}
135
133
  >
136
- {/* Mounted only while open, so the next open re-seeds from the row
134
+ {/* Each opening mounts a fresh form, so it re-seeds from the row
137
135
  instead of keeping the last attempt's values. */}
138
- {editOpen && (
139
- <DialogContent>
140
- <DiscountForm
141
- key={row.id}
142
- api={api}
143
- copy={copy}
144
- formatters={formatters}
145
- currencyField={currencyField}
146
- groups={groups}
147
- editing={row.record}
148
- onError={onError}
149
- {...(timezoneLabel ? { timezoneLabel } : {})}
150
- onSaved={() => {
151
- setEditOpen(false);
152
- onRefresh();
153
- }}
154
- />
155
- </DialogContent>
156
- )}
157
- </Dialog>
136
+ <DiscountForm
137
+ key={row.id}
138
+ api={api}
139
+ copy={copy}
140
+ formatters={formatters}
141
+ currencyField={currencyField}
142
+ groups={groups}
143
+ editing={row.record}
144
+ onError={onError}
145
+ {...(timezoneLabel ? { timezoneLabel } : {})}
146
+ onSaved={() => {
147
+ setEditOpen(false);
148
+ onRefresh();
149
+ }}
150
+ />
151
+ </DiscountDialog>
158
152
  )}
159
153
  </>
160
154
  );
@@ -0,0 +1,67 @@
1
+ "use client";
2
+
3
+ import { Fragment, useState, type JSX, type ReactNode } from "react";
4
+
5
+ import { UsableModal } from "@12-apps/ui/feedback/UsableModal";
6
+ import { Box } from "@12-apps/ui/mui/Box";
7
+
8
+ import type { DiscountsWebCopy } from "./copy";
9
+
10
+ /**
11
+ * The surface the create and edit forms open in (FUT-4417): a stacked modal on
12
+ * a computer, a bottom sheet on a phone (`UsableModal`).
13
+ *
14
+ * `size="wide"` — the panel the library keeps for a dense two-column editor —
15
+ * because the combo form lays out "Como fica no carrinho" in its own column
16
+ * beside the fields when the form has the room (`PREVIEW_ASIDE_MIN_PX`), and
17
+ * the 800 px dialog it replaced never had it. The owner's call, 2026-11-05:
18
+ * "Substitui o modal pelo stacked modal no desktop".
19
+ *
20
+ * Every opening mounts a fresh form, so it re-seeds instead of keeping the
21
+ * last attempt's values — even when it reopens inside the modal's 400 ms way
22
+ * out, while the closing body is still mounted (hence the key per opening).
23
+ * The body stays mounted while it leaves, so the panel does not slide out
24
+ * empty. The submit stays in the form: it acts on the fields above it.
25
+ *
26
+ * `copy.form.dialog` names the ✕ and the back arrow, which carry a glyph only;
27
+ * the arrow shows when the modal opens over another stacked panel.
28
+ */
29
+ export function DiscountDialog({
30
+ open,
31
+ onClose,
32
+ title,
33
+ copy,
34
+ children,
35
+ }: {
36
+ open: boolean;
37
+ onClose: () => void;
38
+ title: string;
39
+ copy: DiscountsWebCopy;
40
+ children: ReactNode;
41
+ }): JSX.Element {
42
+ const [opening, setOpening] = useState(open ? 1 : 0);
43
+ const [wasOpen, setWasOpen] = useState(open);
44
+ if (open !== wasOpen) {
45
+ setWasOpen(open);
46
+ if (open) setOpening(opening + 1);
47
+ }
48
+ return (
49
+ <UsableModal
50
+ open={open}
51
+ onClose={onClose}
52
+ title={title}
53
+ size="wide"
54
+ closeLabel={copy.form.dialog.close}
55
+ backLabel={copy.form.dialog.back}
56
+ dataTestId="discount-dialog"
57
+ >
58
+ {/* `clip`, not `hidden`: a checked switch's hit area reaches 24 px past
59
+ its track (the input is three tracks wide), which the sheet's 16 px
60
+ gutter turned into an 8 px sideways scroll of the whole form. Clipping
61
+ keeps that overflow out of the scroller without making a second one. */}
62
+ <Box sx={{ minWidth: 0, overflowX: "clip" }}>
63
+ <Fragment key={opening}>{children}</Fragment>
64
+ </Box>
65
+ </UsableModal>
66
+ );
67
+ }
@@ -13,7 +13,10 @@ 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, type DiscountKind } from "./form-kind";
16
+ import { ComboRewardFields } from "./combo-reward-fields";
17
+ import { isComboKind, SELECTABLE_DISCOUNT_SCOPES } from "./form-kind";
18
+ import { DateField } from "./date-field";
19
+ import { PercentField, WholeNumberField } from "./number-fields";
17
20
  import { ScheduleBuilder } from "./schedule-builder";
18
21
 
19
22
  /**
@@ -49,14 +52,14 @@ function HintedField({
49
52
  }): JSX.Element {
50
53
  return (
51
54
  <Stack spacing={0.5}>
52
- <Fields.TextField name={name} label={label} type="number" />
55
+ <WholeNumberField name={name} label={label} />
53
56
  <Text variant="caption">{hint}</Text>
54
57
  </Stack>
55
58
  );
56
59
  }
57
60
 
58
61
  /** One closed set as a toggle's options, labelled by the pack. */
59
- function optionsFor<TKey extends string>(
62
+ export function optionsFor<TKey extends string>(
60
63
  values: readonly TKey[],
61
64
  labels: Readonly<Record<TKey, string>>,
62
65
  ): { value: string; label: string }[] {
@@ -73,70 +76,6 @@ function optionsFor<TKey extends string>(
73
76
  */
74
77
  export type CurrencyFieldComponent = (props: { name: string; label: string }) => JSX.Element;
75
78
 
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
- /**
100
- * A combo's reward: which of the two, and how much.
101
- *
102
- * Both are here rather than split between the top of the form and the bottom,
103
- * because to a merchant they are one sentence — "este combo dá 15% de
104
- * desconto". The two are the ONLY rewards a combo can give: a flat bundle price
105
- * reprices the group and goes silently wrong the first time one of its items
106
- * changes price, so it is not offered (see `./form-kind`).
107
- */
108
- function ComboRewardFields({
109
- copy,
110
- currencyField,
111
- }: {
112
- copy: DiscountsWebCopy;
113
- currencyField: CurrencyFieldComponent;
114
- }): JSX.Element {
115
- const { values } = useFormContext();
116
- const Currency = currencyField;
117
- return (
118
- <Stack spacing={0.5}>
119
- <Box sx={{ display: "grid", gap: 2, gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" } }}>
120
- <Fields.ToggleField
121
- name="comboReward"
122
- label={copy.form.comboReward}
123
- options={optionsFor(COMBO_REWARDS, copy.labels.type)}
124
- />
125
- {values.comboReward === "FIXED_AMOUNT" ? (
126
- <Currency name="amountOff" label={copy.form.amountOff} />
127
- ) : (
128
- <Fields.TextField
129
- name="percentOff"
130
- label={copy.form.percentOff}
131
- placeholder={copy.form.percentPlaceholder}
132
- />
133
- )}
134
- </Box>
135
- <Text variant="caption">{copy.form.comboRewardHint}</Text>
136
- </Stack>
137
- );
138
- }
139
-
140
79
  /**
141
80
  * The reward, in whichever unit the chosen KIND actually stores.
142
81
  *
@@ -148,7 +87,8 @@ function ComboRewardFields({
148
87
  *
149
88
  * `FREE_UNITS` is the one kind with nothing here: its reward is a count of the
150
89
  * units it gives away, which only reads as an offer beside the units it takes,
151
- * so the free-units builder carries both.
90
+ * so the free-units builder carries both. A `COMBO`'s reward is one of three —
91
+ * a rate, an amount or a closed price (FUT-4417) — see `./combo-reward-fields`.
152
92
  */
153
93
  export function RewardFields({
154
94
  copy,
@@ -162,15 +102,8 @@ export function RewardFields({
162
102
  const kind = values.kind ?? "PERCENTAGE";
163
103
  if (kind === "COMBO") return <ComboRewardFields copy={copy} currencyField={currencyField} />;
164
104
  if (kind === "FREE_UNITS") return null;
165
- if (kind === "BUNDLE_PRICE") return <Currency name="bundlePrice" label={copy.form.bundlePrice} />;
166
105
  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
- );
106
+ return <PercentField copy={copy} />;
174
107
  }
175
108
 
176
109
  /** The coupon input, shown only for a CODE-triggered rule. */
@@ -252,6 +185,24 @@ export function ComboCapField({ copy }: { copy: DiscountsWebCopy }): JSX.Element
252
185
  );
253
186
  }
254
187
 
188
+ /** Início and Término, day-first on every browser (FUT-4156). */
189
+ function PeriodFields({ copy }: { copy: DiscountsWebCopy }): JSX.Element {
190
+ return (
191
+ <Box sx={{ display: "grid", gap: 2, gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" } }}>
192
+ {(["startsAt", "endsAt"] as const).map((name) => (
193
+ <DateField
194
+ key={name}
195
+ name={name}
196
+ label={copy.form[name]}
197
+ placeholder={copy.form.datePlaceholder}
198
+ invalidMessage={copy.form.invalidDate}
199
+ incompleteMessage={copy.form.dateIncomplete}
200
+ />
201
+ ))}
202
+ </Box>
203
+ );
204
+ }
205
+
255
206
  /**
256
207
  * "Quando vale" — the campaign PERIOD, and the weekly schedule inside it
257
208
  * (FUT-996).
@@ -281,14 +232,19 @@ export function WindowFields({
281
232
  onScheduleChange: (next: DiscountSchedule) => void;
282
233
  onScheduleEnabledChange: (next: boolean) => void;
283
234
  }): JSX.Element {
235
+ // The schedule is not a form value, so nothing paints `errors.schedule` for
236
+ // it: it is handed to the builder, and cleared on the next edit the way a
237
+ // field's own error is (FUT-4155).
238
+ const { errors, setFieldValue } = useFormContext();
239
+ const scheduleError = errors.schedule;
240
+ const clearScheduleError = (): void => {
241
+ if (scheduleError !== undefined) setFieldValue("schedule", "");
242
+ };
284
243
  return (
285
244
  <Stack spacing={1.5}>
286
245
  <Text variant="heading">{copy.schedule.sectionTitle}</Text>
287
246
  <Stack spacing={0.5}>
288
- <Box sx={{ display: "grid", gap: 2, gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" } }}>
289
- <Fields.TextField name="startsAt" label={copy.form.startsAt} type="date" />
290
- <Fields.TextField name="endsAt" label={copy.form.endsAt} type="date" />
291
- </Box>
247
+ <PeriodFields copy={copy} />
292
248
  <Text variant="caption">{copy.schedule.periodHint}</Text>
293
249
  </Stack>
294
250
  <FormControl>
@@ -303,7 +259,10 @@ export function WindowFields({
303
259
  type="radio"
304
260
  name="discount-repetition"
305
261
  checked={scheduleEnabled === value}
306
- onChange={() => onScheduleEnabledChange(value)}
262
+ onChange={() => {
263
+ clearScheduleError();
264
+ onScheduleEnabledChange(value);
265
+ }}
307
266
  data-testid={value ? "repetition-specific" : "repetition-always"}
308
267
  />
309
268
  <Text variant="body">{label}</Text>
@@ -317,7 +276,11 @@ export function WindowFields({
317
276
  schedule={schedule}
318
277
  copy={copy}
319
278
  timezone={timezoneLabel ?? ""}
320
- onChange={onScheduleChange}
279
+ error={scheduleError}
280
+ onChange={(next) => {
281
+ clearScheduleError();
282
+ onScheduleChange(next);
283
+ }}
321
284
  />
322
285
  <OrderScopeScheduleNote copy={copy} />
323
286
  </>
@@ -355,8 +318,8 @@ export function LimitFields({
355
318
  return (
356
319
  <Box sx={{ display: "grid", gap: 2, gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr 1fr" } }}>
357
320
  <Currency name="minSubtotal" label={copy.form.minSubtotal} />
358
- <Fields.TextField name="usageLimit" label={copy.form.usageLimit} type="number" />
359
- <Fields.TextField name="perBuyerLimit" label={copy.form.perBuyerLimit} type="number" />
321
+ <WholeNumberField name="usageLimit" label={copy.form.usageLimit} />
322
+ <WholeNumberField name="perBuyerLimit" label={copy.form.perBuyerLimit} />
360
323
  </Box>
361
324
  );
362
325
  }