@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.
- package/README.md +23 -7
- package/package.json +6 -6
- package/src/react/api.ts +6 -2
- package/src/react/clock-input.tsx +107 -0
- package/src/react/combo-cart-preview.tsx +189 -0
- package/src/react/combo-preview-lines.ts +171 -0
- package/src/react/combo-preview.ts +217 -0
- package/src/react/combo-reward-copy.ts +59 -0
- package/src/react/combo-reward-fields.tsx +162 -0
- package/src/react/combo-slot-builder.tsx +73 -27
- package/src/react/copy.ts +44 -6
- package/src/react/date-field.tsx +141 -0
- package/src/react/discount-actions-menu.tsx +20 -26
- package/src/react/discount-dialog.tsx +67 -0
- package/src/react/discount-form-fields.tsx +47 -84
- package/src/react/discount-form-validate.ts +78 -17
- package/src/react/discount-form-values.ts +1 -1
- package/src/react/discount-form.tsx +68 -57
- package/src/react/discount-target-picker.tsx +1 -1
- package/src/react/discounts-screen.tsx +11 -17
- package/src/react/en-US.ts +43 -1
- package/src/react/form-kind.ts +42 -48
- package/src/react/free-units-builder.tsx +20 -13
- package/src/react/kind-field.tsx +71 -0
- package/src/react/number-fields.tsx +142 -0
- package/src/react/pt-BR.ts +44 -2
- package/src/react/schedule-builder.tsx +181 -37
- package/src/react/schedule-copy.ts +4 -0
- package/src/react/table-config.ts +15 -1
- package/src/server/collections.ts +20 -0
- package/src/server/index.ts +1 -0
|
@@ -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
|
}
|
|
@@ -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
|
-
{
|
|
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
|
/**
|