@12-apps/discounts 1.16.0 → 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
  }
@@ -54,9 +54,23 @@ function labelled(labels: Readonly<Record<string, string>>, value: string): stri
54
54
  return labels[value] ?? value;
55
55
  }
56
56
 
57
+ /** The Nome column's floor, in design px (the grid converts it through the type scale). */
58
+ const NAME_COLUMN_MIN_PX = 200;
59
+
57
60
  export function discountColumns(copy: DiscountsWebCopy): DataViewColumn<DiscountListItem>[] {
58
61
  return [
59
- { id: "name", header: copy.screen.columns.name, accessor: "name", searchable: true },
62
+ {
63
+ id: "name",
64
+ header: copy.screen.columns.name,
65
+ accessor: "name",
66
+ searchable: true,
67
+ // The column a row is read by: nine columns share the width, and with no
68
+ // floor the grid squeezed it until "Frete grátis na segunda" stood on
69
+ // three lines with "na" alone on one (ADR 0074 U1, FUT-4417). The floor
70
+ // holds a typical promotion name on one or two lines; the table scrolls
71
+ // sideways inside its own wrapper on a phone, as it already did.
72
+ minWidth: NAME_COLUMN_MIN_PX,
73
+ },
60
74
  // Pre-formatted by the page: a cell must not do arithmetic on basis points.
61
75
  { id: "value", header: copy.screen.columns.value, accessor: (row) => row.valueLabel },
62
76
  {
@@ -40,6 +40,26 @@ export interface DiscountTarget {
40
40
  * category path is built from the same edges. Absent or null at the root.
41
41
  */
42
42
  parentId?: string | null;
43
+ /**
44
+ * What a buyer can pay for one unit of this row, cheapest to dearest — its own
45
+ * price and its variations' (FUT-4417). Only an ITEM row carries it, and only
46
+ * so the combo form can say what a group's items sum to ("Como fica no
47
+ * carrinho"); nothing prices a cart from it. Absent: the form shows no
48
+ * preview for a group that can take this row alone.
49
+ */
50
+ priceRangeCents?: DiscountTargetPriceRange | null;
51
+ /**
52
+ * The category an ITEM row is filed under, for the same preview: a group that
53
+ * names a category takes every product filed under it or under its
54
+ * subcategories. Absent or null: uncategorized.
55
+ */
56
+ categoryId?: string | null;
57
+ }
58
+
59
+ /** The cheapest and dearest unit price of one target, in integer cents. */
60
+ export interface DiscountTargetPriceRange {
61
+ minCents: number;
62
+ maxCents: number;
43
63
  }
44
64
 
45
65
  /**
@@ -28,6 +28,7 @@ export {
28
28
  type DiscountableCollection,
29
29
  type DiscountableOps,
30
30
  type DiscountTarget,
31
+ type DiscountTargetPriceRange,
31
32
  type DiscountTargetGroup,
32
33
  } from "./collections";
33
34