@12-apps/discounts 1.12.0 → 1.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -6
- package/prisma/discounts.prisma +23 -0
- package/prisma/migrations/20260830120000_discount_weekly_schedule/migration.sql +40 -0
- package/src/engine/combo-offer.ts +21 -3
- package/src/engine/eligibility.ts +76 -0
- package/src/engine/en-US.ts +1 -0
- package/src/engine/evaluate.ts +8 -2
- package/src/engine/kinds.ts +7 -0
- package/src/engine/preview.ts +32 -6
- package/src/engine/pt-BR.ts +8 -0
- package/src/engine/schedule.ts +191 -0
- package/src/engine/teaser.ts +150 -0
- package/src/engine/types.ts +36 -0
- package/src/index.ts +32 -0
- package/src/react/api.ts +13 -0
- package/src/react/copy.ts +5 -0
- package/src/react/create-web-discounts.tsx +39 -1
- package/src/react/discount-actions-menu.tsx +4 -0
- package/src/react/discount-form-fields.tsx +90 -6
- package/src/react/discount-form-validate.ts +40 -0
- package/src/react/discount-form.tsx +38 -2
- package/src/react/discounts-screen.tsx +14 -29
- package/src/react/en-US.ts +45 -0
- package/src/react/format.ts +15 -0
- package/src/react/pt-BR.ts +45 -0
- package/src/react/row.ts +64 -0
- package/src/react/schedule-builder.tsx +253 -0
- package/src/react/schedule-copy.ts +74 -0
- package/src/react/schedule-summary.ts +100 -0
- package/src/react/screen-bindings.ts +57 -0
- package/src/react/table-config.ts +15 -0
- package/src/server/copy.ts +3 -0
- package/src/server/en-US.ts +1 -0
- package/src/server/mcp.ts +32 -0
- package/src/server/pt-BR.ts +1 -0
- package/src/server/store.ts +8 -0
- package/src/server/validate.ts +43 -0
- package/src/timezone.ts +96 -0
|
@@ -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
|
+
}
|
package/src/engine/types.ts
CHANGED
|
@@ -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.
|
|
@@ -49,6 +51,8 @@ export interface DiscountsScreenCopy {
|
|
|
49
51
|
readonly empty: string;
|
|
50
52
|
/** The exported file's base name, without extension. */
|
|
51
53
|
readonly exportFileName: string;
|
|
54
|
+
/** The header's export trigger. */
|
|
55
|
+
readonly exportAction: string;
|
|
52
56
|
/** While the first page is loading. */
|
|
53
57
|
readonly loading: string;
|
|
54
58
|
/** The read failed: a heading, and the button that tries again. */
|
|
@@ -274,6 +278,7 @@ export interface DiscountsWebCopy {
|
|
|
274
278
|
readonly screen: DiscountsScreenCopy;
|
|
275
279
|
readonly labels: DiscountsVocabularyCopy;
|
|
276
280
|
readonly window: DiscountsWindowCopy;
|
|
281
|
+
readonly schedule: DiscountsScheduleCopy;
|
|
277
282
|
readonly form: DiscountsFormCopy;
|
|
278
283
|
readonly combo: DiscountsComboCopy;
|
|
279
284
|
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
|
-
/**
|
|
253
|
-
|
|
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
|
-
<
|
|
256
|
-
<
|
|
257
|
-
<
|
|
258
|
-
|
|
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;
|