@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
|
@@ -0,0 +1,142 @@
|
|
|
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 { Fields, type TextFieldInputProps } from "@12-apps/ui/form/total-form";
|
|
8
|
+
import { Stack } from "@12-apps/ui/mui/Stack";
|
|
9
|
+
|
|
10
|
+
import type { DiscountsWebCopy } from "./copy";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The form's number boxes, and the keypad each one asks the phone for
|
|
14
|
+
* (FUT-4143).
|
|
15
|
+
*
|
|
16
|
+
* None of them is `type="number"`. That control reads "2,5" according to the
|
|
17
|
+
* BROWSER's language — as 2.5, as nothing, or (in the survey's English
|
|
18
|
+
* Chromium) as 25 — and a promotion limited to 25 uses because the operator
|
|
19
|
+
* typed a comma is a silent, expensive mistake. Text boxes keep what was typed;
|
|
20
|
+
* the validator, which knows the pack's language, decides what it means.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** A percentage takes a separator: "12,5". */
|
|
24
|
+
const DECIMAL_KEYPAD: TextFieldInputProps = { inputMode: "decimal" };
|
|
25
|
+
|
|
26
|
+
/** A count of uses or units takes digits and nothing else. */
|
|
27
|
+
const WHOLE_NUMBER_KEYPAD: TextFieldInputProps = { inputMode: "numeric", pattern: "[0-9]*" };
|
|
28
|
+
|
|
29
|
+
/** Digits only, as typed. */
|
|
30
|
+
function isWholeNumber(typed: string): boolean {
|
|
31
|
+
return /^\d+$/u.test(typed.trim());
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** "Desconto (%)", wherever the kind mounts it. */
|
|
35
|
+
export function PercentField({ copy }: { copy: DiscountsWebCopy }): JSX.Element {
|
|
36
|
+
return (
|
|
37
|
+
<Fields.TextField
|
|
38
|
+
name="percentOff"
|
|
39
|
+
label={copy.form.percentOff}
|
|
40
|
+
placeholder={copy.form.percentPlaceholder}
|
|
41
|
+
inputProps={DECIMAL_KEYPAD}
|
|
42
|
+
/>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A form-bound count: "Limite de usos", "Limite por cliente", "Combos por pedido". */
|
|
47
|
+
export function WholeNumberField({ name, label }: { name: string; label: string }): JSX.Element {
|
|
48
|
+
return <Fields.TextField name={name} label={label} inputProps={WHOLE_NUMBER_KEYPAD} />;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The raw-`Input` twin of {@link WholeNumberField}, for a count held as text by its caller. */
|
|
52
|
+
export function WholeNumberInput({
|
|
53
|
+
label,
|
|
54
|
+
value,
|
|
55
|
+
error,
|
|
56
|
+
dataTestId,
|
|
57
|
+
onChange,
|
|
58
|
+
}: {
|
|
59
|
+
label: string;
|
|
60
|
+
value: string;
|
|
61
|
+
error: boolean;
|
|
62
|
+
dataTestId: string;
|
|
63
|
+
onChange: (next: string) => void;
|
|
64
|
+
}): JSX.Element {
|
|
65
|
+
return (
|
|
66
|
+
<Input
|
|
67
|
+
label={label}
|
|
68
|
+
value={value}
|
|
69
|
+
error={error}
|
|
70
|
+
inputProps={WHOLE_NUMBER_KEYPAD}
|
|
71
|
+
onChange={(event) => onChange(event.target.value)}
|
|
72
|
+
data-testid={dataTestId}
|
|
73
|
+
/>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* What a count box shows for the number its caller holds.
|
|
79
|
+
*
|
|
80
|
+
* The STATE decides; the draft is only how it is spelled. A draft that reads
|
|
81
|
+
* as the held number is shown as typed. A draft that is not a number at all
|
|
82
|
+
* ("2,5", "") is shown while the held value is "not a number" too. Anything
|
|
83
|
+
* else is stale — a draft left from another row, a value changed from outside
|
|
84
|
+
* — and the state wins: the number, or an empty box for `NaN`.
|
|
85
|
+
*/
|
|
86
|
+
function countShown(typed: string, value: number): string {
|
|
87
|
+
const parsed = isWholeNumber(typed) ? Number(typed.trim()) : Number.NaN;
|
|
88
|
+
if (Number.isNaN(value)) return Number.isNaN(parsed) ? typed : "";
|
|
89
|
+
return parsed === value ? typed : String(value);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* A count the caller holds as a NUMBER — a combo group's quantity, the units
|
|
94
|
+
* "leve 3, pague 2" takes.
|
|
95
|
+
*
|
|
96
|
+
* It keeps what was typed so "2,5" stays on screen and is refused there, in
|
|
97
|
+
* the pack's words, instead of becoming `NaN` in the box. What the caller gets
|
|
98
|
+
* is the number, or `NaN` while the text is not one — which its own validator
|
|
99
|
+
* refuses at submit, so nothing typed with a comma is ever sent. The box is
|
|
100
|
+
* red whenever the held value is not a count from 1, so it never looks fine
|
|
101
|
+
* beside a summary that says otherwise.
|
|
102
|
+
*/
|
|
103
|
+
export function CountInput({
|
|
104
|
+
label,
|
|
105
|
+
value,
|
|
106
|
+
copy,
|
|
107
|
+
invalidMessage,
|
|
108
|
+
dataTestId,
|
|
109
|
+
onChange,
|
|
110
|
+
}: {
|
|
111
|
+
label: string;
|
|
112
|
+
value: number;
|
|
113
|
+
copy: DiscountsWebCopy;
|
|
114
|
+
/** Under an empty box or a zero — the caller's own refusal. */
|
|
115
|
+
invalidMessage: string;
|
|
116
|
+
dataTestId: string;
|
|
117
|
+
onChange: (next: number) => void;
|
|
118
|
+
}): JSX.Element {
|
|
119
|
+
const [typed, setTyped] = useState<string>(() => (Number.isFinite(value) ? String(value) : ""));
|
|
120
|
+
const shown = countShown(typed, value);
|
|
121
|
+
const notDigits = shown.trim() !== "" && !isWholeNumber(shown);
|
|
122
|
+
const refused = !Number.isInteger(value) || value <= 0;
|
|
123
|
+
return (
|
|
124
|
+
<Stack spacing={0.5}>
|
|
125
|
+
<WholeNumberInput
|
|
126
|
+
label={label}
|
|
127
|
+
value={shown}
|
|
128
|
+
error={refused}
|
|
129
|
+
dataTestId={dataTestId}
|
|
130
|
+
onChange={(next) => {
|
|
131
|
+
setTyped(next);
|
|
132
|
+
onChange(isWholeNumber(next) ? Number(next.trim()) : Number.NaN);
|
|
133
|
+
}}
|
|
134
|
+
/>
|
|
135
|
+
{refused && (
|
|
136
|
+
<FormMessage error dataTestId={`${dataTestId}-message`}>
|
|
137
|
+
{notDigits ? copy.form.wholeNumberOnly : invalidMessage}
|
|
138
|
+
</FormMessage>
|
|
139
|
+
)}
|
|
140
|
+
</Stack>
|
|
141
|
+
);
|
|
142
|
+
}
|
package/src/react/pt-BR.ts
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
PT_BR_CATEGORY_SELECT_COPY,
|
|
4
4
|
PT_BR_CONFIRM_ACTION_COPY,
|
|
5
5
|
PT_BR_DATA_VIEWS_COPY,
|
|
6
|
+
PT_BR_UI_LABELS,
|
|
6
7
|
} from "@12-apps/ui/pt-BR";
|
|
7
8
|
|
|
8
9
|
import type { DiscountsWebCopy } from "./copy";
|
|
@@ -94,7 +95,7 @@ export const PT_BR_DISCOUNTS_WEB_COPY: DiscountsWebCopy = {
|
|
|
94
95
|
toLabel: "às",
|
|
95
96
|
presetEveryDay: "Todos os dias",
|
|
96
97
|
presetWeekdays: "Seg a Sex",
|
|
97
|
-
presetWeekend: "
|
|
98
|
+
presetWeekend: "Sáb e Dom",
|
|
98
99
|
dayShort: ["Seg", "Ter", "Qua", "Qui", "Sex", "Sáb", "Dom"],
|
|
99
100
|
dayLong: ["segunda", "terça", "quarta", "quinta", "sexta", "sábado", "domingo"],
|
|
100
101
|
dayEvery: [
|
|
@@ -122,6 +123,8 @@ export const PT_BR_DISCOUNTS_WEB_COPY: DiscountsWebCopy = {
|
|
|
122
123
|
activeNow: "Ativa agora",
|
|
123
124
|
daysRequired: "Escolha ao menos um dia.",
|
|
124
125
|
timesRequired: "Informe um horário de início e de fim diferentes.",
|
|
126
|
+
invalidTime: "Horário inexistente. Use de 00:00 a 23:59.",
|
|
127
|
+
timeIncomplete: "Termine o horário: HH:MM.",
|
|
125
128
|
windowsRequired: "Adicione ao menos um horário.",
|
|
126
129
|
},
|
|
127
130
|
form: {
|
|
@@ -162,8 +165,13 @@ export const PT_BR_DISCOUNTS_WEB_COPY: DiscountsWebCopy = {
|
|
|
162
165
|
firstOrderOnlyHint:
|
|
163
166
|
"Vale só para quem nunca comprou aqui. O preço do cardápio não muda: o desconto aparece no carrinho de quem pode usar.",
|
|
164
167
|
reviewFields: "Revise os campos destacados.",
|
|
168
|
+
errorSnackbar: PT_BR_UI_LABELS.formErrorSnackbar.message,
|
|
165
169
|
saveFailed: "Não foi possível salvar o desconto",
|
|
166
170
|
nameRequired: "Informe o nome da promoção.",
|
|
171
|
+
nameTooShort: "O nome precisa ter pelo menos 2 letras.",
|
|
172
|
+
datePlaceholder: "DD/MM/AAAA",
|
|
173
|
+
invalidDate: "Data inexistente. Use DD/MM/AAAA, como 25/12/2026.",
|
|
174
|
+
dateIncomplete: "Termine a data: DD/MM/AAAA.",
|
|
167
175
|
invalidPercent: "Informe uma porcentagem maior que 0 e no máximo 100.",
|
|
168
176
|
invalidAmount: "Informe um valor de desconto maior que zero.",
|
|
169
177
|
invalidBundlePrice: "Informe o preço do combo, maior que zero.",
|
|
@@ -178,6 +186,9 @@ export const PT_BR_DISCOUNTS_WEB_COPY: DiscountsWebCopy = {
|
|
|
178
186
|
comboSlotTargetRequired: "Escolha o que pode entrar em cada grupo do combo.",
|
|
179
187
|
invalidComboQuantity: "Informe quantas unidades cada grupo leva, a partir de 1.",
|
|
180
188
|
invalidMaxComboApplications: "Informe quantos combos cabem por pedido, a partir de 1.",
|
|
189
|
+
invalidUsageLimit: "Informe um número inteiro de usos, a partir de 1, ou deixe em branco.",
|
|
190
|
+
invalidPerBuyerLimit: "Informe um número inteiro por cliente, a partir de 1, ou deixe em branco.",
|
|
191
|
+
wholeNumberOnly: "Use só números inteiros, sem vírgula.",
|
|
181
192
|
endsBeforeStarts: "A data de término deve ser posterior à data de início.",
|
|
182
193
|
},
|
|
183
194
|
combo: {
|
|
@@ -189,7 +200,11 @@ export const PT_BR_DISCOUNTS_WEB_COPY: DiscountsWebCopy = {
|
|
|
189
200
|
quantity: "Quantidade",
|
|
190
201
|
pick: "{collection} deste grupo",
|
|
191
202
|
empty: "Nenhum grupo ainda. Adicione o primeiro para montar o combo.",
|
|
192
|
-
summary: "O combo leva {units}
|
|
203
|
+
summary: "O combo leva {units} em {groups}.",
|
|
204
|
+
unitsOne: "{count} item",
|
|
205
|
+
unitsOther: "{count} itens",
|
|
206
|
+
groupsOne: "{count} grupo",
|
|
207
|
+
groupsOther: "{count} grupos",
|
|
193
208
|
freeUnitsTitle: "Leve mais, pague menos",
|
|
194
209
|
freeUnitsHint:
|
|
195
210
|
"Escolha os produtos, quantos o cliente leva e quantos saem de graça — é o clássico \"leve 3, pague 2\".",
|
|
@@ -4,13 +4,18 @@ import 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";
|
|
11
10
|
|
|
12
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
MAX_SCHEDULE_WINDOWS,
|
|
13
|
+
toMinutes,
|
|
14
|
+
type DiscountSchedule,
|
|
15
|
+
type DiscountScheduleWindow,
|
|
16
|
+
} from "../engine/schedule";
|
|
13
17
|
|
|
18
|
+
import { ClockInput } from "./clock-input";
|
|
14
19
|
import { fill, type DiscountsWebCopy } from "./copy";
|
|
15
20
|
import { formatScheduleWindow } from "./schedule-summary";
|
|
16
21
|
|
|
@@ -45,6 +50,83 @@ const WEEK = [0, 1, 2, 3, 4, 5, 6];
|
|
|
45
50
|
const WEEKDAY_PRESET = [0, 1, 2, 3, 4];
|
|
46
51
|
const WEEKEND_PRESET = [5, 6];
|
|
47
52
|
|
|
53
|
+
/**
|
|
54
|
+
* The options of one question on ONE line, in equal parts (FUT-4143 UI review,
|
|
55
|
+
* ADR 0190 in the host): seven day chips, or three presets. Each part shrinks
|
|
56
|
+
* with the form instead of wrapping to a second line, and a label too long for
|
|
57
|
+
* its part is cut with an ellipsis — the full text stays the button's name.
|
|
58
|
+
*/
|
|
59
|
+
function equalParts(count: number, compact = false) {
|
|
60
|
+
return {
|
|
61
|
+
display: "grid",
|
|
62
|
+
gridTemplateColumns: `repeat(${count}, minmax(0, 1fr))`,
|
|
63
|
+
gap: 0.25,
|
|
64
|
+
// No side padding, and a smaller type on a phone, so every label fits its
|
|
65
|
+
// part whole at 320; the ellipsis is only for a form narrower than that.
|
|
66
|
+
"& > button.MuiButton-root": {
|
|
67
|
+
minWidth: 0,
|
|
68
|
+
width: "100%",
|
|
69
|
+
px: 0,
|
|
70
|
+
// Days at 13 px on a phone: "Dom" is 31 px at 14 and its part ~30.
|
|
71
|
+
fontSize: compact ? "0.75rem" : { xs: "0.8125rem", sm: "0.875rem" },
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A label that ellipsizes inside its part rather than wrapping. */
|
|
77
|
+
function OneLine({ children }: { children: string }): JSX.Element {
|
|
78
|
+
return (
|
|
79
|
+
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
|
80
|
+
{children}
|
|
81
|
+
</span>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Where a schedule refusal is about: a row's days, or a row's times. */
|
|
86
|
+
interface ErrorSpot {
|
|
87
|
+
index: number;
|
|
88
|
+
part: "days" | "times";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const WHOLE_TIME = /^\d{2}:\d{2}$/u;
|
|
92
|
+
|
|
93
|
+
/** Which row a time refusal is about, by the refusal itself. */
|
|
94
|
+
function timeCulprit(error: string, copy: DiscountsWebCopy): ((w: DiscountScheduleWindow) => boolean) | null {
|
|
95
|
+
const { timesRequired, timeIncomplete, invalidTime } = copy.schedule;
|
|
96
|
+
const ends = (w: DiscountScheduleWindow): string[] => [w.from, w.to];
|
|
97
|
+
if (error === timeIncomplete) return (w) => ends(w).some((end) => !WHOLE_TIME.test(end));
|
|
98
|
+
if (error === invalidTime) return (w) => ends(w).some((end) => toMinutes(end) === null);
|
|
99
|
+
if (error === timesRequired) return (w) => w.from === w.to;
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Put the refusal under the control it is about (FUT-4143 UI review): "choose
|
|
105
|
+
* a day" under the first row with no day, a time refusal under the first row
|
|
106
|
+
* whose times are refused. Anything else stays at the foot of the builder.
|
|
107
|
+
*/
|
|
108
|
+
function errorSpot(
|
|
109
|
+
error: string | null | undefined,
|
|
110
|
+
windows: readonly DiscountScheduleWindow[],
|
|
111
|
+
copy: DiscountsWebCopy,
|
|
112
|
+
): ErrorSpot | null {
|
|
113
|
+
if (error == null || error === "") return null;
|
|
114
|
+
const days =
|
|
115
|
+
error === copy.schedule.daysRequired ? windows.findIndex((w) => w.days.length === 0) : -1;
|
|
116
|
+
if (days >= 0) return { index: days, part: "days" };
|
|
117
|
+
const culprit = timeCulprit(error, copy);
|
|
118
|
+
const times = culprit === null ? -1 : windows.findIndex(culprit);
|
|
119
|
+
return times >= 0 ? { index: times, part: "times" } : null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function ScheduleMessage({ text }: { text: string }): JSX.Element {
|
|
123
|
+
return (
|
|
124
|
+
<FormMessage error dataTestId="schedule-builder-message">
|
|
125
|
+
{text}
|
|
126
|
+
</FormMessage>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
48
130
|
/** A new row: the most common promotion, so an operator edits rather than fills. */
|
|
49
131
|
export function blankWindow(): DiscountScheduleWindow {
|
|
50
132
|
return { days: [], from: "16:00", to: "20:00" };
|
|
@@ -76,17 +158,75 @@ function DayChip({
|
|
|
76
158
|
aria-pressed={selected}
|
|
77
159
|
data-testid={`schedule-day-${index}-${label}`}
|
|
78
160
|
>
|
|
79
|
-
{label}
|
|
161
|
+
<OneLine>{label}</OneLine>
|
|
80
162
|
</Button>
|
|
81
163
|
);
|
|
82
164
|
}
|
|
83
165
|
|
|
166
|
+
/** One row's "Das … às …", two 24-hour clocks. */
|
|
167
|
+
function WindowTimes({
|
|
168
|
+
window,
|
|
169
|
+
index,
|
|
170
|
+
copy,
|
|
171
|
+
submitted,
|
|
172
|
+
onChange,
|
|
173
|
+
}: {
|
|
174
|
+
window: DiscountScheduleWindow;
|
|
175
|
+
index: number;
|
|
176
|
+
copy: DiscountsWebCopy;
|
|
177
|
+
/** Criar refused one of these clocks in its own words; each clock says it. */
|
|
178
|
+
submitted: boolean;
|
|
179
|
+
onChange: (next: DiscountScheduleWindow) => void;
|
|
180
|
+
}): JSX.Element {
|
|
181
|
+
return (
|
|
182
|
+
<Box sx={{ display: "flex", gap: 1, alignItems: "flex-start", flexWrap: "wrap" }}>
|
|
183
|
+
<Box sx={{ pt: 1 }}>
|
|
184
|
+
<Text variant="caption">{copy.schedule.fromLabel}</Text>
|
|
185
|
+
</Box>
|
|
186
|
+
<ClockInput
|
|
187
|
+
value={window.from}
|
|
188
|
+
label={copy.schedule.fromLabel}
|
|
189
|
+
invalidMessage={copy.schedule.invalidTime}
|
|
190
|
+
incompleteMessage={copy.schedule.timeIncomplete}
|
|
191
|
+
submitted={submitted}
|
|
192
|
+
onChange={(from) => onChange({ ...window, from })}
|
|
193
|
+
dataTestId={`schedule-from-${index}`}
|
|
194
|
+
/>
|
|
195
|
+
<Box sx={{ pt: 1 }}>
|
|
196
|
+
<Text variant="caption">{copy.schedule.toLabel}</Text>
|
|
197
|
+
</Box>
|
|
198
|
+
<ClockInput
|
|
199
|
+
value={window.to}
|
|
200
|
+
label={copy.schedule.toLabel}
|
|
201
|
+
invalidMessage={copy.schedule.invalidTime}
|
|
202
|
+
incompleteMessage={copy.schedule.timeIncomplete}
|
|
203
|
+
submitted={submitted}
|
|
204
|
+
onChange={(to) => onChange({ ...window, to })}
|
|
205
|
+
dataTestId={`schedule-to-${index}`}
|
|
206
|
+
/>
|
|
207
|
+
</Box>
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* An unfinished or impossible clock says so itself, under the clock; the row
|
|
213
|
+
* repeats only the time refusal no single clock owns (equal ends).
|
|
214
|
+
*/
|
|
215
|
+
function clocksSayIt(
|
|
216
|
+
message: { part: "days" | "times"; text: string } | null,
|
|
217
|
+
copy: DiscountsWebCopy,
|
|
218
|
+
): boolean {
|
|
219
|
+
if (message?.part !== "times") return false;
|
|
220
|
+
return message.text === copy.schedule.timeIncomplete || message.text === copy.schedule.invalidTime;
|
|
221
|
+
}
|
|
222
|
+
|
|
84
223
|
/** One "these days, these hours" row. */
|
|
85
224
|
function WindowRow({
|
|
86
225
|
window,
|
|
87
226
|
index,
|
|
88
227
|
copy,
|
|
89
228
|
canRemove,
|
|
229
|
+
message,
|
|
90
230
|
onChange,
|
|
91
231
|
onRemove,
|
|
92
232
|
}: {
|
|
@@ -94,27 +234,23 @@ function WindowRow({
|
|
|
94
234
|
index: number;
|
|
95
235
|
copy: DiscountsWebCopy;
|
|
96
236
|
canRemove: boolean;
|
|
237
|
+
/** This row's share of the builder's refusal, painted under the part it names. */
|
|
238
|
+
message: { part: "days" | "times"; text: string } | null;
|
|
97
239
|
onChange: (next: DiscountScheduleWindow) => void;
|
|
98
240
|
onRemove: () => void;
|
|
99
241
|
}): JSX.Element {
|
|
100
242
|
const sentence = formatScheduleWindow(window, copy);
|
|
243
|
+
const clockSays = clocksSayIt(message, copy);
|
|
101
244
|
return (
|
|
102
245
|
<Stack
|
|
103
246
|
spacing={1}
|
|
104
|
-
sx={{ border: 1, borderColor: "divider", borderRadius: 1, p: 1.5 }}
|
|
247
|
+
sx={{ border: 1, borderColor: "divider", borderRadius: 1, p: { xs: 1, sm: 1.5 } }}
|
|
105
248
|
data-testid={`schedule-window-${index}`}
|
|
106
249
|
>
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
label={copy.schedule.dayShort[day] ?? String(day)}
|
|
112
|
-
index={index}
|
|
113
|
-
selected={window.days.includes(day)}
|
|
114
|
-
onClick={() => onChange({ ...window, days: toggleDay(window.days, day) })}
|
|
115
|
-
/>
|
|
116
|
-
))}
|
|
117
|
-
{canRemove && (
|
|
250
|
+
{/* The remove control sits on its own line, top-right, so the seven
|
|
251
|
+
days keep the whole width for their one line. */}
|
|
252
|
+
{canRemove && (
|
|
253
|
+
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
|
|
118
254
|
<Button
|
|
119
255
|
type="button"
|
|
120
256
|
variant="ghost"
|
|
@@ -125,26 +261,28 @@ function WindowRow({
|
|
|
125
261
|
>
|
|
126
262
|
✕
|
|
127
263
|
</Button>
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
<Box sx={{
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
<Input
|
|
141
|
-
type="time"
|
|
142
|
-
value={window.to}
|
|
143
|
-
onChange={(event) => onChange({ ...window, to: event.target.value })}
|
|
144
|
-
aria-label={copy.schedule.toLabel}
|
|
145
|
-
data-testid={`schedule-to-${index}`}
|
|
146
|
-
/>
|
|
264
|
+
</Box>
|
|
265
|
+
)}
|
|
266
|
+
<Box sx={equalParts(WEEK.length)} data-testid={`schedule-days-${index}`}>
|
|
267
|
+
{WEEK.map((day) => (
|
|
268
|
+
<DayChip
|
|
269
|
+
key={day}
|
|
270
|
+
label={copy.schedule.dayShort[day] ?? String(day)}
|
|
271
|
+
index={index}
|
|
272
|
+
selected={window.days.includes(day)}
|
|
273
|
+
onClick={() => onChange({ ...window, days: toggleDay(window.days, day) })}
|
|
274
|
+
/>
|
|
275
|
+
))}
|
|
147
276
|
</Box>
|
|
277
|
+
{message?.part === "days" && <ScheduleMessage text={message.text} />}
|
|
278
|
+
<WindowTimes
|
|
279
|
+
window={window}
|
|
280
|
+
index={index}
|
|
281
|
+
copy={copy}
|
|
282
|
+
submitted={clockSays}
|
|
283
|
+
onChange={onChange}
|
|
284
|
+
/>
|
|
285
|
+
{message?.part === "times" && !clockSays && <ScheduleMessage text={message.text} />}
|
|
148
286
|
{sentence !== null && (
|
|
149
287
|
<Text variant="caption" data-testid={`schedule-summary-${index}`}>
|
|
150
288
|
{sentence}
|
|
@@ -168,7 +306,7 @@ function Presets({
|
|
|
168
306
|
[copy.schedule.presetWeekend, WEEKEND_PRESET],
|
|
169
307
|
];
|
|
170
308
|
return (
|
|
171
|
-
<Box sx={
|
|
309
|
+
<Box sx={equalParts(presets.length, true)}>
|
|
172
310
|
{presets.map(([label, days]) => (
|
|
173
311
|
<Button
|
|
174
312
|
key={label}
|
|
@@ -178,7 +316,7 @@ function Presets({
|
|
|
178
316
|
onClick={() => onApply([...days])}
|
|
179
317
|
data-testid={`schedule-preset-${label}`}
|
|
180
318
|
>
|
|
181
|
-
{label}
|
|
319
|
+
<OneLine>{label}</OneLine>
|
|
182
320
|
</Button>
|
|
183
321
|
))}
|
|
184
322
|
</Box>
|
|
@@ -200,6 +338,7 @@ export function ScheduleBuilder({
|
|
|
200
338
|
onChange: (next: DiscountSchedule) => void;
|
|
201
339
|
}): JSX.Element {
|
|
202
340
|
const { windows } = schedule;
|
|
341
|
+
const spot = errorSpot(error, windows, copy);
|
|
203
342
|
const replace = (index: number, next: DiscountScheduleWindow): void =>
|
|
204
343
|
onChange({ windows: windows.map((w, i) => (i === index ? next : w)) });
|
|
205
344
|
|
|
@@ -227,6 +366,11 @@ export function ScheduleBuilder({
|
|
|
227
366
|
index={index}
|
|
228
367
|
copy={copy}
|
|
229
368
|
canRemove={windows.length > 1}
|
|
369
|
+
message={
|
|
370
|
+
spot !== null && spot.index === index && error != null
|
|
371
|
+
? { part: spot.part, text: error }
|
|
372
|
+
: null
|
|
373
|
+
}
|
|
230
374
|
onChange={(next) => replace(index, next)}
|
|
231
375
|
onRemove={() => onChange({ windows: windows.filter((_, i) => i !== index) })}
|
|
232
376
|
/>
|
|
@@ -246,7 +390,7 @@ export function ScheduleBuilder({
|
|
|
246
390
|
)}
|
|
247
391
|
<Text variant="caption">{fill(copy.schedule.timezoneNote, { timezone })}</Text>
|
|
248
392
|
<Text variant="caption">{copy.schedule.guaranteeNote}</Text>
|
|
249
|
-
{error != null && error !== "" && <
|
|
393
|
+
{spot === null && error != null && error !== "" && <ScheduleMessage text={error} />}
|
|
250
394
|
</Stack>
|
|
251
395
|
</FormControl>
|
|
252
396
|
);
|
|
@@ -70,5 +70,9 @@ export interface DiscountsScheduleCopy {
|
|
|
70
70
|
/** Refusals. */
|
|
71
71
|
readonly daysRequired: string;
|
|
72
72
|
readonly timesRequired: string;
|
|
73
|
+
/** Under a clock holding a whole `HH:MM` that no day has, such as 25:00. */
|
|
74
|
+
readonly invalidTime: string;
|
|
75
|
+
/** A clock left half-typed ("1:00"): shown once the box is left, or at Criar. */
|
|
76
|
+
readonly timeIncomplete: string;
|
|
73
77
|
readonly windowsRequired: string;
|
|
74
78
|
}
|