@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.
Files changed (60) hide show
  1. package/dist/e2e/globs.d.ts +40 -0
  2. package/dist/e2e/globs.d.ts.map +1 -0
  3. package/dist/e2e/globs.js +66 -0
  4. package/dist/e2e/globs.js.map +1 -0
  5. package/dist/e2e/index.d.ts +12 -0
  6. package/dist/e2e/index.d.ts.map +1 -0
  7. package/dist/e2e/index.js +12 -0
  8. package/dist/e2e/index.js.map +1 -0
  9. package/dist/e2e/steps/journey.steps.d.ts +2 -0
  10. package/dist/e2e/steps/journey.steps.d.ts.map +1 -0
  11. package/dist/e2e/steps/journey.steps.js +146 -0
  12. package/dist/e2e/steps/journey.steps.js.map +1 -0
  13. package/dist/e2e/world.d.ts +76 -0
  14. package/dist/e2e/world.d.ts.map +1 -0
  15. package/dist/e2e/world.js +25 -0
  16. package/dist/e2e/world.js.map +1 -0
  17. package/features/promotions.feature +52 -0
  18. package/package.json +28 -9
  19. package/prisma/discounts.prisma +23 -0
  20. package/prisma/migrations/20260830120000_discount_weekly_schedule/migration.sql +40 -0
  21. package/src/e2e/globs.ts +70 -0
  22. package/src/e2e/index.ts +16 -0
  23. package/src/e2e/steps/journey.steps.ts +170 -0
  24. package/src/e2e/world.ts +93 -0
  25. package/src/engine/combo-offer.ts +21 -3
  26. package/src/engine/eligibility.ts +76 -0
  27. package/src/engine/en-US.ts +1 -0
  28. package/src/engine/evaluate.ts +8 -2
  29. package/src/engine/kinds.ts +7 -0
  30. package/src/engine/preview.ts +32 -6
  31. package/src/engine/pt-BR.ts +8 -0
  32. package/src/engine/schedule.ts +191 -0
  33. package/src/engine/teaser.ts +150 -0
  34. package/src/engine/types.ts +36 -0
  35. package/src/index.ts +32 -0
  36. package/src/manifest/index.ts +12 -0
  37. package/src/react/api.ts +13 -0
  38. package/src/react/copy.ts +3 -0
  39. package/src/react/create-web-discounts.tsx +39 -1
  40. package/src/react/discount-actions-menu.tsx +10 -0
  41. package/src/react/discount-form-fields.tsx +90 -6
  42. package/src/react/discount-form-validate.ts +40 -0
  43. package/src/react/discount-form.tsx +38 -2
  44. package/src/react/discounts-screen.tsx +12 -28
  45. package/src/react/en-US.ts +44 -0
  46. package/src/react/format.ts +15 -0
  47. package/src/react/pt-BR.ts +44 -0
  48. package/src/react/row.ts +64 -0
  49. package/src/react/schedule-builder.tsx +253 -0
  50. package/src/react/schedule-copy.ts +74 -0
  51. package/src/react/schedule-summary.ts +100 -0
  52. package/src/react/screen-bindings.ts +57 -0
  53. package/src/react/table-config.ts +15 -0
  54. package/src/server/copy.ts +3 -0
  55. package/src/server/en-US.ts +1 -0
  56. package/src/server/mcp.ts +32 -0
  57. package/src/server/pt-BR.ts +1 -0
  58. package/src/server/store.ts +8 -0
  59. package/src/server/validate.ts +43 -0
  60. package/src/timezone.ts +96 -0
@@ -2,6 +2,7 @@
2
2
 
3
3
  import type { JSX } from "react";
4
4
 
5
+ import { FormControl, FormLabel } from "@12-apps/ui/form/Form";
5
6
  import { Switch } from "@12-apps/ui/form/Switch";
6
7
  import { Fields, useFormContext } from "@12-apps/ui/form/total-form";
7
8
  import { Box } from "@12-apps/ui/mui/Box";
@@ -9,9 +10,11 @@ import { Stack } from "@12-apps/ui/mui/Stack";
9
10
  import { Text } from "@12-apps/ui/typography/Text";
