@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.
Files changed (38) hide show
  1. package/package.json +7 -6
  2. package/prisma/discounts.prisma +23 -0
  3. package/prisma/migrations/20260830120000_discount_weekly_schedule/migration.sql +40 -0
  4. package/src/engine/combo-offer.ts +21 -3
  5. package/src/engine/eligibility.ts +76 -0
  6. package/src/engine/en-US.ts +1 -0
  7. package/src/engine/evaluate.ts +8 -2
  8. package/src/engine/kinds.ts +7 -0
  9. package/src/engine/preview.ts +32 -6
  10. package/src/engine/pt-BR.ts +8 -0
  11. package/src/engine/schedule.ts +191 -0
  12. package/src/engine/teaser.ts +150 -0
  13. package/src/engine/types.ts +36 -0
  14. package/src/index.ts +32 -0
  15. package/src/react/api.ts +13 -0
  16. package/src/react/copy.ts +3 -0
  17. package/src/react/create-web-discounts.tsx +39 -1
  18. package/src/react/discount-actions-menu.tsx +4 -0
  19. package/src/react/discount-form-fields.tsx +90 -6
  20. package/src/react/discount-form-validate.ts +40 -0
  21. package/src/react/discount-form.tsx +38 -2
  22. package/src/react/discounts-screen.tsx +12 -28
  23. package/src/react/en-US.ts +44 -0
  24. package/src/react/format.ts +15 -0
  25. package/src/react/pt-BR.ts +44 -0
  26. package/src/react/row.ts +64 -0
  27. package/src/react/schedule-builder.tsx +253 -0
  28. package/src/react/schedule-copy.ts +74 -0
  29. package/src/react/schedule-summary.ts +100 -0
  30. package/src/react/screen-bindings.ts +57 -0
  31. package/src/react/table-config.ts +15 -0
  32. package/src/server/copy.ts +3 -0
  33. package/src/server/en-US.ts +1 -0
  34. package/src/server/mcp.ts +32 -0
  35. package/src/server/pt-BR.ts +1 -0
  36. package/src/server/store.ts +8 -0
  37. package/src/server/validate.ts +43 -0
  38. 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 copy={copy} />
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 { useMemo, useState, type JSX } from "react";
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 { toListItem, type DiscountListItem } from "./row";
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, currencyField, onError, breadcrumb } = props;
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
- // ONE instant for the whole render: two rows judged against two different
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 = useServerDataViews({
334
- totalCount: data.page?.pagination.total ?? 0,
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) {
@@ -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",
@@ -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,
@@ -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
+ }