@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
@@ -0,0 +1,150 @@
1
+ import { comboRuleAcceptsLine } from "./combo-match";
2
+ import { buildLineIndex, coveredLineIds } from "./eligibility";
3
+ import { compareRuleOrder } from "./evaluate-passes";
4
+ import type { DiscountType } from "./kinds";
5
+ import { nextWindowToday, scheduleCovers, type LocalClock } from "./schedule";
6
+ import type { DiscountCartLine, DiscountRule } from "./types";
7
+
8
+ /**
9
+ * "Happy hour: 10% das 16h às 20h" — what a card may say about a promotion
10
+ * that has not started yet (FUT-996).
11
+ *
12
+ * This returns a LABELLING fact, never a price, and that distinction is not a
13
+ * new one: `previewItemDiscount` already refuses to badge a combo because "a
14
+ * combo price only exists once the OTHER components are in the cart, so
15
+ * striking a single item's price through with it would advertise a number this
16
+ * item alone can never reach", and `comboOffersForItem` exists precisely to say
17
+ * the true half of that out loud. A happy hour before 16:00 is the same shape:
18
+ * the OFFER is true, the PRICE is not yet. So it gets a sentence and no struck
19
+ * price, and the badge takes over the moment the window opens.
20
+ *
21
+ * Without it a merchant's best promotion goes unsold to everyone who looks
22
+ * before it starts — which, for a happy hour, is most of the day.
23
+ *
24
+ * Pure and copy-free like the rest of the engine: the merchant's `name` is the
25
+ * only string here and it is already their own words. A host renders the
26
+ * sentence around the two times.
27
+ */
28
+
29
+ /** One promotion this item takes part in that opens later today. */
30
+ export interface ScheduledOfferTeaser {
31
+ discountId: string;
32
+ /** The merchant's own promotion name — already their copy, their language. */
33
+ name: string;
34
+ type: DiscountType;
35
+ /** Basis points off. Non-null iff `type === "PERCENTAGE"`. */
36
+ percentOffBp: number | null;
37
+ /** Cents off. Non-null iff `type === "FIXED_AMOUNT"`. */
38
+ amountOffCents: number | null;
39
+ /** `HH:MM` the window opens, in the store's timezone. */
40
+ from: string;
41
+ /** `HH:MM` the window closes, exclusive. */
42
+ to: string;
43
+ }
44
+
45
+ export interface ScheduledTeaserInput {
46
+ /** The BASE (grouping) menu-item id the card renders. */
47
+ menuItemId: string;
48
+ /** The variation whose price the card shows, when it shows one. */
49
+ variationMenuItemId?: string | null;
50
+ /** The item's own category id followed by its ancestors, nearest first. */
51
+ categoryPath: readonly string[];
52
+ /** The tenant's live discount rules, loaded once for the whole menu. */
53
+ rules: readonly DiscountRule[];
54
+ /** Evaluation instant. Injected — this module never calls `new Date()`. */
55
+ now: Date;
56
+ /** `now` as the STORE's wall clock. Null ⇒ nothing is teased. */
57
+ localNow?: LocalClock | null;
58
+ }
59
+
60
+ const TEASER_LINE_ID = "teaser";
61
+
62
+ function toTeaserLine(input: ScheduledTeaserInput): DiscountCartLine {
63
+ return {
64
+ lineId: TEASER_LINE_ID,
65
+ menuItemId: input.menuItemId,
66
+ variationMenuItemId: input.variationMenuItemId ?? null,
67
+ categoryPath: input.categoryPath,
68
+ quantity: 1,
69
+ // A teaser asks whether the rule REACHES this item, which no price enters
70
+ // into — and deliberately never asks whether it is running, since the
71
+ // whole point is that it is not.
72
+ unitPriceCents: 0,
73
+ };
74
+ }
75
+
76
+ /**
77
+ * Worth teasing at all: an automatic, live, unexhausted, SCHEDULED rule that is
78
+ * not running at this moment.
79
+ *
80
+ * `ORDER` scope is excluded for the reason `previewItemDiscount` excludes it —
81
+ * an order-wide promotion is not this item's offer, and teasing it on a card
82
+ * would put the same sentence on every card in the menu.
83
+ *
84
+ * A rule with no schedule is excluded because it cannot be "starting later":
85
+ * either it is running, in which case the badge already speaks, or its campaign
86
+ * has not opened, which is a date and not an hour.
87
+ */
88
+ function isTeasable(rule: DiscountRule, now: Date, localNow: LocalClock | null): boolean {
89
+ if (!isTeasableShape(rule)) return false;
90
+ if (!withinCampaign(rule, now)) return false;
91
+ // Already running ⇒ the badge owns this card. Never both.
92
+ if (scheduleCovers(rule.schedule, localNow)) return false;
93
+ return rule.usageLimit === null || rule.usageCount < rule.usageLimit;
94
+ }
95
+
96
+ /**
97
+ * The static half: a rule that could ever be teased at all.
98
+ *
99
+ * `ORDER` is excluded for the reason `previewItemDiscount` excludes it, and an
100
+ * unscheduled rule for the reason in the header — it cannot be "starting
101
+ * later", only running or not yet begun, and the second is a date.
102
+ */
103
+ function isTeasableShape(rule: DiscountRule): boolean {
104
+ if (rule.trigger !== "AUTOMATIC" || !rule.active || rule.scope === "ORDER") return false;
105
+ return rule.schedule !== null && rule.schedule !== undefined;
106
+ }
107
+
108
+ /** Inside `[startsAt, endsAt)` — the CAMPAIGN, not the weekly schedule. */
109
+ function withinCampaign(rule: DiscountRule, now: Date): boolean {
110
+ if (rule.startsAt !== null && now.getTime() < rule.startsAt.getTime()) return false;
111
+ return rule.endsAt === null || now.getTime() < rule.endsAt.getTime();
112
+ }
113
+
114
+ /** Whether this rule reaches this item at all, by whichever route its scope uses. */
115
+ function reachesItem(rule: DiscountRule, line: DiscountCartLine): boolean {
116
+ if (rule.scope === "COMBO") return comboRuleAcceptsLine(rule, line);
117
+ return coveredLineIds(rule, [line], buildLineIndex([line])).has(TEASER_LINE_ID);
118
+ }
119
+
120
+ /**
121
+ * Every promotion this item takes part in that opens later TODAY, in the
122
+ * evaluator's canonical rule order so a card and a cart never disagree about
123
+ * which of two promotions comes first.
124
+ *
125
+ * Empty is the common case and the one a host should render as nothing at all.
126
+ */
127
+ export function upcomingOffersForItem(
128
+ input: ScheduledTeaserInput,
129
+ ): readonly ScheduledOfferTeaser[] {
130
+ const localNow = input.localNow ?? null;
131
+ if (localNow === null) return [];
132
+ const line = toTeaserLine(input);
133
+ return input.rules
134
+ .filter((rule) => isTeasable(rule, input.now, localNow) && reachesItem(rule, line))
135
+ .map((rule) => ({ rule, window: nextWindowToday(rule.schedule, localNow) }))
136
+ .filter(
137
+ (entry): entry is { rule: DiscountRule; window: NonNullable<typeof entry.window> } =>
138
+ entry.window !== null,
139
+ )
140
+ .sort((a, b) => compareRuleOrder(a.rule, b.rule))
141
+ .map(({ rule, window }) => ({
142
+ discountId: rule.id,
143
+ name: rule.name,
144
+ type: rule.type,
145
+ percentOffBp: rule.percentOffBp,
146
+ amountOffCents: rule.amountOffCents,
147
+ from: window.from,
148
+ to: window.to,
149
+ }));
150
+ }
@@ -4,6 +4,7 @@ import type {
4
4
  DiscountTrigger,
5
5
  DiscountType,
6
6
  } from "./kinds";
