@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 +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/discount-list-card.tsx +12 -6
- 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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12-apps/discounts",
|
|
3
|
-
"version": "1.
|
|
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.
|
|
45
|
-
"@12-apps/forms-core": "^1.
|
|
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.
|
|
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.
|
|
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
|
|
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 `
|
|
79
|
-
*
|
|
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
|
-
<
|
|
121
|
-
type="number"
|
|
155
|
+
<CountInput
|
|
122
156
|
label={copy.combo.quantity}
|
|
123
|
-
value={
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
//
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
|
|
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={() =>
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
<
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
</
|
|
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
|
-
/**
|
|
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
|
+
}
|