@12-apps/discounts 1.11.0 → 1.13.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/dist/e2e/globs.d.ts +40 -0
- package/dist/e2e/globs.d.ts.map +1 -0
- package/dist/e2e/globs.js +66 -0
- package/dist/e2e/globs.js.map +1 -0
- package/dist/e2e/index.d.ts +12 -0
- package/dist/e2e/index.d.ts.map +1 -0
- package/dist/e2e/index.js +12 -0
- package/dist/e2e/index.js.map +1 -0
- package/dist/e2e/steps/journey.steps.d.ts +2 -0
- package/dist/e2e/steps/journey.steps.d.ts.map +1 -0
- package/dist/e2e/steps/journey.steps.js +146 -0
- package/dist/e2e/steps/journey.steps.js.map +1 -0
- package/dist/e2e/world.d.ts +76 -0
- package/dist/e2e/world.d.ts.map +1 -0
- package/dist/e2e/world.js +25 -0
- package/dist/e2e/world.js.map +1 -0
- package/features/promotions.feature +52 -0
- package/package.json +28 -9
- package/prisma/discounts.prisma +23 -0
- package/prisma/migrations/20260830120000_discount_weekly_schedule/migration.sql +40 -0
- package/src/e2e/globs.ts +70 -0
- package/src/e2e/index.ts +16 -0
- package/src/e2e/steps/journey.steps.ts +170 -0
- package/src/e2e/world.ts +93 -0
- package/src/engine/combo-offer.ts +21 -3
- package/src/engine/eligibility.ts +76 -0
- package/src/engine/en-US.ts +1 -0
- package/src/engine/evaluate.ts +8 -2
- package/src/engine/kinds.ts +7 -0
- package/src/engine/preview.ts +32 -6
- package/src/engine/pt-BR.ts +8 -0
- package/src/engine/schedule.ts +191 -0
- package/src/engine/teaser.ts +150 -0
- package/src/engine/types.ts +36 -0
- package/src/index.ts +32 -0
- package/src/manifest/index.ts +12 -0
- package/src/react/api.ts +13 -0
- package/src/react/copy.ts +3 -0
- package/src/react/create-web-discounts.tsx +39 -1
- package/src/react/discount-actions-menu.tsx +10 -0
- package/src/react/discount-form-fields.tsx +90 -6
- package/src/react/discount-form-validate.ts +40 -0
- package/src/react/discount-form.tsx +38 -2
- package/src/react/discounts-screen.tsx +12 -28
- package/src/react/en-US.ts +44 -0
- package/src/react/format.ts +15 -0
- package/src/react/pt-BR.ts +44 -0
- package/src/react/row.ts +64 -0
- package/src/react/schedule-builder.tsx +253 -0
- package/src/react/schedule-copy.ts +74 -0
- package/src/react/schedule-summary.ts +100 -0
- package/src/react/screen-bindings.ts +57 -0
- package/src/react/table-config.ts +15 -0
- package/src/server/copy.ts +3 -0
- package/src/server/en-US.ts +1 -0
- package/src/server/mcp.ts +32 -0
- package/src/server/pt-BR.ts +1 -0
- package/src/server/store.ts +8 -0
- package/src/server/validate.ts +43 -0
- package/src/timezone.ts +96 -0
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { JSX } from "react";
|
|
4
|
+
|
|
5
|
+
import { Button } from "@12-apps/ui/form/Button";
|
|
6
|
+
import { FormControl, FormLabel, FormMessage } from "@12-apps/ui/form/Form";
|
|
7
|
+
import { Input } from "@12-apps/ui/form/Input";
|
|
8
|
+
import { Box } from "@12-apps/ui/mui/Box";
|
|
9
|
+
import { Stack } from "@12-apps/ui/mui/Stack";
|
|
10
|
+
import { Text } from "@12-apps/ui/typography/Text";
|
|
11
|
+
|
|
12
|
+
import { MAX_SCHEDULE_WINDOWS, type DiscountSchedule, type DiscountScheduleWindow } from "../engine/schedule";
|
|
13
|
+
|
|
14
|
+
import { fill, type DiscountsWebCopy } from "./copy";
|
|
15
|
+
import { formatScheduleWindow } from "./schedule-summary";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* "Toda sexta, das 16:00 às 20:00" — the weekly schedule editor (FUT-996).
|
|
19
|
+
*
|
|
20
|
+
* The shape is chosen from how a merchant DESCRIBES a promotion, not from how
|
|
21
|
+
* a week is stored. Three decisions carry it:
|
|
22
|
+
*
|
|
23
|
+
* **Day chips, not checkboxes.** Seven chips in a row read as a week at a
|
|
24
|
+
* glance; seven checkboxes read as a list you have to parse. Monday-first,
|
|
25
|
+
* because that is the axis `WeekHours` and every hours screen already uses, and
|
|
26
|
+
* a promotions screen starting on Sunday next to an hours screen starting on
|
|
27
|
+
* Monday is its own bug report.
|
|
28
|
+
*
|
|
29
|
+
* **A LIST of rows, each with its own days and hours** — not a per-weekday
|
|
30
|
+
* hours grid. One row covers "toda sexta das 16 às 20" and "segunda e terça à
|
|
31
|
+
* tarde" alike; a second covers "sexta 16–20 e sábado 12–16". The grid shape
|
|
32
|
+
* (what `openingHours` uses) asks seven questions to answer one, and a
|
|
33
|
+
* promotion is a sentence, not a business week.
|
|
34
|
+
*
|
|
35
|
+
* **A live read-back.** See `./schedule-summary` — it is the only thing on this
|
|
36
|
+
* screen the operator can check against what they meant.
|
|
37
|
+
*
|
|
38
|
+
* Presets WRITE INTO the chips rather than being a mode: after "Seg a Sex" the
|
|
39
|
+
* operator can still deselect Wednesday, which is what makes them a shortcut
|
|
40
|
+
* instead of a fourth thing to understand.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
/** Monday-first, 0..6 — the axis the engine and every hours surface share. */
|
|
44
|
+
const WEEK = [0, 1, 2, 3, 4, 5, 6];
|
|
45
|
+
const WEEKDAY_PRESET = [0, 1, 2, 3, 4];
|
|
46
|
+
const WEEKEND_PRESET = [5, 6];
|
|
47
|
+
|
|
48
|
+
/** A new row: the most common promotion, so an operator edits rather than fills. */
|
|
49
|
+
export function blankWindow(): DiscountScheduleWindow {
|
|
50
|
+
return { days: [], from: "16:00", to: "20:00" };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function toggleDay(days: readonly number[], day: number): number[] {
|
|
54
|
+
return days.includes(day) ? days.filter((d) => d !== day) : [...days, day].sort((a, b) => a - b);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** One day chip. A button rather than a checkbox — see the header. */
|
|
58
|
+
function DayChip({
|
|
59
|
+
label,
|
|
60
|
+
index,
|
|
61
|
+
selected,
|
|
62
|
+
onClick,
|
|
63
|
+
}: {
|
|
64
|
+
label: string;
|
|
65
|
+
/** The ROW this chip belongs to — test ids must be unique across rows. */
|
|
66
|
+
index: number;
|
|
67
|
+
selected: boolean;
|
|
68
|
+
onClick: () => void;
|
|
69
|
+
}): JSX.Element {
|
|
70
|
+
return (
|
|
71
|
+
<Button
|
|
72
|
+
type="button"
|
|
73
|
+
variant={selected ? "solid" : "outline"}
|
|
74
|
+
size="sm"
|
|
75
|
+
onClick={onClick}
|
|
76
|
+
aria-pressed={selected}
|
|
77
|
+
data-testid={`schedule-day-${index}-${label}`}
|
|
78
|
+
>
|
|
79
|
+
{label}
|
|
80
|
+
</Button>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** One "these days, these hours" row. */
|
|
85
|
+
function WindowRow({
|
|
86
|
+
window,
|
|
87
|
+
index,
|
|
88
|
+
copy,
|
|
89
|
+
canRemove,
|
|
90
|
+
onChange,
|
|
91
|
+
onRemove,
|
|
92
|
+
}: {
|
|
93
|
+
window: DiscountScheduleWindow;
|
|
94
|
+
index: number;
|
|
95
|
+
copy: DiscountsWebCopy;
|
|
96
|
+
canRemove: boolean;
|
|
97
|
+
onChange: (next: DiscountScheduleWindow) => void;
|
|
98
|
+
onRemove: () => void;
|
|
99
|
+
}): JSX.Element {
|
|
100
|
+
const sentence = formatScheduleWindow(window, copy);
|
|
101
|
+
return (
|
|
102
|
+
<Stack
|
|
103
|
+
spacing={1}
|
|
104
|
+
sx={{ border: 1, borderColor: "divider", borderRadius: 1, p: 1.5 }}
|
|
105
|
+
data-testid={`schedule-window-${index}`}
|
|
106
|
+
>
|
|
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 && (
|
|
118
|
+
<Button
|
|
119
|
+
type="button"
|
|
120
|
+
variant="ghost"
|
|
121
|
+
size="sm"
|
|
122
|
+
onClick={onRemove}
|
|
123
|
+
aria-label={copy.schedule.removeWindow}
|
|
124
|
+
data-testid={`schedule-remove-${index}`}
|
|
125
|
+
>
|
|
126
|
+
✕
|
|
127
|
+
</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
|
+
/>
|
|
147
|
+
</Box>
|
|
148
|
+
{sentence !== null && (
|
|
149
|
+
<Text variant="caption" data-testid={`schedule-summary-${index}`}>
|
|
150
|
+
{sentence}
|
|
151
|
+
</Text>
|
|
152
|
+
)}
|
|
153
|
+
</Stack>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The three shortcuts. They write into the chips; they are not a mode. */
|
|
158
|
+
function Presets({
|
|
159
|
+
copy,
|
|
160
|
+
onApply,
|
|
161
|
+
}: {
|
|
162
|
+
copy: DiscountsWebCopy;
|
|
163
|
+
onApply: (days: number[]) => void;
|
|
164
|
+
}): JSX.Element {
|
|
165
|
+
const presets: [string, number[]][] = [
|
|
166
|
+
[copy.schedule.presetEveryDay, WEEK],
|
|
167
|
+
[copy.schedule.presetWeekdays, WEEKDAY_PRESET],
|
|
168
|
+
[copy.schedule.presetWeekend, WEEKEND_PRESET],
|
|
169
|
+
];
|
|
170
|
+
return (
|
|
171
|
+
<Box sx={{ display: "flex", gap: 0.5, flexWrap: "wrap" }}>
|
|
172
|
+
{presets.map(([label, days]) => (
|
|
173
|
+
<Button
|
|
174
|
+
key={label}
|
|
175
|
+
type="button"
|
|
176
|
+
variant="ghost"
|
|
177
|
+
size="sm"
|
|
178
|
+
onClick={() => onApply([...days])}
|
|
179
|
+
data-testid={`schedule-preset-${label}`}
|
|
180
|
+
>
|
|
181
|
+
{label}
|
|
182
|
+
</Button>
|
|
183
|
+
))}
|
|
184
|
+
</Box>
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function ScheduleBuilder({
|
|
189
|
+
schedule,
|
|
190
|
+
copy,
|
|
191
|
+
timezone,
|
|
192
|
+
error,
|
|
193
|
+
onChange,
|
|
194
|
+
}: {
|
|
195
|
+
schedule: DiscountSchedule;
|
|
196
|
+
copy: DiscountsWebCopy;
|
|
197
|
+
/** Whose clock these times are in — a merchant setting 16:00 must know. */
|
|
198
|
+
timezone: string;
|
|
199
|
+
error?: string | null;
|
|
200
|
+
onChange: (next: DiscountSchedule) => void;
|
|
201
|
+
}): JSX.Element {
|
|
202
|
+
const { windows } = schedule;
|
|
203
|
+
const replace = (index: number, next: DiscountScheduleWindow): void =>
|
|
204
|
+
onChange({ windows: windows.map((w, i) => (i === index ? next : w)) });
|
|
205
|
+
|
|
206
|
+
return (
|
|
207
|
+
<FormControl>
|
|
208
|
+
<FormLabel>{copy.schedule.builderTitle}</FormLabel>
|
|
209
|
+
<Stack spacing={1} data-testid="schedule-builder">
|
|
210
|
+
<Presets
|
|
211
|
+
copy={copy}
|
|
212
|
+
onApply={(days) =>
|
|
213
|
+
onChange({
|
|
214
|
+
windows:
|
|
215
|
+
windows.length === 0
|
|
216
|
+
? [{ ...blankWindow(), days }]
|
|
217
|
+
: windows.map((w, i) => (i === 0 ? { ...w, days } : w)),
|
|
218
|
+
})
|
|
219
|
+
}
|
|
220
|
+
/>
|
|
221
|
+
{windows.map((window, index) => (
|
|
222
|
+
<WindowRow
|
|
223
|
+
// Index is the identity here: rows have no id, and the list is
|
|
224
|
+
// edited by position — which is also the order the sentence reads.
|
|
225
|
+
key={index}
|
|
226
|
+
window={window}
|
|
227
|
+
index={index}
|
|
228
|
+
copy={copy}
|
|
229
|
+
canRemove={windows.length > 1}
|
|
230
|
+
onChange={(next) => replace(index, next)}
|
|
231
|
+
onRemove={() => onChange({ windows: windows.filter((_, i) => i !== index) })}
|
|
232
|
+
/>
|
|
233
|
+
))}
|
|
234
|
+
{windows.length < MAX_SCHEDULE_WINDOWS && (
|
|
235
|
+
<Box>
|
|
236
|
+
<Button
|
|
237
|
+
type="button"
|
|
238
|
+
variant="outline"
|
|
239
|
+
size="sm"
|
|
240
|
+
onClick={() => onChange({ windows: [...windows, blankWindow()] })}
|
|
241
|
+
data-testid="schedule-add-window"
|
|
242
|
+
>
|
|
243
|
+
{copy.schedule.addWindow}
|
|
244
|
+
</Button>
|
|
245
|
+
</Box>
|
|
246
|
+
)}
|
|
247
|
+
<Text variant="caption">{fill(copy.schedule.timezoneNote, { timezone })}</Text>
|
|
248
|
+
<Text variant="caption">{copy.schedule.guaranteeNote}</Text>
|
|
249
|
+
{error != null && error !== "" && <FormMessage error>{error}</FormMessage>}
|
|
250
|
+
</Stack>
|
|
251
|
+
</FormControl>
|
|
252
|
+
);
|
|
253
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The weekly schedule editor and the sentence it reads back (FUT-996).
|
|
3
|
+
*
|
|
4
|
+
* The read-back is the highest-value thing on this screen — a grid of chips and
|
|
5
|
+
* two clocks is data, and "Toda sexta, das 16:00 às 20:00" is the promise the
|
|
6
|
+
* operator is actually making. It follows the free-units builder's rule: the
|
|
7
|
+
* sentence appears only once the values make a real offer, so it is never
|
|
8
|
+
* confirmation of something that cannot be saved.
|
|
9
|
+
*
|
|
10
|
+
* Day names are three separate records because Portuguese needs them to be.
|
|
11
|
+
* `dayShort` is a chip ("Seg"), `dayLong` is a list item ("segunda"), and
|
|
12
|
+
* `dayEvery` is a sentence opener whose article follows the day's GENDER
|
|
13
|
+
* ("Toda segunda", "Todo sábado"). Deriving the third from the second would
|
|
14
|
+
* mean encoding Portuguese grammar in a package that must not know which
|
|
15
|
+
* language it is rendering.
|
|
16
|
+
*/
|
|
17
|
+
export interface DiscountsScheduleCopy {
|
|
18
|
+
/** Section heading over the period and the repetition. */
|
|
19
|
+
readonly sectionTitle: string;
|
|
20
|
+
readonly periodTitle: string;
|
|
21
|
+
readonly periodHint: string;
|
|
22
|
+
readonly repetitionTitle: string;
|
|
23
|
+
/** The two repetition choices. */
|
|
24
|
+
readonly always: string;
|
|
25
|
+
readonly specific: string;
|
|
26
|
+
/** The builder. */
|
|
27
|
+
readonly builderTitle: string;
|
|
28
|
+
readonly addWindow: string;
|
|
29
|
+
readonly removeWindow: string;
|
|
30
|
+
readonly fromLabel: string;
|
|
31
|
+
readonly toLabel: string;
|
|
32
|
+
/** Preset buttons that write into the chips rather than replacing them. */
|
|
33
|
+
readonly presetEveryDay: string;
|
|
34
|
+
readonly presetWeekdays: string;
|
|
35
|
+
readonly presetWeekend: string;
|
|
36
|
+
/** Monday-first, 0..6. */
|
|
37
|
+
readonly dayShort: readonly string[];
|
|
38
|
+
readonly dayLong: readonly string[];
|
|
39
|
+
readonly dayEvery: readonly string[];
|
|
40
|
+
/** Joining a day list: "segunda, terça e quarta". */
|
|
41
|
+
readonly listSeparator: string;
|
|
42
|
+
readonly listLast: string;
|
|
43
|
+
/** Collapsed day sets, so a common week reads as a phrase not a list. */
|
|
44
|
+
readonly allDays: string;
|
|
45
|
+
readonly weekdays: string;
|
|
46
|
+
readonly weekend: string;
|
|
47
|
+
/** The read-back. `{days}`, `{from}`, `{to}`. */
|
|
48
|
+
readonly summary: string;
|
|
49
|
+
/** The same, when the window runs past midnight. */
|
|
50
|
+
readonly summaryOvernight: string;
|
|
51
|
+
/** Whose clock these times are in. `{timezone}`. */
|
|
52
|
+
readonly timezoneNote: string;
|
|
53
|
+
/**
|
|
54
|
+
* What the shopper is promised — stated where the merchant promises it.
|
|
55
|
+
* FUT-996 decision 2: the price follows the moment the item entered the cart.
|
|
56
|
+
*/
|
|
57
|
+
readonly guaranteeNote: string;
|
|
58
|
+
/**
|
|
59
|
+
* The ORDER-scope asymmetry, shown ONLY for that combination because it is
|
|
60
|
+
* otherwise invisible: an order-wide rule has no line to anchor to, so it is
|
|
61
|
+
* judged at checkout while every other scope is judged per line.
|
|
62
|
+
*/
|
|
63
|
+
readonly orderScopeNote: string;
|
|
64
|
+
/** Grid: the campaign window and the schedule in one cell. `{window}`, `{schedule}`. */
|
|
65
|
+
readonly windowWithSchedule: string;
|
|
66
|
+
/** Grid: a compact schedule, plus "+N" when there are more rows. `{count}`. */
|
|
67
|
+
readonly moreWindows: string;
|
|
68
|
+
/** Grid + card: this rule's hours are running at this moment. */
|
|
69
|
+
readonly activeNow: string;
|
|
70
|
+
/** Refusals. */
|
|
71
|
+
readonly daysRequired: string;
|
|
72
|
+
readonly timesRequired: string;
|
|
73
|
+
readonly windowsRequired: string;
|
|
74
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { DiscountSchedule, DiscountScheduleWindow } from "../engine/schedule";
|
|
2
|
+
import { toMinutes } from "../engine/schedule";
|
|
3
|
+
|
|
4
|
+
import { fill, type DiscountsWebCopy } from "./copy";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The read-back sentence: "Toda sexta, das 16:00 às 20:00." (FUT-996)
|
|
8
|
+
*
|
|
9
|
+
* The highest-value thing on the schedule editor. A row of day chips and two
|
|
10
|
+
* clocks is DATA; this is the promise the operator is actually making, in the
|
|
11
|
+
* words they would use to describe it out loud, and it is the only thing on the
|
|
12
|
+
* screen they can check against what they meant.
|
|
13
|
+
*
|
|
14
|
+
* It follows `summaryOf` in the free-units builder exactly: **nothing is
|
|
15
|
+
* rendered until the values make a real offer.** A sentence over a half-built
|
|
16
|
+
* window would read as confirmation of something the validator is about to
|
|
17
|
+
* refuse, which is worse than no sentence at all.
|
|
18
|
+
*
|
|
19
|
+
* Its own module because it is pure string assembly the builder, the grid cell
|
|
20
|
+
* and the card all want, and a copy of it in each is how three surfaces start
|
|
21
|
+
* describing one promotion three ways.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const ALL_DAYS = [0, 1, 2, 3, 4, 5, 6];
|
|
25
|
+
const WEEKDAYS = [0, 1, 2, 3, 4];
|
|
26
|
+
const WEEKEND = [5, 6];
|
|
27
|
+
|
|
28
|
+
function sameDays(days: readonly number[], expected: readonly number[]): boolean {
|
|
29
|
+
const unique = [...new Set(days)].sort((a, b) => a - b);
|
|
30
|
+
return unique.length === expected.length && unique.every((day, i) => day === expected[i]);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The day set as a phrase.
|
|
35
|
+
*
|
|
36
|
+
* Collapsed sets come first so a common week reads as a phrase rather than a
|
|
37
|
+
* list — "De segunda a sexta" beats "segunda, terça, quarta, quinta e sexta",
|
|
38
|
+
* which is the same fact spelled at four times the length.
|
|
39
|
+
*
|
|
40
|
+
* A SINGLE day uses the pack's `dayEvery` form ("Toda sexta", "Todo sábado")
|
|
41
|
+
* rather than the bare name, because that is how a merchant says it. The
|
|
42
|
+
* article follows the day's gender in Portuguese, which is why the pack carries
|
|
43
|
+
* all seven rather than the code prefixing a word.
|
|
44
|
+
*/
|
|
45
|
+
function formatDays(days: readonly number[], copy: DiscountsWebCopy): string | null {
|
|
46
|
+
const unique = [...new Set(days)].sort((a, b) => a - b);
|
|
47
|
+
if (unique.length === 0) return null;
|
|
48
|
+
if (unique.some((day) => day < 0 || day > 6)) return null;
|
|
49
|
+
const { schedule } = copy;
|
|
50
|
+
if (sameDays(unique, ALL_DAYS)) return schedule.allDays;
|
|
51
|
+
if (sameDays(unique, WEEKDAYS)) return schedule.weekdays;
|
|
52
|
+
if (sameDays(unique, WEEKEND)) return schedule.weekend;
|
|
53
|
+
if (unique.length === 1) return schedule.dayEvery[unique[0] as number] ?? null;
|
|
54
|
+
const names = unique.map((day) => schedule.dayLong[day] ?? String(day));
|
|
55
|
+
const last = names[names.length - 1] as string;
|
|
56
|
+
return names.slice(0, -1).join(schedule.listSeparator) + schedule.listLast + last;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* One window as a sentence, or null when it is not yet an offer.
|
|
61
|
+
*
|
|
62
|
+
* An overnight window gets its OWN sentence rather than the same one, because
|
|
63
|
+
* "das 22:00 às 02:00" is ambiguous on a screen and unambiguous only once it
|
|
64
|
+
* says which day the 02:00 belongs to. An operator who typed it by mistake sees
|
|
65
|
+
* it; one who meant it gets confirmation.
|
|
66
|
+
*/
|
|
67
|
+
export function formatScheduleWindow(
|
|
68
|
+
window: DiscountScheduleWindow,
|
|
69
|
+
copy: DiscountsWebCopy,
|
|
70
|
+
): string | null {
|
|
71
|
+
const days = formatDays(window.days, copy);
|
|
72
|
+
const from = toMinutes(window.from);
|
|
73
|
+
const to = toMinutes(window.to);
|
|
74
|
+
if (days === null || from === null || to === null || from === to) return null;
|
|
75
|
+
const template = to < from ? copy.schedule.summaryOvernight : copy.schedule.summary;
|
|
76
|
+
return fill(template, { days, from: window.from, to: window.to });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The COMPACT form for a grid cell: "sex 16:00–20:00", plus "+N" when the rule
|
|
81
|
+
* has more windows than the column can show.
|
|
82
|
+
*
|
|
83
|
+
* Short day names rather than the sentence, because a table cell is read in a
|
|
84
|
+
* column of forty and the sentence is read once, deliberately, in a form.
|
|
85
|
+
*/
|
|
86
|
+
export function formatScheduleCell(
|
|
87
|
+
schedule: DiscountSchedule | null | undefined,
|
|
88
|
+
copy: DiscountsWebCopy,
|
|
89
|
+
): string | null {
|
|
90
|
+
if (schedule === null || schedule === undefined) return null;
|
|
91
|
+
const [first, ...rest] = schedule.windows;
|
|
92
|
+
if (first === undefined) return null;
|
|
93
|
+
const days = [...new Set(first.days)]
|
|
94
|
+
.sort((a, b) => a - b)
|
|
95
|
+
.map((day) => copy.schedule.dayShort[day] ?? String(day))
|
|
96
|
+
.join(" ");
|
|
97
|
+
const head = `${days} ${first.from}–${first.to}`;
|
|
98
|
+
if (rest.length === 0) return head;
|
|
99
|
+
return `${head} ${fill(copy.schedule.moreWindows, { count: rest.length })}`;
|
|
100
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useServerDataViews } from "@12-apps/app-shell/react";
|
|
4
|
+
|
|
5
|
+
import type { DiscountsPagination, WireTargetGroup } from "./api";
|
|
6
|
+
import type { MenuBinding, DiscountsScreenProps } from "./discounts-screen";
|
|
7
|
+
import { discountsQueryToParams } from "./table-config";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The two bindings {@link DiscountsScreen} hands down — pagination and the row
|
|
11
|
+
* menu — lifted out of it.
|
|
12
|
+
*
|
|
13
|
+
* Neither is a second concern; they live here because that component is at both
|
|
14
|
+
* the size and the complexity ceilings, and these are the parts of it that are
|
|
15
|
+
* pure plumbing rather than layout.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The grid's server-side pagination binding, with the defaults a page that has
|
|
20
|
+
* not loaded yet needs.
|
|
21
|
+
*
|
|
22
|
+
* Its own hook so the three `??` fallbacks are not branches inside
|
|
23
|
+
* {@link DiscountsScreen}, which sits at the complexity ceiling.
|
|
24
|
+
*/
|
|
25
|
+
export function usePagedServer(
|
|
26
|
+
pagination: DiscountsPagination | undefined,
|
|
27
|
+
): ReturnType<typeof useServerDataViews> {
|
|
28
|
+
return useServerDataViews({
|
|
29
|
+
totalCount: pagination?.total ?? 0,
|
|
30
|
+
page: pagination?.page ?? 1,
|
|
31
|
+
pageSize: pagination?.pageSize ?? 20,
|
|
32
|
+
toParams: discountsQueryToParams,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The binding every row menu, tile and card receives — built once so an edit
|
|
38
|
+
* opened from any of the three offers the same pickers.
|
|
39
|
+
*
|
|
40
|
+
* A helper rather than an inline literal: the optional `timezoneLabel` is a
|
|
41
|
+
* conditional spread, and {@link DiscountsScreen} is at the complexity ceiling.
|
|
42
|
+
*/
|
|
43
|
+
export function toMenuBinding(
|
|
44
|
+
props: DiscountsScreenProps,
|
|
45
|
+
groups: readonly WireTargetGroup[] | undefined,
|
|
46
|
+
): MenuBinding {
|
|
47
|
+
const { api, copy, formatters, currencyField, onError, timezoneLabel } = props;
|
|
48
|
+
return {
|
|
49
|
+
api,
|
|
50
|
+
copy,
|
|
51
|
+
formatters,
|
|
52
|
+
currencyField,
|
|
53
|
+
groups,
|
|
54
|
+
onError,
|
|
55
|
+
...(timezoneLabel ? { timezoneLabel } : {}),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -76,6 +76,21 @@ export function discountColumns(copy: DiscountsWebCopy): DataViewColumn<Discount
|
|
|
76
76
|
searchable: true,
|
|
77
77
|
},
|
|
78
78
|
{ id: "window", header: copy.screen.columns.window, accessor: (row) => row.windowLabel },
|
|
79
|
+
{
|
|
80
|
+
// The live answer to "is my happy hour on right now?" (FUT-996).
|
|
81
|
+
//
|
|
82
|
+
// Its own column rather than a widening of the vigência pill beside it:
|
|
83
|
+
// those three values are resolved by a store-side SQL predicate, and a
|
|
84
|
+
// recurrence predicate is not expressible there. This one is computed in
|
|
85
|
+
// the browser from the store's timezone, so it can be SHOWN but not
|
|
86
|
+
// FILTERED — and a cell is honest about that in a way a pill would not be.
|
|
87
|
+
//
|
|
88
|
+
// Blank, not "Não", for a rule with no schedule: there is nothing
|
|
89
|
+
// intermittent to report, and a column of "Não" would read as a fault.
|
|
90
|
+
id: "activeNow",
|
|
91
|
+
header: copy.schedule.activeNow,
|
|
92
|
+
accessor: (row) => (row.activeNow ? copy.schedule.activeNow : EMPTY),
|
|
93
|
+
},
|
|
79
94
|
{
|
|
80
95
|
id: "usageCount",
|
|
81
96
|
header: copy.screen.columns.usageCount,
|
package/src/server/copy.ts
CHANGED
|
@@ -34,6 +34,8 @@ export interface DiscountsServerCopy {
|
|
|
34
34
|
readonly invalidDate: string;
|
|
35
35
|
/** 422 — the window closes at or before it opens, so it is never open. */
|
|
36
36
|
readonly endsBeforeStarts: string;
|
|
37
|
+
/** The weekly schedule is not one the engine can act on (FUT-996). */
|
|
38
|
+
readonly invalidSchedule: string;
|
|
37
39
|
/** 422 — a minimum-basket threshold of zero or less. */
|
|
38
40
|
readonly invalidMinSubtotal: string;
|
|
39
41
|
/** 422 — a global redemption cap of zero or less. */
|
|
@@ -79,6 +81,7 @@ const COPY_KEYS: readonly (keyof DiscountsServerCopy)[] = [
|
|
|
79
81
|
"itemTargetRequired",
|
|
80
82
|
"invalidDate",
|
|
81
83
|
"endsBeforeStarts",
|
|
84
|
+
"invalidSchedule",
|
|
82
85
|
"invalidMinSubtotal",
|
|
83
86
|
"invalidUsageLimit",
|
|
84
87
|
"invalidPerBuyerLimit",
|
package/src/server/en-US.ts
CHANGED
|
@@ -23,6 +23,7 @@ export const EN_US_DISCOUNTS_SERVER_COPY: DiscountsServerCopy = {
|
|
|
23
23
|
// would send an operator to type something the endpoint rejects.
|
|
24
24
|
invalidDate: "Invalid date. Use the format YYYY-MM-DD.",
|
|
25
25
|
endsBeforeStarts: "The end date must be after the start date.",
|
|
26
|
+
invalidSchedule: "Invalid promotion schedule. Pick at least one day, and a start and end time that differ.",
|
|
26
27
|
invalidMinSubtotal: "The minimum order must be greater than zero (or left blank).",
|
|
27
28
|
invalidUsageLimit: "The usage limit must be greater than zero (or left blank).",
|
|
28
29
|
invalidPerBuyerLimit: "The per-customer limit must be greater than zero (or left blank).",
|
package/src/server/mcp.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
MAX_COMBO_SLOT_QUANTITY,
|
|
18
18
|
MAX_PERCENT_OFF_BP,
|
|
19
19
|
} from "../engine/kinds";
|
|
20
|
+
import { MAX_SCHEDULE_WINDOWS } from "../engine/schedule";
|
|
20
21
|
import { discountSearchConfig } from "./search";
|
|
21
22
|
|
|
22
23
|
/**
|
|
@@ -66,6 +67,35 @@ export const comboRequirementSchema = z.object({
|
|
|
66
67
|
quantity: z.number().int().min(1).max(MAX_COMBO_SLOT_QUANTITY),
|
|
67
68
|
});
|
|
68
69
|
|
|
70
|
+
/**
|
|
71
|
+
* A `HH:MM` clock time, 24-hour — the schedule's own wire format (FUT-996).
|
|
72
|
+
*
|
|
73
|
+
* A string rather than minutes-since-midnight because it is what an operator
|
|
74
|
+
* typed and what a card reads back; converting at the edge would mean two
|
|
75
|
+
* representations of one fact and a conversion to get wrong in each direction.
|
|
76
|
+
*/
|
|
77
|
+
const clockTimeSchema = z
|
|
78
|
+
.string()
|
|
79
|
+
.regex(/^([01]\d|2[0-3]):[0-5]\d$/, "Use HH:MM, 24-hour");
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* One "these days, these hours" row. `days` is **Monday-first, 0..6** — the
|
|
83
|
+
* same axis every hours surface uses, and NOT `Date#getDay()`'s Sunday-first.
|
|
84
|
+
*
|
|
85
|
+
* `to` earlier than `from` is legal and means the window runs past midnight: a
|
|
86
|
+
* bar shutting at 02:00 is the most common happy hour there is.
|
|
87
|
+
*/
|
|
88
|
+
const scheduleWindowSchema = z.object({
|
|
89
|
+
days: z.array(z.number().int().min(0).max(6)).min(1).max(7),
|
|
90
|
+
from: clockTimeSchema,
|
|
91
|
+
to: clockTimeSchema,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
/** The weekly schedule. Null = always, within the campaign window. */
|
|
95
|
+
const discountScheduleSchema = z.object({
|
|
96
|
+
windows: z.array(scheduleWindowSchema).min(1).max(MAX_SCHEDULE_WINDOWS),
|
|
97
|
+
});
|
|
98
|
+
|
|
69
99
|
/**
|
|
70
100
|
* A discount as the API returns it (JSON-serialized: dates become ISO
|
|
71
101
|
* strings). The two join tables are flattened into id arrays.
|
|
@@ -93,6 +123,7 @@ export const discountSchema = z.object({
|
|
|
93
123
|
code: z.string().nullable(),
|
|
94
124
|
startsAt: z.string().nullable(),
|
|
95
125
|
endsAt: z.string().nullable(),
|
|
126
|
+
schedule: discountScheduleSchema.nullable().optional(),
|
|
96
127
|
minSubtotalCents: z.number().int().nullable(),
|
|
97
128
|
usageLimit: z.number().int().nullable(),
|
|
98
129
|
perBuyerLimit: z.number().int().nullable(),
|
|
@@ -150,6 +181,7 @@ const discountWriteShape = {
|
|
|
150
181
|
code: z.string().min(1).max(64).nullable().optional(),
|
|
151
182
|
startsAt: calendarDateSchema.nullable().optional(),
|
|
152
183
|
endsAt: calendarDateSchema.nullable().optional(),
|
|
184
|
+
schedule: discountScheduleSchema.nullable().optional(),
|
|
153
185
|
minSubtotalCents: z.number().int().positive().nullable().optional(),
|
|
154
186
|
usageLimit: z.number().int().positive().nullable().optional(),
|
|
155
187
|
perBuyerLimit: z.number().int().positive().nullable().optional(),
|
package/src/server/pt-BR.ts
CHANGED
|
@@ -20,6 +20,7 @@ export const PT_BR_DISCOUNTS_SERVER_COPY: DiscountsServerCopy = {
|
|
|
20
20
|
itemTargetRequired: "Selecione ao menos um produto para este desconto.",
|
|
21
21
|
invalidDate: "Data inválida. Use o formato AAAA-MM-DD.",
|
|
22
22
|
endsBeforeStarts: "A data de término deve ser posterior à data de início.",
|
|
23
|
+
invalidSchedule: "Horários da promoção inválidos. Escolha ao menos um dia e um horário de início e fim diferentes.",
|
|
23
24
|
invalidMinSubtotal: "O pedido mínimo deve ser maior que zero (ou deixe em branco).",
|
|
24
25
|
invalidUsageLimit: "O limite de usos deve ser maior que zero (ou deixe em branco).",
|
|
25
26
|
invalidPerBuyerLimit: "O limite por cliente deve ser maior que zero (ou deixe em branco).",
|
package/src/server/store.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { PaginatedResult, ParsedSearchInput } from "@12-apps/shared-helpers/search";
|
|
2
2
|
|
|
3
3
|
import type { DiscountScope, DiscountTrigger, DiscountType } from "../engine/kinds";
|
|
4
|
+
import type { DiscountSchedule } from "../engine/schedule";
|
|
4
5
|
import type { ComboRequirement } from "../engine/types";
|
|
5
6
|
import type { DiscountScalars, DiscountTargets } from "./validate";
|
|
6
7
|
|
|
@@ -33,6 +34,13 @@ export interface DiscountRecord {
|
|
|
33
34
|
code: string | null;
|
|
34
35
|
startsAt: Date | null;
|
|
35
36
|
endsAt: Date | null;
|
|
37
|
+
/**
|
|
38
|
+
* The WEEKLY schedule (FUT-996), OPTIONAL for the reason the combo columns
|
|
39
|
+
* below are: a host that has not added the column keeps compiling and keeps
|
|
40
|
+
* answering the record it always did. Null or absent = always, within the
|
|
41
|
+
* campaign window.
|
|
42
|
+
*/
|
|
43
|
+
schedule?: DiscountSchedule | null;
|
|
36
44
|
minSubtotalCents: number | null;
|
|
37
45
|
usageLimit: number | null;
|
|
38
46
|
perBuyerLimit: number | null;
|