7
+ import type { DiscountSchedule, LocalClock } from "./schedule";
7
8
 
8
9
  /**
9
10
  * The evaluator's type contract (FUT-245). Types only, no logic — the shape
@@ -100,6 +101,16 @@ export interface DiscountRule {
100
101
  startsAt: Date | null;
101
102
  /** EXCLUSIVE upper bound of the active window; null = never closes. */
102
103
  endsAt: Date | null;
104
+ /**
105
+ * The WEEKLY schedule INSIDE that window (FUT-996) — "toda sexta, das 16:00
106
+ * às 20:00". Null or omitted = always, within `[startsAt, endsAt)`, which is
107
+ * every rule that predates the feature.
108
+ *
109
+ * OPTIONAL for the reason `comboRequirements` is: a schedule is a capability
110
+ * a host opts into, so requiring the field would have made recurring
111
+ * promotions a breaking change for every adopter that does not want them.
112
+ */
113
+ schedule?: DiscountSchedule | null;
103
114
  /** Minimum PRE-discount subtotal in cents; null = no threshold. */
104
115
  minSubtotalCents: number | null;
105
116
  /** Global redemption cap; null = uncapped. */
@@ -137,6 +148,20 @@ export interface DiscountCartLine {
137
148
  quantity: number;
138
149
  /** Composed unit price in integer cents. */
139
150
  unitPriceCents: number;
151
+ /**
152
+ * WHEN this line was committed, as the store's own wall clock (FUT-996).
153
+ *
154
+ * The instant a scheduled promotion is screened against — never the instant
155
+ * of payment. For a cart line that is `cart_items.created_at`; for a comanda
156
+ * line it is the kitchen-send time at which its price was already frozen.
157
+ * That is what makes "the beer ordered at 19:55 was ordered during happy
158
+ * hour" true however long the table takes to settle.
159
+ *
160
+ * Optional, and `null` means "the host could not resolve one" — which makes
161
+ * a scheduled rule cover the line rather than skip it. See `scheduleCovers`
162
+ * for why the unknown fails in that direction.
163
+ */
164
+ committedLocal?: LocalClock | null;
140
165
  }
