@12-apps/discounts 1.15.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@12-apps/discounts",
3
- "version": "1.15.0",
3
+ "version": "1.17.0",
4
4
  "type": "module",
5
5
  "sideEffects": [
6
6
  "**/e2e/**"
@@ -41,10 +41,10 @@
41
41
  "typecheck": "tsc --noEmit"
42
42
  },
43
43
  "dependencies": {
44
- "@12-apps/app-shell": "^5.11.0",
45
- "@12-apps/forms-core": "^1.21.0",
44
+ "@12-apps/app-shell": "^5.14.1",
45
+ "@12-apps/forms-core": "^1.23.0",
46
46
  "@12-apps/shared-helpers": "^2.1.0",
47
- "@12-apps/ui": "^6.43.0",
47
+ "@12-apps/ui": "^6.86.0",
48
48
  "@mui/icons-material": "^6.5.0",
49
49
  "zod": "^4.3.5"
50
50
  },
@@ -80,7 +80,7 @@
80
80
  "@12-apps/eslint-config": "^1.22.0",
81
81
  "@12-apps/i18n": "^1.3.0",
82
82
  "@12-apps/typescript-config": "^1.21.0",
83
- "@12-apps/wiring": "^1.16.0",
83
+ "@12-apps/wiring": "^1.17.0",
84
84
  "@electric-sql/pglite": "^0.2.17",
85
85
  "@emotion/react": "^11.14.0",
86
86
  "@emotion/styled": "^11.14.0",
@@ -0,0 +1,107 @@
1
+ "use client";
2
+
3
+ import { useState, type JSX } from "react";
4
+
5
+ import { FormMessage } from "@12-apps/ui/form/Form";
6
+ import { Input } from "@12-apps/ui/form/Input";
7
+ import { Stack } from "@12-apps/ui/mui/Stack";
8
+
9
+ import { toMinutes } from "../engine/schedule";
10
+
11
+ /**
12
+ * A 24-hour `HH:MM` box that is NOT `<input type="time">` (FUT-4143).
13
+ *
14
+ * The native control renders itself in the BROWSER's language, not the page's:
15
+ * on an English machine "Das 16:00" reads "04:00 PM" to a Brazilian merchant on
16
+ * a Portuguese screen, and no attribute overrides it. The host's own hours
17
+ * screens dropped it for the same reason (FUT-872), and this is the same field:
18
+ * text, masked, with the phone's digit keypad.
19
+ *
20
+ * The mask inserts the colon as the operator types, so four digits make
21
+ * `09:00` and "0900123" can never exist — the same rule as the host's
22
+ * `maskTime`, restated because a package cannot import a host. A half-typed
23
+ * `09:` is a fine intermediate state and is not shouted at; a COMPLETE time no
24
+ * day has (25:00, 12:60) is marked on the spot, in the pack's words.
25
+ */
26
+
27
+ /**
28
+ * Keep four digits and put the colon after the second.
29
+ *
30
+ * Except while EDITING a time that already has its colon: select "09" in
31
+ * "09:00" and type "1" and the box holds "1:00", whose digits re-masked would
32
+ * read "10:0". So when the previous value had a separator and the new one
33
+ * still does, each side keeps its own digits — "1:00" stays, then "15:00".
34
+ * Any separator counts ("25/10" reads 25:10), and the result is still at most
35
+ * two digits a side.
36
+ */
37
+ function maskTime(raw: string, previous: string): string {
38
+ const separator = /\D/u.exec(raw);
39
+ if (separator !== null && previous.includes(":")) {
40
+ const hours = raw.slice(0, separator.index).replace(/\D/gu, "").slice(0, 2);
41
+ const minutes = raw.slice(separator.index + 1).replace(/\D/gu, "").slice(0, 2);
42
+ return `${hours}:${minutes}`;
43
+ }
44
+ const digits = raw.replace(/\D/gu, "").slice(0, 4);
45
+ if (digits.length <= 2) return digits;
46
+ return `${digits.slice(0, 2)}:${digits.slice(2)}`;
47
+ }
48
+
49
+ /** A finished `HH:MM` that names no minute of a day. */
50
+ function isImpossibleTime(value: string): boolean {
51
+ return /^\d{2}:\d{2}$/u.test(value) && toMinutes(value) === null;
52
+ }
53
+
54
+ export function ClockInput({
55
+ value,
56
+ label,
57
+ invalidMessage,
58
+ incompleteMessage,
59
+ submitted = false,
60
+ dataTestId,
61
+ onChange,
62
+ }: {
63
+ value: string;
64
+ /** The accessible name — "Das" and "às" sit side by side. */
65
+ label: string;
66
+ /** Under a time that does not exist. */
67
+ invalidMessage: string;
68
+ /** Under a half-typed time once the box is left — never while typing. */
69
+ incompleteMessage: string;
70
+ /**
71
+ * The form refused this row's times at Criar: an unfinished clock says so
72
+ * now, left or not, and the row does not repeat it (FUT-4143).
73
+ */
74
+ submitted?: boolean;
75
+ dataTestId: string;
76
+ onChange: (next: string) => void;
77
+ }): JSX.Element {
78
+ const [left, setLeft] = useState(false);
79
+ const impossible = isImpossibleTime(value);
80
+ const whole = /^\d{2}:\d{2}$/u.test(value);
81
+ const unfinished = !whole && ((left && value !== "") || submitted);
82
+ const message = impossible ? invalidMessage : unfinished ? incompleteMessage : undefined;
83
+ return (
84
+ <Stack spacing={0.5}>
85
+ <Input
86
+ type="text"
87
+ size="sm"
88
+ placeholder="00:00"
89
+ value={value}
90
+ error={message !== undefined}
91
+ aria-label={label}
92
+ inputProps={{ inputMode: "numeric", maxLength: 5, autoComplete: "off" }}
93
+ onBlur={() => setLeft(true)}
94
+ onChange={(event) => {
95
+ setLeft(false);
96
+ onChange(maskTime(event.target.value, value));
97
+ }}
98
+ data-testid={dataTestId}
99
+ />
100
+ {message !== undefined && (
101
+ <FormMessage error dataTestId={`${dataTestId}-message`}>
102
+ {message}
103
+ </FormMessage>
104
+ )}
105
+ </Stack>
106
+ );
107
+ }
@@ -1,10 +1,9 @@
1
1
  "use client";
