@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
@@ -0,0 +1,191 @@
1
+ /**
2
+ * The WEEKLY schedule inside a discount's campaign window (FUT-996).
3
+ *
4
+ * `startsAt`/`endsAt` answer "for how long is this campaign on?". This answers
5
+ * the second question a merchant actually asks — "and within that, on which
6
+ * days and at what hours?" — so that "toda sexta, das 16:00 às 20:00" and
7
+ * "segunda e terça à tarde" are expressible at all.
8
+ *
9
+ * Pure and clock-free, like the rest of the engine: it never calls `new Date()`
10
+ * and never touches `Intl`. A recurring window is a WALL-CLOCK fact and is
11
+ * therefore only meaningful in the store's own timezone — but resolving an
12
+ * IANA zone is the HOST's job, because this module runs in an offline tool
13
+ * generator, a browser bundle and a worker, and none of them may depend on a
14
+ * timezone database. The host resolves an instant into a {@link LocalClock} and
15
+ * passes it in; everything here is integer arithmetic over that.
16
+ *
17
+ * A rule with no schedule is every rule that exists today, and answers `true`
18
+ * unconditionally — which is what makes this a strictly additive change.
19
+ */
20
+
21
+ /** Monday-first, matching the seven-entry week every hours surface uses. */
22
+ export const MAX_SCHEDULE_WINDOWS = 7;
23
+
24
+ /**
25
+ * One "these days, these hours" row of a schedule.
26
+ *
27
+ * A LIST of these rather than one shared time range, because "sexta 16–20 e
28
+ * sábado 12–16" is one promotion to the merchant selling it and two rows here.
29
+ * Deliberately NOT a per-weekday hours table (the shape `openingHours` uses):
30
+ * that editor asks seven questions to answer one, and a promotion is a
31
+ * sentence, not a business week.
32
+ */
33
+ export interface DiscountScheduleWindow {
34
+ /**
35
+ * Weekdays this window runs on, **Monday-first, 0..6**.
36
+ *
37
+ * Monday-first because that is what `WeekHours` and every hours screen in a
38
+ * host already uses; `Date#getDay()` is Sunday-first and the conversion is
39
+ * `(getDay() + 6) % 7`. Stated here because a schedule that is off by one day
40
+ * is a promotion that runs on the wrong day and looks like a pricing bug.
41
+ */
42
+ days: readonly number[];
43
+ /** `HH:MM`, 24-hour, in the STORE's timezone. Inclusive. */
44
+ from: string;
45
+ /** `HH:MM`, 24-hour, in the STORE's timezone. EXCLUSIVE. */
46
+ to: string;
47
+ }
48
+
49
+ /** A rule's weekly schedule. Null/absent = always, within the campaign window. */
50
+ export interface DiscountSchedule {
51
+ windows: readonly DiscountScheduleWindow[];
52
+ }
53
+
54
+ /**
55
+ * An instant already resolved into the store's own wall clock.
56
+ *
57
+ * Two integers rather than a `Date`, so this module cannot be tempted to read a
58
+ * timezone: by the time a value reaches here the only question left is
59
+ * arithmetic. The host builds it once per line and once per evaluation.
60
+ */
61
+ export interface LocalClock {
62
+ /** Monday-first, 0..6 — the same axis as {@link DiscountScheduleWindow.days}. */
63
+ weekday: number;
64
+ /** Minutes since local midnight, 0..1439. */
65
+ minutes: number;
66
+ }
67
+
68
+ /** `HH:MM` → minutes since midnight, or null when it is not a time at all. */
69
+ export function toMinutes(value: string): number | null {
70
+ const match = /^(\d{2}):(\d{2})$/.exec(value);
71
+ if (match === null) return null;
72
+ const hours = Number(match[1]);
73
+ const minutes = Number(match[2]);
74
+ if (hours > 23 || minutes > 59) return null;
75
+ return hours * 60 + minutes;
76
+ }
77
+
78
+ /** The weekday before this one, on the Monday-first axis. */
79
+ function previousWeekday(weekday: number): number {
80
+ return (weekday + 6) % 7;
81
+ }
82
+
83
+ /**
84
+ * Whether one window is running at `clock`.
85
+ *
86
+ * A `to` EARLIER than `from` is a bar that shuts at 02:00, not a typo — the
87
+ * window runs past midnight into the next day, exactly as `minutesOpen` reads a
88
+ * store's opening hours. Treating it as invalid would refuse the single most
89
+ * common shape of a happy hour.
90
+ *
91
+ * So an overnight window is two intervals: `[from, 24:00)` on each of its own
92
+ * days, and `[00:00, to)` on the day AFTER each of them — which is why the
93
+ * second test asks about `previousWeekday`. A Saturday 00:30 instant is inside
94
+ * a Friday 22:00–02:00 window, and no amount of testing Saturday against
95
+ * `days` would ever discover that.
96
+ *
97
+ * Half-open `[from, to)`, matching `[startsAt, endsAt)`: a window ending at
98
+ * 20:00 is over the instant 20:00 begins.
99
+ */
100
+ function windowCovers(window: DiscountScheduleWindow, clock: LocalClock): boolean {
101
+ const from = toMinutes(window.from);
102
+ const to = toMinutes(window.to);
103
+ // A malformed or empty window covers NOTHING rather than everything: it can
104
+ // only arrive by bypassing the validator, and the safe reading of "I cannot
105
+ // tell what this means" is not "charge the discount".
106
+ if (from === null || to === null || from === to) return false;
107
+ const days = new Set(window.days);
108
+ if (to > from) {
109
+ return days.has(clock.weekday) && clock.minutes >= from && clock.minutes < to;
110
+ }
111
+ if (days.has(clock.weekday) && clock.minutes >= from) return true;
112
+ return days.has(previousWeekday(clock.weekday)) && clock.minutes < to;
113
+ }
114
+
115
+ /**
116
+ * Whether the schedule is running at `clock` — true if ANY window is.
117
+ *
118
+ * `null` schedule → always true, which is every rule that predates this
119
+ * feature. A schedule with no windows is a rule an operator half-built; it
120
+ * covers nothing, and the validator refuses to save one.
121
+ *
122
+ * `clock` of `null` → **true**, and this is the load-bearing failure
123
+ * direction. It means the host could not resolve a timezone, and the same
124
+ * unknown makes `inStoreZone` fall back to the caller's clock rather than throw
125
+ * ("an unknown zone must not take the storefront down — nor refuse a payment")
126
+ * and `readStoreHours` keep a store selling. Failing the other way would
127
+ * silently switch off every scheduled promotion in a store on one bad timezone
128
+ * string — with nothing red anywhere, because a promotion that does not fire
129
+ * looks exactly like a promotion nobody qualified for.
130
+ */
131
+ export function scheduleCovers(
132
+ schedule: DiscountSchedule | null | undefined,
133
+ clock: LocalClock | null | undefined,
134
+ ): boolean {
135
+ if (schedule === null || schedule === undefined) return true;
136
+ if (clock === null || clock === undefined) return true;
137
+ return schedule.windows.some((window) => windowCovers(window, clock));
138
+ }
139
+
140
+ /**
141
+ * Whether a schedule is one this engine can act on at all.
142
+ *
143
+ * Used by the validator and by hosts reading a stored blob back; the evaluator
144
+ * itself is deliberately tolerant (see `windowCovers`) because refusing to
145
+ * price a cart is never the right answer to a bad row.
146
+ */
147
+ export function isUsableSchedule(schedule: DiscountSchedule): boolean {
148
+ if (schedule.windows.length === 0) return false;
149
+ if (schedule.windows.length > MAX_SCHEDULE_WINDOWS) return false;
150
+ return schedule.windows.every(
151
+ (window) =>
152
+ window.days.length > 0 &&
153
+ window.days.every((day) => Number.isInteger(day) && day >= 0 && day <= 6) &&
154
+ toMinutes(window.from) !== null &&
155
+ toMinutes(window.to) !== null &&
156
+ window.from !== window.to,
157
+ );
158
+ }
159
+
160
+ /**
161
+ * The next window that OPENS later today, or null — the teaser's whole input
162
+ * (FUT-996).
163
+ *
164
+ * "Later today" and not "next Friday": a card promising an offer six days out
165
+ * is noise on a screen the shopper is reading now, and it advertises a price
166
+ * they cannot have for almost a week. Scoped to the current weekday, the label
167
+ * is a reason to wait rather than a reason to leave.
168
+ *
169
+ * A window already running returns nothing from here — that is the BADGE's job,
170
+ * and a card must never carry both. The two are mutually exclusive by
171
+ * construction because this only looks at windows whose `from` is still ahead
172
+ * on the clock.
173
+ *
174
+ * Ties break on the earliest opening, so a card teases the offer that arrives
175
+ * soonest rather than whichever row the operator happened to add first.
176
+ */
177
+ export function nextWindowToday(
178
+ schedule: DiscountSchedule | null | undefined,
179
+ clock: LocalClock | null | undefined,
180
+ ): DiscountScheduleWindow | null {
181
+ if (schedule === null || schedule === undefined) return null;
182
+ if (clock === null || clock === undefined) return null;
183
+ const upcoming = schedule.windows
184
+ .filter((window) => {
185
+ const from = toMinutes(window.from);
186
+ if (from === null || from <= clock.minutes) return false;
187
+ return window.days.includes(clock.weekday);
188
+ })
189
+ .sort((a, b) => (toMinutes(a.from) ?? 0) - (toMinutes(b.from) ?? 0));
190
+ return upcoming[0] ?? null;
191
+ }
@@ -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,
@@ -82,6 +82,18 @@ export const discountsManifest = {
82
82
  * as the order that redeemed it is a rule that can be redeemed twice.
83
83
  */
84
84
  db: { partial: "prisma/discounts.prisma", migrations: "prisma/migrations" },
85
+ /**
86
+ * The promotions journeys (`../e2e`), and the world port a host implements
87
+ * to run them.
88
+ *
89
+ * Undeclared, they were adopted BY CONVENTION: a 167-line spec in the origin
90
+ * host asserting this package's own test ids from a repo it does not build —
91
+ * the arrangement whose failure mode is silent from here, since renaming an
92
+ * id in this package goes red somewhere this package cannot see. Declared, a
93
+ * host either binds `featuresRoot` or declines in writing, and the decline
94
+ * lands in the wiring report rather than in nobody's head.
95
+ */
96
+ e2e: { entry: "@12-apps/discounts/e2e", world: { factory: "defineDiscountsWorld" } },
85
97
  server: ["http"],
86
98
  web: ["surface", "areas"],
87
99
  } as const satisfies PackageManifest;
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);
@@ -92,17 +95,23 @@ export function DiscountActionsMenu({
92
95
 
93
96
  const remove = useDeleteConfirm(row, api, copy, onError);
94
97
 
98
+ // Each entry carries a test id derived from its STABLE id, never its label:
99
+ // a label is host copy, so a spec clicking one is pinned to a single
100
+ // adopter's words — which is exactly what the packaged journeys exist not to
101
+ // be. The origin host's spec reached for `menuitem { name: 'Editar' }`.
95
102
  const items: DropdownMenuItem[] = [];
96
103
  if (canEdit) {
97
104
  items.push({
98
105
  id: "edit",
99
106
  label: copy.actions.edit,
107
+ dataTestId: "discount-action-edit",
100
108
  onClick: () => setEditOpen(true),
101
109
  });
102
110
  }
103
111
  items.push({
104
112
  id: "delete",
105
113
  label: copy.actions.delete,
114
+ dataTestId: "discount-action-delete",
106
115
  color: "danger",
107
116
  onClick: remove.request,
108
117
  });
@@ -137,6 +146,7 @@ export function DiscountActionsMenu({
137
146
  groups={groups}
138
147
  editing={row.record}
139
148
  onError={onError}
149
+ {...(timezoneLabel ? { timezoneLabel } : {})}
140
150
  onSaved={() => {
141
151
  setEditOpen(false);
142
152
  onRefresh();