@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.
- package/dist/e2e/globs.d.ts +40 -0
- package/dist/e2e/globs.d.ts.map +1 -0
- package/dist/e2e/globs.js +66 -0
- package/dist/e2e/globs.js.map +1 -0
- package/dist/e2e/index.d.ts +12 -0
- package/dist/e2e/index.d.ts.map +1 -0
- package/dist/e2e/index.js +12 -0
- package/dist/e2e/index.js.map +1 -0
- package/dist/e2e/steps/journey.steps.d.ts +2 -0
- package/dist/e2e/steps/journey.steps.d.ts.map +1 -0
- package/dist/e2e/steps/journey.steps.js +146 -0
- package/dist/e2e/steps/journey.steps.js.map +1 -0
- package/dist/e2e/world.d.ts +76 -0
- package/dist/e2e/world.d.ts.map +1 -0
- package/dist/e2e/world.js +25 -0
- package/dist/e2e/world.js.map +1 -0
- package/features/promotions.feature +52 -0
- package/package.json +28 -9
- package/prisma/discounts.prisma +23 -0
- package/prisma/migrations/20260830120000_discount_weekly_schedule/migration.sql +40 -0
- package/src/e2e/globs.ts +70 -0
- package/src/e2e/index.ts +16 -0
- package/src/e2e/steps/journey.steps.ts +170 -0
- package/src/e2e/world.ts +93 -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/manifest/index.ts +12 -0
- package/src/react/api.ts +13 -0
- package/src/react/copy.ts +3 -0
- package/src/react/create-web-discounts.tsx +39 -1
- package/src/react/discount-actions-menu.tsx +10 -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 +12 -28
- package/src/react/en-US.ts +44 -0
- package/src/react/format.ts +15 -0
- package/src/react/pt-BR.ts +44 -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
package/src/server/validate.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
type DiscountTrigger,
|
|
6
6
|
type DiscountType,
|
|
7
7
|
} from "../engine/kinds";
|
|
8
|
+
import { isUsableSchedule, type DiscountSchedule } from "../engine/schedule";
|
|
8
9
|
import type { ComboRequirement } from "../engine/types";
|
|
9
10
|
import type { DiscountsServerCopy } from "./copy";
|
|
10
11
|
import { comboRequirementsForScope, toComboColumns } from "./validate-combo";
|
|
@@ -50,6 +51,15 @@ export interface DiscountWriteInput {
|
|
|
50
51
|
/** Calendar dates (`YYYY-MM-DD`) as the form sends them, or null. */
|
|
51
52
|
startsAt: string | null;
|
|
52
53
|
endsAt: string | null;
|
|
54
|
+
/**
|
|
55
|
+
* The WEEKLY schedule inside that window (FUT-996), or null for "always".
|
|
56
|
+
* Validated here rather than trusted: it is stored as JSON, so the database
|
|
57
|
+
* cannot check its shape and this is the only gate in front of it.
|
|
58
|
+
*
|
|
59
|
+
* Optional so a caller assembling a write input by hand — a test, a script,
|
|
60
|
+
* an older adopter — does not have to name a field it has no opinion about.
|
|
61
|
+
*/
|
|
62
|
+
schedule?: DiscountSchedule | null;
|
|
53
63
|
minSubtotalCents: number | null;
|
|
54
64
|
usageLimit: number | null;
|
|
55
65
|
perBuyerLimit: number | null;
|
|
@@ -168,6 +178,32 @@ function assertWindow(
|
|
|
168
178
|
return { startsAt, endsAt };
|
|
169
179
|
}
|
|
170
180
|
|
|
181
|
+
/**
|
|
182
|
+
* The weekly schedule, or null (FUT-996).
|
|
183
|
+
*
|
|
184
|
+
* Every failure here is ONE message rather than a per-window field error,
|
|
185
|
+
* because the operator does not edit this as fields: they edit a row of day
|
|
186
|
+
* chips and two clocks, and "a linha 2 não tem dias" points at something the
|
|
187
|
+
* form can highlight while `schedule.windows[1].days` does not.
|
|
188
|
+
*
|
|
189
|
+
* A `to` earlier than `from` is NOT an error — it is a bar shutting at 02:00,
|
|
190
|
+
* which is the most common happy hour there is. The only refusals are the ones
|
|
191
|
+
* that cannot mean anything: no windows at all, a row naming no day, a day off
|
|
192
|
+
* the Monday-first axis, a malformed clock, and a zero-length window.
|
|
193
|
+
*/
|
|
194
|
+
function assertSchedule(
|
|
195
|
+
input: DiscountWriteInput,
|
|
196
|
+
copy: DiscountsServerCopy,
|
|
197
|
+
): DiscountSchedule | null {
|
|
198
|
+
const { schedule } = input;
|
|
199
|
+
// `== null` catches BOTH, deliberately: the field is required on the type,
|
|
200
|
+
// but a caller assembling a write input by hand omits what it does not use,
|
|
201
|
+
// and "absent" and "explicitly none" are the same promotion.
|
|
202
|
+
if (schedule == null) return null;
|
|
203
|
+
if (!isUsableSchedule(schedule)) throw invalid("schedule", copy.invalidSchedule);
|
|
204
|
+
return { windows: schedule.windows.map((w) => ({ days: [...w.days], from: w.from, to: w.to })) };
|
|
205
|
+
}
|
|
206
|
+
|
|
171
207
|
/** A cap of zero is not a cap, it is a discount that can never be used. */
|
|
172
208
|
function assertPositiveOrNull(value: number | null, field: string, message: string): void {
|
|
173
209
|
if (value === null) return;
|
|
@@ -194,6 +230,8 @@ export interface DiscountScalars {
|
|
|
194
230
|
code: string | null;
|
|
195
231
|
startsAt: Date | null;
|
|
196
232
|
endsAt: Date | null;
|
|
233
|
+
/** The weekly schedule, or null. Persisted as a JSON column. */
|
|
234
|
+
schedule: DiscountSchedule | null;
|
|
197
235
|
minSubtotalCents: number | null;
|
|
198
236
|
usageLimit: number | null;
|
|
199
237
|
perBuyerLimit: number | null;
|
|
@@ -233,6 +271,7 @@ export function toDiscountScalars(
|
|
|
233
271
|
const code = assertTrigger(input, copy);
|
|
234
272
|
assertTargets(input, copy);
|
|
235
273
|
const { startsAt, endsAt } = assertWindow(input, copy);
|
|
274
|
+
const schedule = assertSchedule(input, copy);
|
|
236
275
|
assertLimits(input, copy);
|
|
237
276
|
const combo = toComboColumns(input, copy);
|
|
238
277
|
|
|
@@ -249,6 +288,7 @@ export function toDiscountScalars(
|
|
|
249
288
|
code,
|
|
250
289
|
startsAt,
|
|
251
290
|
endsAt,
|
|
291
|
+
schedule,
|
|
252
292
|
minSubtotalCents: input.minSubtotalCents,
|
|
253
293
|
usageLimit: input.usageLimit,
|
|
254
294
|
perBuyerLimit: input.perBuyerLimit,
|
|
@@ -277,6 +317,8 @@ export interface DiscountWriteBody {
|
|
|
277
317
|
code?: string | null;
|
|
278
318
|
startsAt?: string | null;
|
|
279
319
|
endsAt?: string | null;
|
|
320
|
+
/** The weekly schedule, or null/omitted for "always" (FUT-996). */
|
|
321
|
+
schedule?: DiscountSchedule | null;
|
|
280
322
|
minSubtotalCents?: number | null;
|
|
281
323
|
usageLimit?: number | null;
|
|
282
324
|
perBuyerLimit?: number | null;
|
|
@@ -311,6 +353,7 @@ export function toDiscountWriteInput(body: DiscountWriteBody): DiscountWriteInpu
|
|
|
311
353
|
code: orNull(body.code),
|
|
312
354
|
startsAt: orNull(body.startsAt),
|
|
313
355
|
endsAt: orNull(body.endsAt),
|
|
356
|
+
schedule: orNull(body.schedule),
|
|
314
357
|
minSubtotalCents: orNull(body.minSubtotalCents),
|
|
315
358
|
usageLimit: orNull(body.usageLimit),
|
|
316
359
|
perBuyerLimit: orNull(body.perBuyerLimit),
|
package/src/timezone.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { LocalClock } from "./engine/schedule";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resolve an instant into a store's own wall clock (FUT-996).
|
|
5
|
+
*
|
|
6
|
+
* Deliberately NOT part of the engine, and exported from its own subpath rather
|
|
7
|
+
* than the root. The engine's contract is that it reaches no clock and no
|
|
8
|
+
* locale data — it runs in an offline tool generator, a browser bundle and a
|
|
9
|
+
* worker — and this function reads `Intl`, which is exactly the dependency that
|
|
10
|
+
* contract exists to keep out. Everything here produces the two integers the
|
|
11
|
+
* engine consumes; nothing here decides anything about a discount.
|
|
12
|
+
*
|
|
13
|
+
* It lives in the PACKAGE rather than in each host because there must be one
|
|
14
|
+
* implementation. The admin grid's "ativa agora" dot runs in a browser, the
|
|
15
|
+
* price a shopper is charged is computed on a server, and a second copy of
|
|
16
|
+
* "which weekday is it in São Paulo at 23:50" is how those two start
|
|
17
|
+
* disagreeing about the same minute — silently, and only for the hour that
|
|
18
|
+
* matters.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* `Intl` reports a Sunday-first weekday; the schedule axis is Monday-first, the
|
|
23
|
+
* same one `WeekHours` and every hours screen uses. This table is the whole
|
|
24
|
+
* conversion, written as data so it cannot be got subtly wrong by arithmetic.
|
|
25
|
+
*/
|
|
26
|
+
const MONDAY_FIRST: Readonly<Record<string, number>> = {
|
|
27
|
+
Mon: 0,
|
|
28
|
+
Tue: 1,
|
|
29
|
+
Wed: 2,
|
|
30
|
+
Thu: 3,
|
|
31
|
+
Fri: 4,
|
|
32
|
+
Sat: 5,
|
|
33
|
+
Sun: 6,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The store's weekday and minute-of-day at `instant`, or null when the zone is
|
|
38
|
+
* not one this runtime knows.
|
|
39
|
+
*
|
|
40
|
+
* NULL rather than a fallback, and the CALLER decides what that means — which
|
|
41
|
+
* is the only honest split, because the two callers need opposite things. A
|
|
42
|
+
* cart must keep pricing (a scheduled rule fires; see `scheduleCovers`), and a
|
|
43
|
+
* teaser must stay quiet rather than promise an hour it cannot name. A default
|
|
44
|
+
* chosen here would silently impose one of those on both.
|
|
45
|
+
*/
|
|
46
|
+
export function resolveLocalClock(instant: Date, timeZone: string): LocalClock | null {
|
|
47
|
+
try {
|
|
48
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
49
|
+
timeZone,
|
|
50
|
+
weekday: "short",
|
|
51
|
+
hour: "2-digit",
|
|
52
|
+
minute: "2-digit",
|
|
53
|
+
hour12: false,
|
|
54
|
+
}).formatToParts(instant);
|
|
55
|
+
const read = (type: string): string =>
|
|
56
|
+
parts.find((part) => part.type === type)?.value ?? "";
|
|
57
|
+
const weekday = MONDAY_FIRST[read("weekday")];
|
|
58
|
+
const hour = Number(read("hour"));
|
|
59
|
+
const minute = Number(read("minute"));
|
|
60
|
+
if (weekday === undefined || !Number.isFinite(hour) || !Number.isFinite(minute)) return null;
|
|
61
|
+
// `hour12: false` yields 24 for midnight in some ICU versions — a real,
|
|
62
|
+
// once-a-day discrepancy that would put every midnight instant outside
|
|
63
|
+
// every window.
|
|
64
|
+
return { weekday, minutes: (hour % 24) * 60 + minute };
|
|
65
|
+
} catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The zone as a PERSON would read it, in their own language — "Horário Padrão
|
|
72
|
+
* de Brasília", "Brasilia Standard Time".
|
|
73
|
+
*
|
|
74
|
+
* Derived rather than configured, and that is the point: a host passing a
|
|
75
|
+
* display name would be passing COPY, which this repo's own rules say must
|
|
76
|
+
* ship bilingual. `Intl` already knows every zone's name in every locale, so
|
|
77
|
+
* asking it is both less to maintain and correct in languages nobody here
|
|
78
|
+
* speaks.
|
|
79
|
+
*
|
|
80
|
+
* Falls back to the IANA id with its separators softened ("America/Sao_Paulo"
|
|
81
|
+
* → "Sao Paulo"), which is wrong-looking but never blank — a caption is not
|
|
82
|
+
* worth an exception for.
|
|
83
|
+
*/
|
|
84
|
+
export function timeZoneLabel(timeZone: string, locale: string): string {
|
|
85
|
+
try {
|
|
86
|
+
const parts = new Intl.DateTimeFormat(locale, {
|
|
87
|
+
timeZone,
|
|
88
|
+
timeZoneName: "longGeneric",
|
|
89
|
+
}).formatToParts(new Date());
|
|
90
|
+
const name = parts.find((part) => part.type === "timeZoneName")?.value;
|
|
91
|
+
if (name !== undefined && name !== "") return name;
|
|
92
|
+
} catch {
|
|
93
|
+
// fall through
|
|
94
|
+
}
|
|
95
|
+
return (timeZone.split("/").pop() ?? timeZone).replace(/_/g, " ");
|
|
96
|
+
}
|