10
11
 
11
12
  import { DISCOUNT_TRIGGERS } from "../engine/kinds";
13
+ import type { DiscountSchedule } from "../engine/schedule";
12
14
 
13
15
  import type { DiscountsWebCopy } from "./copy";
14
16
  import { COMBO_REWARDS, isComboKind, SELECTABLE_DISCOUNT_SCOPES, type DiscountKind } from "./form-kind";
17
+ import { ScheduleBuilder } from "./schedule-builder";
15
18
 
16
19
  /**
17
20
  * The form's inputs, split from the form itself for the 400-line file gate —
@@ -249,16 +252,97 @@ export function ComboCapField({ copy }: { copy: DiscountsWebCopy }): JSX.Element
249
252
  );
250
253
  }
251
254
 
252
- /** The active window. Both sides optional; the end date is EXCLUSIVE. */
253
- export function WindowFields({ copy }: { copy: DiscountsWebCopy }): JSX.Element {
255
+ /**
256
+ * "Quando vale" — the campaign PERIOD, and the weekly schedule inside it
257
+ * (FUT-996).
258
+ *
259
+ * The two are one section because they answer one question in two halves, and
260
+ * an operator reads them together: "de setembro a novembro" and "toda sexta,
261
+ * das 16 às 20" describe the same promotion. They were a bare pair of date
262
+ * inputs before there was a second half to pair with.
263
+ *
264
+ * Repetition defaults to "Sempre", so every rule that predates this and every
265
+ * simple new one is untouched — an operator creating a plain 10%-off never sees
266
+ * the builder. That default is what keeps the screen from getting harder for
267
+ * the promotions nobody schedules.
268
+ */
269
+ export function WindowFields({
270
+ copy,
271
+ schedule,
272
+ scheduleEnabled,
273
+ timezoneLabel,
274
+ onScheduleChange,
275
+ onScheduleEnabledChange,
276
+ }: {
277
+ copy: DiscountsWebCopy;
278
+ schedule: DiscountSchedule;
279
+ scheduleEnabled: boolean;
280
+ timezoneLabel: string | null;
281
+ onScheduleChange: (next: DiscountSchedule) => void;
282
+ onScheduleEnabledChange: (next: boolean) => void;
283
+ }): JSX.Element {
254
284
  return (
255
- <Box sx={{ display: "grid", gap: 2, gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" } }}>
256
- <Fields.TextField name="startsAt" label={copy.form.startsAt} type="date" />
257
- <Fields.TextField name="endsAt" label={copy.form.endsAt} type="date" />
258
- </Box>
285
+ <Stack spacing={1.5}>
286
+ <Text variant="heading">{copy.schedule.sectionTitle}</Text>
287
+ <Stack spacing={0.5}>
288
+ <Box sx={{ display: "grid", gap: 2, gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" } }}>
289
+ <Fields.TextField name="startsAt" label={copy.form.startsAt} type="date" />
290
+ <Fields.TextField name="endsAt" label={copy.form.endsAt} type="date" />
291
+ </Box>
292
+ <Text variant="caption">{copy.schedule.periodHint}</Text>
293
+ </Stack>
294
+ <FormControl>
295
+ <FormLabel>{copy.schedule.repetitionTitle}</FormLabel>
296
+ <Stack spacing={0.5}>
297
+ {[
298
+ [false, copy.schedule.always] as const,
299
+ [true, copy.schedule.specific] as const,
300
+ ].map(([value, label]) => (
301
+ <label key={label} style={{ display: "flex", gap: 8, alignItems: "center" }}>
302
+ <input
303
+ type="radio"
304
+ name="discount-repetition"
305
+ checked={scheduleEnabled === value}
306
+ onChange={() => onScheduleEnabledChange(value)}
307
+ data-testid={value ? "repetition-specific" : "repetition-always"}
308
+ />
309
+ <Text variant="body">{label}</Text>
310
+ </label>
311
+ ))}
312
+ </Stack>
313
+ </FormControl>
314
+ {scheduleEnabled && (
315
+ <>
316
+ <ScheduleBuilder
317
+ schedule={schedule}
318
+ copy={copy}
319
+ timezone={timezoneLabel ?? ""}
320
+ onChange={onScheduleChange}
321
+ />
322
+ <OrderScopeScheduleNote copy={copy} />
323
+ </>
324
+ )}
325
+ </Stack>
259
326
  );
260
327
  }
261
328
 
329
+ /**
330
+ * The ORDER-scope asymmetry, at the point of confusion.
331
+ *
332
+ * An order-wide rule has no line to anchor a schedule to, so it is judged at
333
+ * CHECKOUT while every other scope is judged per line — which means a cart
334
+ * straddling 20:00 behaves differently under "10% em tudo" than under "10% em
335
+ * cervejas". That is a real difference and an invisible one, so it is said
336
+ * here, only for the combination that has it, rather than left in a document
337
+ * nobody opens while filling in a form.
338
+ */
339
+ function OrderScopeScheduleNote({ copy }: { copy: DiscountsWebCopy }): JSX.Element | null {
340
+ const { values } = useFormContext();
341
+ const kind = values.kind ?? "PERCENTAGE";
342
+ if (isComboKind(kind) || values.scope !== "ORDER") return null;
343
+ return <Text variant="caption">{copy.schedule.orderScopeNote}</Text>;
344
+ }
345
+
262
346
  /** The minimum basket and the two redemption caps. */
263
347
  export function LimitFields({
264
348
  copy,
@@ -1,3 +1,4 @@
1
+ import { toMinutes, type DiscountSchedule } from "../engine/schedule";
1
2
  import type { ComboRequirement } from "../engine/types";
2
3
 
3
4
  import { comboUnits } from "./combo-slot-builder";
@@ -141,16 +142,55 @@ export interface FormTargets {
141
142
  }
142
143
 
143
144
  /** The cross-field rules a per-input schema cannot express. */
145
+ /**
146
+ * The weekly schedule, when the operator asked for one (FUT-996).
147
+ *
148
+ * Three refusals, and each is a thing the operator can SEE and fix on the row
149
+ * in front of them. Deliberately NOT refused: a `to` earlier than `from` — that
150
+ * is a bar shutting at 02:00, the commonest happy hour there is, and the
151
+ * builder's own sentence says "do dia seguinte" so it can be checked rather
152
+ * than guessed at.
153
+ *
154
+ * Every one of these is re-checked on the server (`assertSchedule`), which is
155
+ * the authority. This half exists so the operator is told before they submit.
156
+ */
157
+ function checkSchedule(
158
+ state: ScheduleState,
159
+ copy: DiscountsWebCopy,
160
+ ): Record<string, string> {
161
+ if (!state.enabled) return {};
162
+ const { windows } = state.schedule;
163
+ if (windows.length === 0) return { schedule: copy.schedule.windowsRequired };
164
+ if (windows.some((window) => window.days.length === 0)) {
165
+ return { schedule: copy.schedule.daysRequired };
166
+ }
167
+ const badTime = windows.some(
168
+ (window) =>
169
+ toMinutes(window.from) === null ||
170
+ toMinutes(window.to) === null ||
171
+ window.from === window.to,
172
+ );
173
+ return badTime ? { schedule: copy.schedule.timesRequired } : {};
174
+ }
175
+
176
+ /** The schedule editor's state: its switch and its rows, kept separate. */
177
+ interface ScheduleState {
178
+ enabled: boolean;
179
+ schedule: DiscountSchedule;
180
+ }
181
+
144
182
  export function validate(
145
183
  values: DiscountFormValues,
146
184
  targets: FormTargets,
147
185
  formatters: DiscountsFormatters,
148
186
  copy: DiscountsWebCopy,
187
+ scheduleState: ScheduleState,
149
188
  ): Record<string, string> {
150
189
  const slots = targets.comboRequirements;
151
190
  const errors: Record<string, string> = {
152
191
  ...checkValue(values, slots, formatters, copy),
153
192
  ...checkCombo(values, slots, copy),
193
+ ...checkSchedule(scheduleState, copy),
154
194
  };
155
195
  if (values.trigger === "CODE" && values.code.trim() === "") {
156
196
  errors.code = copy.form.codeRequired;
@@ -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
+ }