2
2
 
3
- import type { JSX } from "react";
3
+ import { useRef, type JSX } from "react";
4
4
 
5
5
  import { Button } from "@12-apps/ui/form/Button";
6
6
  import { FormControl, FormLabel, FormMessage } from "@12-apps/ui/form/Form";
7
- import { Input } from "@12-apps/ui/form/Input";
8
7
  import { Box } from "@12-apps/ui/mui/Box";
9
8
  import { Stack } from "@12-apps/ui/mui/Stack";
10
9
  import { Text } from "@12-apps/ui/typography/Text";
@@ -13,6 +12,7 @@ import type { ComboRequirement } from "../engine/types";
13
12
 
14
13
  import type { WireTargetGroup } from "./api";
15
14
  import { fill, type DiscountsWebCopy } from "./copy";
15
+ import { CountInput } from "./number-fields";
16
16
  import { TargetPickerField } from "./target-picker-field";
17
17
 
18
18
  /**
@@ -48,11 +48,45 @@ export function comboUnits(slots: readonly ComboRequirement[]): number {
48
48
  return slots.reduce((total, slot) => total + (Number.isFinite(slot.quantity) ? slot.quantity : 0), 0);
49
49
  }
50
50
 
51
+ /** "1 item" or "4 itens": the pack's one form for exactly 1, its other form otherwise. */
52
+ function counted(count: number, one: string, other: string): string {
53
+ return fill(count === 1 ? one : other, { count });
54
+ }
55
+
56
+ /** The read-back sentence, both nouns in the right number. */
57
+ function comboSummary(slots: readonly ComboRequirement[], copy: DiscountsWebCopy): string {
58
+ const { combo } = copy;
59
+ return fill(combo.summary, {
60
+ units: counted(comboUnits(slots), combo.unitsOne, combo.unitsOther),
61
+ groups: counted(slots.length, combo.groupsOne, combo.groupsOther),
62
+ });
63
+ }
64
+
51
65
  /** An empty group: one unit, nothing chosen yet. */
52
66
  export function blankSlot(): ComboRequirement {
53
67
  return { quantity: 1, categoryIds: [], menuItemIds: [] };
54
68
  }
55
69
 
70
+ /**
71
+ * One screen-only id per group, in list order (FUT-4143).
72
+ *
73
+ * A group has no id until the database gives it one, and an index key hands a
74
+ * removed row's component — and its half-typed draft — to the row that moves
75
+ * up. These ids live here and nowhere else: the wire still carries the plain
76
+ * `ComboRequirement`s. Appends get a fresh id; a removal drops its own (see
77
+ * `onRemove`); a list replaced from outside is padded or cut to length.
78
+ */
79
+ function useRowIds(length: number): { current: number[] } {
80
+ const ids = useRef<number[]>([]);
81
+ const next = useRef(0);
82
+ while (ids.current.length < length) {
83
+ ids.current = [...ids.current, next.current];
84
+ next.current += 1;
85
+ }
86
+ if (ids.current.length > length) ids.current = ids.current.slice(0, length);
87
+ return ids;
88
+ }
89
+
56
90
  /** Replace one group, leaving the rest (and their order) alone. */
