@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.
@@ -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
+ }
@@ -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: "Fim de semana",
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} itens em {groups} grupos.",
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 { MAX_SCHEDULE_WINDOWS, type DiscountSchedule, type DiscountScheduleWindow } from "../engine/schedule";
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
- <Box sx={{ display: "flex", gap: 0.5, flexWrap: "wrap", alignItems: "center" }}>
108
- {WEEK.map((day) => (
109
- <DayChip
110
- key={day}
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
- </Box>
130
- <Box sx={{ display: "flex", gap: 1, alignItems: "center", flexWrap: "wrap" }}>
131
- <Text variant="caption">{copy.schedule.fromLabel}</Text>
132
- <Input
133
- type="time"
134
- value={window.from}
135
- onChange={(event) => onChange({ ...window, from: event.target.value })}
136
- aria-label={copy.schedule.fromLabel}
137
- data-testid={`schedule-from-${index}`}
138
- />
139
- <Text variant="caption">{copy.schedule.toLabel}</Text>
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={{ display: "flex", gap: 0.5, flexWrap: "wrap" }}>
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 !== "" && <FormMessage error>{error}</FormMessage>}
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
  }