141
166
 
142
167
  export interface DiscountEvaluationInput {
@@ -147,6 +172,17 @@ export interface DiscountEvaluationInput {
147
172
  couponCode: string | null;
148
173
  /** Evaluation instant. Injected — the evaluator never calls `new Date()`. */
149
174
  now: Date;
175
+ /**
176
+ * `now` as the STORE's wall clock (FUT-996) — what an `ORDER`-scoped
177
+ * scheduled rule is screened against.
178
+ *
179
+ * `ORDER` scope is the one place a schedule cannot be answered per line: an
180
+ * order-wide discount names no line, and the order itself comes into
181
+ * existence at checkout. Every other scope screens each line against its own
182
+ * `committedLocal`. That asymmetry is deliberate and is surfaced to the
183
+ * operator in the form rather than left invisible.
184
+ */
185
+ localNow?: LocalClock | null;
150
186
  }
151
187
 
152
188
  /** Why a discount the buyer asked for was not applied. */
package/src/index.ts CHANGED
@@ -64,6 +64,38 @@ export type {
64
64
 
65
65
  export { evaluateDiscounts } from "./engine/evaluate";
66
66
 
67
+ /**
68
+ * The WEEKLY schedule (FUT-996) — "toda sexta, das 16:00 às 20:00".
69
+ *
70
+ * `scheduleCovers` and `nextWindowToday` are exported because a host has two
71
+ * jobs this package cannot do for it: resolve an instant into the STORE's wall
72
+ * clock (an IANA zone, which this engine deliberately cannot read), and write
73
+ * the sentence around a window. Both answers must come from the same predicate
74
+ * the evaluator screens with, or an admin grid's "ativa agora" dot and the
75
+ * price a shopper is charged will disagree about the same minute.
76
+ */
77
+ export {
78
+ isUsableSchedule,
79
+ MAX_SCHEDULE_WINDOWS,
80
+ nextWindowToday,
81
+ scheduleCovers,
82
+ toMinutes,
83
+ type DiscountSchedule,
84
+ type DiscountScheduleWindow,
85
+ type LocalClock,
86
+ } from "./engine/schedule";
87
+
88
+ /**
89
+ * The teaser: what a card may say about a promotion that has not started yet.
90
+ * A LABEL, never a price — see `./engine/teaser.ts` for why that line is the
91
+ * same one `comboOffersForItem` already draws.
92
+ */
93
+ export {
94
+ upcomingOffersForItem,
95
+ type ScheduledOfferTeaser,
96
+ type ScheduledTeaserInput,
97
+ } from "./engine/teaser";
98
+
67
99
  export {
68
100
  previewItemDiscount,
69
101
  type ItemDiscountPreview,
package/src/react/api.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { DiscountSchedule } from "../engine/schedule";
1
2
  import type { ComboRequirement } from "../engine/types";
2
3
 
3
4
  import type { DiscountsFormatters } from "./format";
@@ -30,6 +31,8 @@ export interface DiscountWireRecord {
30
31
  code: string | null;
31
32
  startsAt: string | null;
32
33
  endsAt: string | null;
34
+ /** The weekly schedule (FUT-996), or null for "always". */
35
+ schedule?: DiscountSchedule | null;
33
36
  minSubtotalCents: number | null;
34
37
  usageLimit: number | null;
35
38
  perBuyerLimit: number | null;
@@ -100,6 +103,15 @@ export interface DiscountFormPayload {
100
103
  menuItemIds: string[];
101
104
  /** The combo's groups, in the operator's own order. Empty at every other scope. */
102
105
  comboRequirements: readonly ComboRequirement[];
106
+ /**
107
+ * The weekly schedule (FUT-996), or null for "sempre".
108
+ *
109
+ * Not a form STRING like the two dates beside it, for the reason the target
110
+ * id lists are not: it is a nested structure the operator edits with chips
111
+ * and clocks, so it lives in state beside the container and is merged in at
112
+ * submit.
113
+ */
114
+ schedule: DiscountSchedule | null;
103
115
  }
104
116
 
105
117
  /** A money amount as integer cents, or null when blank. */
@@ -165,6 +177,7 @@ export function toWriteBody(
165
177
  code: input.trigger === "CODE" ? toNullable(input.code) : null,
166
178
  startsAt: toNullable(input.startsAt),
167
179
  endsAt: toNullable(input.endsAt),
180
+ schedule: input.schedule,
168
181
  minSubtotalCents: toCents(input.minSubtotal, formatters),
169
182
  usageLimit: toCount(input.usageLimit, formatters),
170
183
  perBuyerLimit: toCount(input.perBuyerLimit, formatters),
package/src/react/copy.ts CHANGED
@@ -7,6 +7,8 @@ import type {
7
7
  DiscountWindowState,
8
8
  } from "../engine/kinds";
9
9
 
10
+ import type { DiscountsScheduleCopy } from "./schedule-copy";
11
+
10
12
  /**
11
13
  * Every word the discounts ADMIN screens put in front of a human — required
12
14
  * host config, with NO defaults.
@@ -274,6 +276,7 @@ export interface DiscountsWebCopy {
274
276
  readonly screen: DiscountsScreenCopy;
275
277
  readonly labels: DiscountsVocabularyCopy;
276
278
  readonly window: DiscountsWindowCopy;
279
+ readonly schedule: DiscountsScheduleCopy;
277
280
  readonly form: DiscountsFormCopy;
278
281
  readonly combo: DiscountsComboCopy;
279
282
  readonly targets: DiscountsTargetCopy;
@@ -2,6 +2,8 @@
2
2
 
3
3
  import { useMemo, type ComponentType, type JSX } from "react";
4
4
 
5
+ import { timeZoneLabel } from "../timezone";
6
+
5
7
  import { createDiscountsApiClient, type DiscountsApiClient } from "./api";
6
8
  import { missingWebCopy, type DiscountsWebCopy } from "./copy";
7
9
  import { DiscountForm, type DiscountFormProps } from "./discount-form";
@@ -47,6 +49,34 @@ export interface DiscountsWebConfig {
47
49
  locale: string;
48
50
  /** ISO 4217, e.g. `BRL`. */
49
51
  currency: string;
52
+ /**
53
+ * The STORE's timezone, as a name an operator recognises ("São Paulo") —
54
+ * shown under the schedule's clocks (FUT-996).
55
+ *
56
+ * A label rather than an IANA id, because it is read by a person and
57
+ * "America/Sao_Paulo" is not how anyone says it. The engine never sees this:
58
+ * resolving an instant into the store's wall clock is the SERVER's job, and
59
+ * this string exists so a merchant typing 16:00 knows whose 16:00 it is —
60
+ * which is not a rhetorical worry for a store admin sitting in another one.
61
+ *
62
+ * Optional: a host that has not adopted schedules yet shows the builder
63
+ * without the line rather than a wrong one.
64
+ */
65
+ timezoneLabel?: string;
66
+ /**
67
+ * The store's IANA timezone (`America/Sao_Paulo`) — what the grid's
68
+ * "ativa agora" dot is computed against (FUT-996).
69
+ *
70
+ * Separate from {@link timezoneLabel} because they do different jobs: this
71
+ * one is read by `Intl`, that one is read by a person. Deriving the label
72
+ * from this would print "Sao_Paulo" at a merchant.
73
+ *
74
+ * Omitted ⇒ no dot. A rule's SCHEDULE still shows in the vigência cell; only
75
+ * the live "is it running this minute" answer is withheld, because without a
76
+ * zone it would be the browser's answer rather than the store's — and a shop
77
+ * whose admin is abroad would be told its happy hour is off while it is on.
78
+ */
79
+ timezone?: string;
50
80
  /**
51
81
  * Where a failure goes, beyond the operator's own screen — REQUIRED, and for
52
82
  * the same reason `createApiDiscounts` requires a logger.
@@ -110,7 +140,12 @@ function assertConfig(config: DiscountsWebConfig): void {
110
140
 
111
141
  export function createWebDiscounts(config: DiscountsWebConfig): WebDiscounts {
112
142
  assertConfig(config);
113
- const { copy, onError, currencyField, breadcrumb } = config;
143
+ const { copy, onError, currencyField, breadcrumb, timezone } = config;
144
+ // DERIVED from the zone unless the host insists otherwise. A host naming the
145
+ // zone in words would be shipping copy, which has to be bilingual; `Intl`
146
+ // already knows it in every locale.
147
+ const timezoneLabel =
148
+ config.timezoneLabel ?? (timezone ? timeZoneLabel(timezone, config.locale) : undefined);
114
149
  const formatters = createFormatters(config.locale, config.currency);
115
150
  const transport = config.transport ?? httpDiscountsTransport(copy.form.saveFailed);
116
151
  const api = createDiscountsApiClient(config.apiBase, transport, formatters);
@@ -122,6 +157,8 @@ export function createWebDiscounts(config: DiscountsWebConfig): WebDiscounts {
122
157
  currencyField,
123
158
  onError,
124
159
  ...(breadcrumb ? { breadcrumb } : {}),
160
+ ...(timezoneLabel ? { timezoneLabel } : {}),
161
+ ...(timezone ? { timezone } : {}),
125
162
  };
126
163
 
127
164
  return {
@@ -133,6 +170,7 @@ export function createWebDiscounts(config: DiscountsWebConfig): WebDiscounts {
133
170
  formatters={formatters}
134
171
  currencyField={currencyField}
135
172
  onError={onError}
173
+ {...(timezoneLabel ? { timezoneLabel } : {})}
136
174
  {...props}
137
175
  />
138
176
  ),
@@ -47,6 +47,8 @@ export interface DiscountActionsMenuProps {
47
47
  */
48
48
  groups?: readonly WireTargetGroup[];
49
49
  onError: (error: unknown, context: string) => void;
50
+ /** The store's timezone as a person says it, for the schedule editor. */
51
+ timezoneLabel?: string;
50
52
  }
51
53
 
52
54
  /**
@@ -85,6 +87,7 @@ export function DiscountActionsMenu({
85
87
  currencyField,
86
88
  groups,
87
89
  onError,
90
+ timezoneLabel,
88
91
  }: DiscountActionsMenuProps): JSX.Element {
89
92
  const { onRefresh } = useCardActions();
90
93
  const [editOpen, setEditOpen] = useState(false);
@@ -143,6 +146,7 @@ export function DiscountActionsMenu({
143
146
  groups={groups}
144
147
  editing={row.record}
145
148
  onError={onError}
149
+ {...(timezoneLabel ? { timezoneLabel } : {})}
146
150
  onSaved={() => {
147
151
  setEditOpen(false);
148
152
  onRefresh();
@@ -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;