57
91
  function replaceAt(
58
92
  slots: readonly ComboRequirement[],
@@ -75,8 +109,9 @@ function withIds(slot: ComboRequirement, targetType: string, ids: string[]): Com
75
109
  * One group: its quantity, one picker per registered collection, and the
76
110
  * control that removes it.
77
111
  *
78
- * The quantity is a `number` input rather than a masked one — it counts units,
79
- * not money, so there is no locale in it. It is held as a NUMBER in state
112
+ * The quantity is a digits-only text box (`CountInput`, FUT-4143) rather than
113
+ * `type="number"`, which read "2,5" by the browser's language — as 25 in an
114
+ * English one. It is held as a NUMBER in state
80
115
  * rather than as the form's string, because the summary line above adds them up
81
116
  * and the validator compares them against `freeUnits`; parsing on every read
82
117
  * would put the same `Number()` in three places.
@@ -117,12 +152,13 @@ function ComboSlotRow({
117
152
  </Button>
118
153
  </Stack>
119
154
  <Stack spacing={2} sx={{ mt: 1 }}>
120
- <Input
121
- type="number"
155
+ <CountInput
122
156
  label={copy.combo.quantity}
123
- value={String(slot.quantity)}
124
- onChange={(event) => onChange({ ...slot, quantity: Number(event.target.value) })}
125
- data-testid={`combo-slot-${index}-quantity`}
157
+ value={slot.quantity}
158
+ copy={copy}
159
+ invalidMessage={copy.form.invalidComboQuantity}
160
+ dataTestId={`combo-slot-${index}-quantity`}
161
+ onChange={(quantity) => onChange({ ...slot, quantity })}
126
162
  />
127
163
  {groups.map((group) => (
128
164
  <TargetPickerField
@@ -170,6 +206,7 @@ export function ComboSlotBuilder({
170
206
  copy: DiscountsWebCopy;
171
207
  onChange: (next: ComboRequirement[]) => void;
172
208
  }): JSX.Element {
209
+ const rowIds = useRowIds(slots.length);
173
210
  return (
174
211
  <FormControl fullWidth>
175
212
  <FormLabel>{copy.combo.title}</FormLabel>
@@ -177,17 +214,19 @@ export function ComboSlotBuilder({
177
214
  <Stack spacing={2} sx={{ mt: 1 }} data-testid="combo-slots">
178
215
  {slots.map((slot, index) => (
179
216
  <ComboSlotRow
180
- // The index IS the identity here: `position` is the array index,
181
- // and a group carries no id of its own until the database gives it
182
- // one. Removing a group renumbers the rest, which is exactly what
183
- // the operator sees happen.
184
- key={index}
217
+ // `position` is the array index, and removing a group renumbers
218
+ // the rest — but the ROW is keyed by a screen-only id, so the
219
+ // group below does not inherit the removed one's draft.
220
+ key={rowIds.current[index]}
185
221
  slot={slot}
186
222
  index={index}
187
223
  groups={groups}
188
224
  copy={copy}
189
225
  onChange={(next) => onChange(replaceAt(slots, index, next))}
190
- onRemove={() => onChange(slots.filter((_, position) => position !== index))}
226
+ onRemove={() => {
227
+ rowIds.current = rowIds.current.filter((_, position) => position !== index);
228
+ onChange(slots.filter((_, position) => position !== index));
229
+ }}
191
230
  />
192
231
  ))}
193
232
  {slots.length === 0 && (
@@ -195,20 +234,27 @@ export function ComboSlotBuilder({
195
234
  {copy.combo.empty}
196
235
  </Text>
197
236
  )}
198
- <Stack direction="row" alignItems="center" justifyContent="space-between" spacing={2}>
199
- <Button
200
- variant="outline"
201
- onClick={() => onChange([...slots, blankSlot()])}
202
- data-testid="combo-add-slot"
203
- >
204
- {copy.combo.addSlot}
205
- </Button>
237
+ {/* Wraps as a ROW, never inside the button: where the note does not
238
+ fit beside "Adicionar grupo" it takes the line below, and the
239
+ button's own label stays on one line (FUT-4143 UI review). */}
240
+ <Box sx={{ display: "flex", flexWrap: "wrap", alignItems: "center", columnGap: 2, rowGap: 1 }}>
241
+ <Box sx={{ flexShrink: 0, "& > button": { whiteSpace: "nowrap" } }}>
242
+ <Button
243
+ variant="outline"
244
+ onClick={() => onChange([...slots, blankSlot()])}
245
+ data-testid="combo-add-slot"
246
+ >
247
+ {copy.combo.addSlot}
248
+ </Button>
249
+ </Box>
206
250
  {slots.length > 0 && (
207
- <Text variant="caption" data-testid="combo-summary">
208
- {fill(copy.combo.summary, { units: comboUnits(slots), groups: slots.length })}
209
- </Text>
251
+ <Box sx={{ flex: "1 1 200px", minWidth: 0 }}>
252
+ <Text variant="caption" data-testid="combo-summary">
253
+ {comboSummary(slots, copy)}
254
+ </Text>
255
+ </Box>
210
256
  )}
211
- </Stack>
257
+ </Box>
212
258
  </Stack>
213
259
  </FormControl>
214
260
  );
package/src/react/copy.ts CHANGED
@@ -132,6 +132,8 @@ export interface DiscountsFormCopy {
132
132
  readonly scope: string;
133
133
  readonly startsAt: string;
134
134
  readonly endsAt: string;
135
+ /** Inside an empty Início / Término: the day-first order the box reads (FUT-4156). */
136
+ readonly datePlaceholder: string;
135
137
  readonly minSubtotal: string;
136
138
  readonly usageLimit: string;
137
139
  readonly perBuyerLimit: string;
@@ -147,12 +149,24 @@ export interface DiscountsFormCopy {
147
149
  /** The first-purchase condition (FUT-2825), and what it does to the menu. */
148
150
  readonly firstOrderOnly: string;
149
151
  readonly firstOrderOnlyHint: string;
150
- /** The banner over a form the operator must fix before it will send. */
152
+ /**
153
+ * The banner over a form the operator must fix before it will send. No
154
+ * longer rendered (FUT-4143): a client refusal says it once, in the toast
155
+ * (`errorSnackbar`). Kept so a host's own pack still type-checks.
156
+ */
151
157
  readonly reviewFields: string;
158
+ /**
159
+ * The toast a failed submit raises over the whole form (FUT-4143). The packs
160
+ * take it from `@12-apps/ui`'s own labels; `FormErrorSnackbar`'s fallback is
161
+ * English, so a form that does not pass one shows English on any screen.
162
+ */
163
+ readonly errorSnackbar: string;
152
164
  /** The banner over a write the SERVER refused. */
153
165
  readonly saveFailed: string;
154
166
  /** Per-input refusals, each attached to the input it names. */
155
167
  readonly nameRequired: string;
168
+ /** A name of one character — the two-character floor this form has always held. */
169
+ readonly nameTooShort: string;
156
170
  readonly invalidPercent: string;
157
171
  readonly invalidAmount: string;
158
172
  readonly invalidBundlePrice: string;
@@ -174,7 +188,20 @@ export interface DiscountsFormCopy {
174
188
  readonly comboSlotTargetRequired: string;
175
189
  readonly invalidComboQuantity: string;
176
190
  readonly invalidMaxComboApplications: string;
191
+ /** The two redemption caps: whole numbers from 1, or blank for "no cap". */
192
+ readonly invalidUsageLimit: string;
193
+ readonly invalidPerBuyerLimit: string;
194
+ /**
195
+ * Under a unit count typed with anything but digits ("2,5"), said while it is
196
+ * typed: a count of units has no decimals, and a browser that read the comma
197
+ * away stored 25.
198
+ */
199
+ readonly wholeNumberOnly: string;
177
200
  readonly endsBeforeStarts: string;
201
+ /** A date that is not a whole, real day — "31/02/2026", or "12/0" at Criar. */
202
+ readonly invalidDate: string;
203
+ /** A date left half-typed ("25/10/202"): shown once the box is left, or at Criar. */
204
+ readonly dateIncomplete: string;
178
205
  }
179
206
 
180
207
  /**
@@ -205,9 +232,17 @@ export interface DiscountsComboCopy {
205
232
  readonly empty: string;
206
233
  /**
207
234
  * The offer read back in the operator's own numbers, so a combo can be
208
- * checked without saving it. `{units}` and `{groups}`.
235
+ * checked without saving it. `{units}` and `{groups}` are the two counted
236
+ * nouns below, already filled — "4 itens", "1 grupo" (FUT-4143: the sentence
237
+ * used to say "1 grupos").
209
238
  */
210
239
  readonly summary: string;
240
+ /** "1 item" / "{count} itens": one for exactly 1, the other for every other count. */
241
+ readonly unitsOne: string;
242
+ readonly unitsOther: string;
243
+ /** "1 grupo" / "{count} grupos", the same rule. */
244
+ readonly groupsOne: string;
245
+ readonly groupsOther: string;
211
246
  /**
212
247
  * The "leve 3, pague 2" builder (`./free-units-builder`), which is the same
213
248
  * stored shape wearing the sentence an operator would actually say. Its own
@@ -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
+ }