@12-apps/discounts 1.12.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/package.json +7 -6
- package/prisma/discounts.prisma +23 -0
- package/prisma/migrations/20260830120000_discount_weekly_schedule/migration.sql +40 -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/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 +4 -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
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
} from "@12-apps/ui/form/total-form";
|
|
13
13
|
import { Stack } from "@12-apps/ui/mui/Stack";
|
|
14
14
|
|
|
15
|
+
import type { DiscountSchedule } from "../engine/schedule";
|
|
15
16
|
import type { ComboRequirement } from "../engine/types";
|
|
16
17
|
|
|
17
18
|
import type { DiscountFormPayload, DiscountsApiClient, DiscountWireRecord, WireTargetGroup } from "./api";
|
|
@@ -28,6 +29,7 @@ import {
|
|
|
28
29
|
type CurrencyFieldComponent,
|
|
29
30
|
} from "./discount-form-fields";
|
|
30
31
|
import { DiscountTargetPicker } from "./discount-target-picker";
|
|
32
|
+
import { blankWindow } from "./schedule-builder";
|
|
31
33
|
import { validate, type FormTargets } from "./discount-form-validate";
|
|
32
34
|
import { initialValues, type DiscountFormValues } from "./discount-form-values";
|
|
33
35
|
import { kindOptions, typeAndScopeFor } from "./form-kind";
|
|
@@ -61,17 +63,36 @@ function useNonStringState(editing: DiscountWireRecord | null) {
|
|
|
61
63
|
const [comboRequirements, setComboRequirements] = useState<ComboRequirement[]>(() =>
|
|
62
64
|
editing?.comboRequirements ? [...editing.comboRequirements] : [],
|
|
63
65
|
);
|
|
66
|
+
// The weekly schedule (FUT-996) is the fourth: a nested structure edited with
|
|
67
|
+
// chips and clocks, which `total-form`'s strings cannot hold.
|
|
68
|
+
//
|
|
69
|
+
// Its ON/OFF is kept SEPARATE from its contents on purpose. An operator who
|
|
70
|
+
// switches back to "Sempre" and then changes their mind has not lost the days
|
|
71
|
+
// they picked — the rows survive the toggle and only the SUBMIT reads the
|
|
72
|
+
// switch. Collapsing the two would make the radio destructive.
|
|
73
|
+
const [scheduleEnabled, setScheduleEnabled] = useState<boolean>(
|
|
74
|
+
() => (editing?.schedule?.windows.length ?? 0) > 0,
|
|
75
|
+
);
|
|
76
|
+
const [schedule, setSchedule] = useState<DiscountSchedule>(() =>
|
|
77
|
+
editing?.schedule && editing.schedule.windows.length > 0
|
|
78
|
+
? { windows: editing.schedule.windows.map((w) => ({ ...w, days: [...w.days] })) }
|
|
79
|
+
: { windows: [blankWindow()] },
|
|
80
|
+
);
|
|
64
81
|
return {
|
|
65
82
|
active,
|
|
66
83
|
stackable,
|
|
67
84
|
categoryIds,
|
|
68
85
|
menuItemIds,
|
|
69
86
|
comboRequirements,
|
|
87
|
+
scheduleEnabled,
|
|
88
|
+
schedule,
|
|
70
89
|
setActive,
|
|
71
90
|
setStackable,
|
|
72
91
|
setCategoryIds,
|
|
73
92
|
setMenuItemIds,
|
|
74
93
|
setComboRequirements,
|
|
94
|
+
setScheduleEnabled,
|
|
95
|
+
setSchedule,
|
|
75
96
|
};
|
|
76
97
|
}
|
|
77
98
|
|
|
@@ -88,6 +109,8 @@ export interface DiscountFormProps {
|
|
|
88
109
|
onSaved: () => void;
|
|
89
110
|
/** Where a refused or failed write is reported, beyond the operator's screen. */
|
|
90
111
|
onError: (error: unknown, context: string) => void;
|
|
112
|
+
/** The store's timezone as a person says it — shown under the clocks. */
|
|
113
|
+
timezoneLabel?: string;
|
|
91
114
|
}
|
|
92
115
|
|
|
93
116
|
/**
|
|
@@ -110,7 +133,10 @@ function makeSubmit(
|
|
|
110
133
|
menuItemIds: extra.menuItemIds,
|
|
111
134
|
comboRequirements: extra.comboRequirements,
|
|
112
135
|
};
|
|
113
|
-
const invalid = validate(values, targets, formatters, copy
|
|
136
|
+
const invalid = validate(values, targets, formatters, copy, {
|
|
137
|
+
enabled: extra.scheduleEnabled,
|
|
138
|
+
schedule: extra.schedule,
|
|
139
|
+
});
|
|
114
140
|
if (Object.keys(invalid).length > 0) {
|
|
115
141
|
setFieldErrors(invalid);
|
|
116
142
|
setError(copy.form.reviewFields);
|
|
@@ -118,6 +144,9 @@ function makeSubmit(
|
|
|
118
144
|
}
|
|
119
145
|
const payload: DiscountFormPayload = {
|
|
120
146
|
...values,
|
|
147
|
+
// The switch decides whether the rows are sent at all — see the state
|
|
148
|
+
// hook for why the two are separate.
|
|
149
|
+
schedule: extra.scheduleEnabled ? extra.schedule : null,
|
|
121
150
|
// The one place the form's question becomes the API's two columns. Every
|
|
122
151
|
// other consumer — the write path, the evaluator, the CHECK constraints —
|
|
123
152
|
// still sees the `type`/`scope` pair it always did.
|
|
@@ -173,7 +202,14 @@ export function DiscountForm(props: DiscountFormProps): JSX.Element {
|
|
|
173
202
|
/>
|
|
174
203
|
<ComboCapField copy={copy} />
|
|
175
204
|
<TriggerFields copy={copy} />
|
|
176
|
-
<WindowFields
|
|
205
|
+
<WindowFields
|
|
206
|
+
copy={copy}
|
|
207
|
+
schedule={extra.schedule}
|
|
208
|
+
scheduleEnabled={extra.scheduleEnabled}
|
|
209
|
+
timezoneLabel={props.timezoneLabel ?? null}
|
|
210
|
+
onScheduleChange={extra.setSchedule}
|
|
211
|
+
onScheduleEnabledChange={extra.setScheduleEnabled}
|
|
212
|
+
/>
|
|
177
213
|
<LimitFields copy={copy} currencyField={currencyField} />
|
|
178
214
|
<DiscountSwitches
|
|
179
215
|
copy={copy}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { useState, type JSX } from "react";
|
|
4
4
|
import { useSearchParams } from "react-router-dom";
|
|
5
5
|
|
|
6
6
|
import AddIcon from "@mui/icons-material/Add";
|
|
@@ -32,13 +32,13 @@ import { DiscountForm } from "./discount-form";
|
|
|
32
32
|
import type { CurrencyFieldComponent } from "./discount-form-fields";
|
|
33
33
|
import { DiscountListCard, discountCells } from "./discount-list-card";
|
|
34
34
|
import type { DiscountsFormatters } from "./format";
|
|
35
|
-
import {
|
|
35
|
+
import { useDiscountRows, type DiscountListItem } from "./row";
|
|
36
|
+
import { toMenuBinding, usePagedServer } from "./screen-bindings";
|
|
36
37
|
import {
|
|
37
38
|
discountColumns,
|
|
38
39
|
discountExportColumns,
|
|
39
40
|
discountFilters,
|
|
40
41
|
discountsAppliedState,
|
|
41
|
-
discountsQueryToParams,
|
|
42
42
|
discountsSearch,
|
|
43
43
|
} from "./table-config";
|
|
44
44
|
import { useDiscountsData } from "./use-discounts-data";
|
|
@@ -55,6 +55,10 @@ export interface DiscountsScreenProps {
|
|
|
55
55
|
api: DiscountsApiClient;
|
|
56
56
|
copy: DiscountsWebCopy;
|
|
57
57
|
formatters: DiscountsFormatters;
|
|
58
|
+
/** The store's IANA zone, for the grid's live "ativa agora" dot (FUT-996). */
|
|
59
|
+
timezone?: string;
|
|
60
|
+
/** The store's timezone as a person says it, for the schedule editor. */
|
|
61
|
+
timezoneLabel?: string;
|
|
58
62
|
currencyField: CurrencyFieldComponent;
|
|
59
63
|
onError: (error: unknown, context: string) => void;
|
|
60
64
|
/** The crumbs above the title. The host owns its own information hierarchy. */
|
|
@@ -142,7 +146,7 @@ function HeaderControls({
|
|
|
142
146
|
}
|
|
143
147
|
|
|
144
148
|
/** Everything the row menu, both cards and the edit form are bound to. */
|
|
145
|
-
type MenuBinding = Omit<Parameters<typeof DiscountActionsMenu>[0], "row">;
|
|
149
|
+
export type MenuBinding = Omit<Parameters<typeof DiscountActionsMenu>[0], "row">;
|
|
146
150
|
|
|
147
151
|
/**
|
|
148
152
|
* The grid, split from the page container so neither crosses the size gate.
|
|
@@ -308,21 +312,14 @@ function ScreenBody({
|
|
|
308
312
|
}
|
|
309
313
|
|
|
310
314
|
export function DiscountsScreen(props: DiscountsScreenProps): JSX.Element {
|
|
311
|
-
const { api, copy, formatters,
|
|
315
|
+
const { api, copy, formatters, onError, breadcrumb, timezone } = props;
|
|
312
316
|
const [searchParams] = useSearchParams();
|
|
313
317
|
const data = useDiscountsData(api, discountsSearch(searchParams), onError);
|
|
314
318
|
const [createOpen, setCreateOpen] = useState(false);
|
|
315
319
|
const [visibleRows, setVisibleRows] = useState<DiscountListItem[]>([]);
|
|
316
320
|
const bulkDelete = useBulkDelete(api, copy, data.refresh, onError);
|
|
317
321
|
|
|
318
|
-
|
|
319
|
-
// "now"s could disagree about which of them is still running.
|
|
320
|
-
const rows = useMemo(() => {
|
|
321
|
-
const now = new Date();
|
|
322
|
-
return (data.page?.data ?? []).map((record) =>
|
|
323
|
-
toListItem(record, formatters, copy, now),
|
|
324
|
-
);
|
|
325
|
-
}, [data.page, formatters, copy]);
|
|
322
|
+
const rows = useDiscountRows(data.page?.data, formatters, copy, timezone);
|
|
326
323
|
|
|
327
324
|
// Seeded ONCE from the URL. Re-applying it on every render would wipe the
|
|
328
325
|
// operator's column-visibility choices each time the query synced back.
|
|
@@ -330,21 +327,8 @@ export function DiscountsScreen(props: DiscountsScreenProps): JSX.Element {
|
|
|
330
327
|
discountsAppliedState(searchParams, copy),
|
|
331
328
|
);
|
|
332
329
|
|
|
333
|
-
const server =
|
|
334
|
-
|
|
335
|
-
page: data.page?.pagination.page ?? 1,
|
|
336
|
-
pageSize: data.page?.pagination.pageSize ?? 20,
|
|
337
|
-
toParams: discountsQueryToParams,
|
|
338
|
-
});
|
|
339
|
-
|
|
340
|
-
const menu: MenuBinding = {
|
|
341
|
-
api,
|
|
342
|
-
copy,
|
|
343
|
-
formatters,
|
|
344
|
-
currencyField,
|
|
345
|
-
groups: data.groups,
|
|
346
|
-
onError,
|
|
347
|
-
};
|
|
330
|
+
const server = usePagedServer(data.page?.pagination);
|
|
331
|
+
const menu = toMenuBinding(props, data.groups);
|
|
348
332
|
|
|
349
333
|
if (data.loading) return <LoadingState dataTestId="discounts-loading" />;
|
|
350
334
|
if (data.error !== null) {
|
package/src/react/en-US.ts
CHANGED
|
@@ -88,6 +88,50 @@ export const EN_US_DISCOUNTS_WEB_COPY: DiscountsWebCopy = {
|
|
|
88
88
|
until: "Until {date}",
|
|
89
89
|
between: "{from} to {to}",
|
|
90
90
|
},
|
|
91
|
+
schedule: {
|
|
92
|
+
sectionTitle: "When it applies",
|
|
93
|
+
periodTitle: "Period",
|
|
94
|
+
periodHint: "Leave blank to run with no end date.",
|
|
95
|
+
repetitionTitle: "Repeats",
|
|
96
|
+
always: "Always, within the period",
|
|
97
|
+
specific: "On specific days and hours",
|
|
98
|
+
builderTitle: "Days and hours",
|
|
99
|
+
addWindow: "Add another time",
|
|
100
|
+
removeWindow: "Remove time",
|
|
101
|
+
fromLabel: "From",
|
|
102
|
+
toLabel: "to",
|
|
103
|
+
presetEveryDay: "Every day",
|
|
104
|
+
presetWeekdays: "Mon to Fri",
|
|
105
|
+
presetWeekend: "Weekend",
|
|
106
|
+
dayShort: ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
|
|
107
|
+
dayLong: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"],
|
|
108
|
+
dayEvery: [
|
|
109
|
+
"Every Monday",
|
|
110
|
+
"Every Tuesday",
|
|
111
|
+
"Every Wednesday",
|
|
112
|
+
"Every Thursday",
|
|
113
|
+
"Every Friday",
|
|
114
|
+
"Every Saturday",
|
|
115
|
+
"Every Sunday",
|
|
116
|
+
],
|
|
117
|
+
listSeparator: ", ",
|
|
118
|
+
listLast: " and ",
|
|
119
|
+
allDays: "Every day",
|
|
120
|
+
weekdays: "Monday to Friday",
|
|
121
|
+
weekend: "Saturday and Sunday",
|
|
122
|
+
summary: "{days}, from {from} to {to}.",
|
|
123
|
+
summaryOvernight: "{days}, from {from} to {to} the next day.",
|
|
124
|
+
timezoneNote: "Time zone: {timezone}.",
|
|
125
|
+
guaranteeNote: "The price is held from the moment the item went into the cart.",
|
|
126
|
+
orderScopeNote:
|
|
127
|
+
"Applies to orders placed within these hours. Unlike the other scopes, which follow the moment each item went into the cart.",
|
|
128
|
+
windowWithSchedule: "{window} · {schedule}",
|
|
129
|
+
moreWindows: "+{count}",
|
|
130
|
+
activeNow: "Running now",
|
|
131
|
+
daysRequired: "Pick at least one day.",
|
|
132
|
+
timesRequired: "Enter a start and end time that differ.",
|
|
133
|
+
windowsRequired: "Add at least one time.",
|
|
134
|
+
},
|
|
91
135
|
form: {
|
|
92
136
|
createTitle: "New discount",
|
|
93
137
|
editTitle: "Edit discount",
|
package/src/react/format.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { discountWindowState, type DiscountWindowState } from "../engine/kinds";
|
|
2
|
+
import type { DiscountSchedule } from "../engine/schedule";
|
|
2
3
|
|
|
3
4
|
import { fill, type DiscountsWebCopy } from "./copy";
|
|
5
|
+
import { formatScheduleCell } from "./schedule-summary";
|
|
4
6
|
|
|
5
7
|
/**
|
|
6
8
|
* Money, percentages and dates — as ONE locale's reader expects them, and as
|
|
@@ -103,6 +105,19 @@ export function formatDiscountValue(
|
|
|
103
105
|
* mean very different things when it is not running yet.
|
|
104
106
|
*/
|
|
105
107
|
export function formatWindow(
|
|
108
|
+
row: { startsAt: string | null; endsAt: string | null; schedule?: DiscountSchedule | null },
|
|
109
|
+
formatters: DiscountsFormatters,
|
|
110
|
+
copy: DiscountsWebCopy,
|
|
111
|
+
): string {
|
|
112
|
+
const period = formatPeriod(row, formatters, copy);
|
|
113
|
+
const schedule = formatScheduleCell(row.schedule, copy);
|
|
114
|
+
// Both facts in one cell (FUT-996): the campaign period answers "for how
|
|
115
|
+
// long", the schedule answers "and when within that", and a cell showing only
|
|
116
|
+
// the first would say a happy hour runs all day for three months.
|
|
117
|
+
return schedule === null ? period : fill(copy.schedule.windowWithSchedule, { window: period, schedule });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function formatPeriod(
|
|
106
121
|
row: { startsAt: string | null; endsAt: string | null },
|
|
107
122
|
formatters: DiscountsFormatters,
|
|
108
123
|
copy: DiscountsWebCopy,
|
package/src/react/pt-BR.ts
CHANGED
|
@@ -79,6 +79,50 @@ export const PT_BR_DISCOUNTS_WEB_COPY: DiscountsWebCopy = {
|
|
|
79
79
|
until: "Até {date}",
|
|
80
80
|
between: "{from} a {to}",
|
|
81
81
|
},
|
|
82
|
+
schedule: {
|
|
83
|
+
sectionTitle: "Quando vale",
|
|
84
|
+
periodTitle: "Período",
|
|
85
|
+
periodHint: "Deixe em branco para valer sem prazo.",
|
|
86
|
+
repetitionTitle: "Repetição",
|
|
87
|
+
always: "Sempre, dentro do período",
|
|
88
|
+
specific: "Em dias e horários específicos",
|
|
89
|
+
builderTitle: "Dias e horários",
|
|
90
|
+
addWindow: "Adicionar outro horário",
|
|
91
|
+
removeWindow: "Remover horário",
|
|
92
|
+
fromLabel: "Das",
|
|
93
|
+
toLabel: "às",
|
|
94
|
+
presetEveryDay: "Todos os dias",
|
|
95
|
+
presetWeekdays: "Seg a Sex",
|
|
96
|
+
presetWeekend: "Fim de semana",
|
|
97
|
+
dayShort: ["Seg", "Ter", "Qua", "Qui", "Sex", "Sáb", "Dom"],
|
|
98
|
+
dayLong: ["segunda", "terça", "quarta", "quinta", "sexta", "sábado", "domingo"],
|
|
99
|
+
dayEvery: [
|
|
100
|
+
"Toda segunda",
|
|
101
|
+
"Toda terça",
|
|
102
|
+
"Toda quarta",
|
|
103
|
+
"Toda quinta",
|
|
104
|
+
"Toda sexta",
|
|
105
|
+
"Todo sábado",
|
|
106
|
+
"Todo domingo",
|
|
107
|
+
],
|
|
108
|
+
listSeparator: ", ",
|
|
109
|
+
listLast: " e ",
|
|
110
|
+
allDays: "Todos os dias",
|
|
111
|
+
weekdays: "De segunda a sexta",
|
|
112
|
+
weekend: "Sábado e domingo",
|
|
113
|
+
summary: "{days}, das {from} às {to}.",
|
|
114
|
+
summaryOvernight: "{days}, das {from} às {to} do dia seguinte.",
|
|
115
|
+
timezoneNote: "Fuso horário: {timezone}.",
|
|
116
|
+
guaranteeNote: "O preço é garantido pelo horário em que o item entrou no carrinho.",
|
|
117
|
+
orderScopeNote:
|
|
118
|
+
"Vale para pedidos fechados dentro do horário. Diferente das outras abrangências, que valem pelo horário em que o item entrou no carrinho.",
|
|
119
|
+
windowWithSchedule: "{window} · {schedule}",
|
|
120
|
+
moreWindows: "+{count}",
|
|
121
|
+
activeNow: "Ativa agora",
|
|
122
|
+
daysRequired: "Escolha ao menos um dia.",
|
|
123
|
+
timesRequired: "Informe um horário de início e de fim diferentes.",
|
|
124
|
+
windowsRequired: "Adicione ao menos um horário.",
|
|
125
|
+
},
|
|
82
126
|
form: {
|
|
83
127
|
createTitle: "Novo desconto",
|
|
84
128
|
editTitle: "Editar desconto",
|
package/src/react/row.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
|
|
1
3
|
import type { DiscountWindowState } from "../engine/kinds";
|
|
4
|
+
import { scheduleCovers } from "../engine/schedule";
|
|
5
|
+
import { resolveLocalClock } from "../timezone";
|
|
2
6
|
|
|
3
7
|
import type { DiscountWireRecord } from "./api";
|
|
4
8
|
import type { DiscountsWebCopy } from "./copy";
|
|
@@ -40,6 +44,23 @@ export interface DiscountListItem extends Record<string, unknown> {
|
|
|
40
44
|
windowLabel: string;
|
|
41
45
|
/** Where it sits relative to its window, as a stable wire token. */
|
|
42
46
|
windowState: DiscountWindowState;
|
|
47
|
+
/**
|
|
48
|
+
* Whether this rule's weekly SCHEDULE is running at this moment (FUT-996).
|
|
49
|
+
*
|
|
50
|
+
* Deliberately not folded into {@link windowState}. Those three values are
|
|
51
|
+
* resolved by a store-side SQL predicate so the grid's filter pill and the
|
|
52
|
+
* badge on a row cannot disagree — and a recurrence predicate is not
|
|
53
|
+
* expressible there. Widening the enum would mean offering a filter the
|
|
54
|
+
* backend cannot serve.
|
|
55
|
+
*
|
|
56
|
+
* So the pill keeps meaning the CAMPAIGN, and this answers the operator's
|
|
57
|
+
* actual question ("is my happy hour on right now?") as a row-level dot
|
|
58
|
+
* computed here. The cost, stated: you cannot FILTER by it.
|
|
59
|
+
*
|
|
60
|
+
* `false` for a rule with no schedule — there is nothing intermittent to
|
|
61
|
+
* report, and a dot on every row would say nothing.
|
|
62
|
+
*/
|
|
63
|
+
activeNow: boolean;
|
|
43
64
|
/** The record itself, for the edit dialog to re-seed from. */
|
|
44
65
|
record: DiscountWireRecord;
|
|
45
66
|
}
|
|
@@ -54,6 +75,8 @@ export function toListItem(
|
|
|
54
75
|
formatters: DiscountsFormatters,
|
|
55
76
|
copy: DiscountsWebCopy,
|
|
56
77
|
now: Date,
|
|
78
|
+
/** The store's IANA zone. Omitted ⇒ no live dot; see {@link DiscountListItem.activeNow}. */
|
|
79
|
+
timezone?: string,
|
|
57
80
|
): DiscountListItem {
|
|
58
81
|
return {
|
|
59
82
|
...record,
|
|
@@ -61,6 +84,47 @@ export function toListItem(
|
|
|
61
84
|
valueLabel: formatDiscountValue(record, formatters),
|
|
62
85
|
windowLabel: formatWindow(record, formatters, copy),
|
|
63
86
|
windowState: windowStateOf(record, now),
|
|
87
|
+
activeNow: isRunningNow(record, now, timezone),
|
|
64
88
|
record,
|
|
65
89
|
};
|
|
66
90
|
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Is this rule's schedule running right now, in the STORE's timezone?
|
|
94
|
+
*
|
|
95
|
+
* Answered with the same `scheduleCovers` the evaluator screens with — never a
|
|
96
|
+
* second implementation, or the dot and the price a shopper is charged would
|
|
97
|
+
* disagree about the same minute.
|
|
98
|
+
*/
|
|
99
|
+
function isRunningNow(
|
|
100
|
+
record: DiscountWireRecord,
|
|
101
|
+
now: Date,
|
|
102
|
+
timezone: string | undefined,
|
|
103
|
+
): boolean {
|
|
104
|
+
if (record.schedule == null || timezone === undefined) return false;
|
|
105
|
+
const clock = resolveLocalClock(now, timezone);
|
|
106
|
+
// An unresolvable zone means "we do not know", which must not be drawn as a
|
|
107
|
+
// confident "on" — `scheduleCovers` answers TRUE for a null clock because a
|
|
108
|
+
// cart must keep pricing, and that is the wrong default for an indicator.
|
|
109
|
+
return clock !== null && scheduleCovers(record.schedule, clock);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The grid's rows, derived once per page load.
|
|
114
|
+
*
|
|
115
|
+
* ONE instant for the whole render: two rows judged against two different
|
|
116
|
+
* "now"s could disagree about which of them is still running, and on a list of
|
|
117
|
+
* forty that difference is invisible. A hook rather than an inline `useMemo`
|
|
118
|
+
* because {@link DiscountsScreen} sits at the size gate's ceiling.
|
|
119
|
+
*/
|
|
120
|
+
export function useDiscountRows(
|
|
121
|
+
records: readonly DiscountWireRecord[] | undefined,
|
|
122
|
+
formatters: DiscountsFormatters,
|
|
123
|
+
copy: DiscountsWebCopy,
|
|
124
|
+
timezone: string | undefined,
|
|
125
|
+
): DiscountListItem[] {
|
|
126
|
+
return useMemo(() => {
|
|
127
|
+
const now = new Date();
|
|
128
|
+
return (records ?? []).map((record) => toListItem(record, formatters, copy, now, timezone));
|
|
129
|
+
}, [records, formatters, copy, timezone]);
|
|
130
|
+
}
|
|
@@ -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
|
+
}
|