@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
|
@@ -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;
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
} from "@12-apps/ui/form/total-form";
|
|
13
13
|
import { Stack } from "@12-apps/ui/mui/Stack";
|
|
14
14
|
|
|
15
|
+
import type { DiscountSchedule } from "../engine/schedule";
|
|
15
16
|
import type { ComboRequirement } from "../engine/types";
|
|
16
17
|
|
|
17
18
|
import type { DiscountFormPayload, DiscountsApiClient, DiscountWireRecord, WireTargetGroup } from "./api";
|
|
@@ -28,6 +29,7 @@ import {
|
|
|
28
29
|
type CurrencyFieldComponent,
|
|
29
30
|
} from "./discount-form-fields";
|
|
30
31
|
import { DiscountTargetPicker } from "./discount-target-picker";
|
|
32
|
+
import { blankWindow } from "./schedule-builder";
|
|
31
33
|
import { validate, type FormTargets } from "./discount-form-validate";
|
|
32
34
|
import { initialValues, type DiscountFormValues } from "./discount-form-values";
|
|
33
35
|
import { kindOptions, typeAndScopeFor } from "./form-kind";
|
|
@@ -61,17 +63,36 @@ function useNonStringState(editing: DiscountWireRecord | null) {
|
|
|
61
63
|
const [comboRequirements, setComboRequirements] = useState<ComboRequirement[]>(() =>
|
|
62
64
|
editing?.comboRequirements ? [...editing.comboRequirements] : [],
|
|
63
65
|
);
|
|
66
|
+
// The weekly schedule (FUT-996) is the fourth: a nested structure edited with
|
|
67
|
+
// chips and clocks, which `total-form`'s strings cannot hold.
|
|
68
|
+
//
|
|
69
|
+
// Its ON/OFF is kept SEPARATE from its contents on purpose. An operator who
|
|
70
|
+
// switches back to "Sempre" and then changes their mind has not lost the days
|
|
71
|
+
// they picked — the rows survive the toggle and only the SUBMIT reads the
|
|
72
|
+
// switch. Collapsing the two would make the radio destructive.
|
|
73
|
+
const [scheduleEnabled, setScheduleEnabled] = useState<boolean>(
|
|
74
|
+
() => (editing?.schedule?.windows.length ?? 0) > 0,
|
|
75
|
+
);
|
|
76
|
+
const [schedule, setSchedule] = useState<DiscountSchedule>(() =>
|
|
77
|
+
editing?.schedule && editing.schedule.windows.length > 0
|
|
78
|
+
? { windows: editing.schedule.windows.map((w) => ({ ...w, days: [...w.days] })) }
|
|
79
|
+
: { windows: [blankWindow()] },
|
|
80
|
+
);
|
|
64
81
|
return {
|
|
65
82
|
active,
|
|
66
83
|
stackable,
|
|
67
84
|
categoryIds,
|
|
68
85
|
menuItemIds,
|
|
69
86
|
comboRequirements,
|
|
87
|
+
scheduleEnabled,
|
|
88
|
+
schedule,
|
|
70
89
|
setActive,
|
|
71
90
|
setStackable,
|
|
72
91
|
setCategoryIds,
|
|
73
92
|
setMenuItemIds,
|
|
74
93
|
setComboRequirements,
|
|
94
|
+
setScheduleEnabled,
|
|
95
|
+
setSchedule,
|
|
75
96
|
};
|
|
76
97
|
}
|
|
77
98
|
|
|
@@ -88,6 +109,8 @@ export interface DiscountFormProps {
|
|
|
88
109
|
onSaved: () => void;
|
|
89
110
|
/** Where a refused or failed write is reported, beyond the operator's screen. */
|
|
90
111
|
onError: (error: unknown, context: string) => void;
|
|
112
|
+
/** The store's timezone as a person says it — shown under the clocks. */
|
|
113
|
+
timezoneLabel?: string;
|
|
91
114
|
}
|
|
92
115
|
|
|
93
116
|
/**
|
|
@@ -110,7 +133,10 @@ function makeSubmit(
|
|
|
110
133
|
menuItemIds: extra.menuItemIds,
|
|
111
134
|
comboRequirements: extra.comboRequirements,
|
|
112
135
|
};
|
|
113
|
-
const invalid = validate(values, targets, formatters, copy
|
|
136
|
+
const invalid = validate(values, targets, formatters, copy, {
|
|
137
|
+
enabled: extra.scheduleEnabled,
|
|
138
|
+
schedule: extra.schedule,
|
|
139
|
+
});
|
|
114
140
|
if (Object.keys(invalid).length > 0) {
|
|
115
141
|
setFieldErrors(invalid);
|
|
116
142
|
setError(copy.form.reviewFields);
|
|
@@ -118,6 +144,9 @@ function makeSubmit(
|
|
|
118
144
|
}
|
|
119
145
|
const payload: DiscountFormPayload = {
|
|
120
146
|
...values,
|
|
147
|
+
// The switch decides whether the rows are sent at all — see the state
|
|
148
|
+
// hook for why the two are separate.
|
|
149
|
+
schedule: extra.scheduleEnabled ? extra.schedule : null,
|
|
121
150
|
// The one place the form's question becomes the API's two columns. Every
|
|
122
151
|
// other consumer — the write path, the evaluator, the CHECK constraints —
|
|
123
152
|
// still sees the `type`/`scope` pair it always did.
|
|
@@ -173,7 +202,14 @@ export function DiscountForm(props: DiscountFormProps): JSX.Element {
|
|
|
173
202
|
/>
|
|
174
203
|
<ComboCapField copy={copy} />
|
|
175
204
|
<TriggerFields copy={copy} />
|
|
176
|
-
<WindowFields
|
|
205
|
+
<WindowFields
|
|
206
|
+
copy={copy}
|
|
207
|
+
schedule={extra.schedule}
|
|
208
|
+
scheduleEnabled={extra.scheduleEnabled}
|
|
209
|
+
timezoneLabel={props.timezoneLabel ?? null}
|
|
210
|
+
onScheduleChange={extra.setSchedule}
|
|
211
|
+
onScheduleEnabledChange={extra.setScheduleEnabled}
|
|
212
|
+
/>
|
|
177
213
|
<LimitFields copy={copy} currencyField={currencyField} />
|
|
178
214
|
<DiscountSwitches
|
|
179
215
|
copy={copy}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { useState, type JSX } from "react";
|
|
4
4
|
import { useSearchParams } from "react-router-dom";
|
|
5
5
|
|
|
6
6
|
import AddIcon from "@mui/icons-material/Add";
|
|
@@ -32,13 +32,13 @@ import { DiscountForm } from "./discount-form";
|
|
|
32
32
|
import type { CurrencyFieldComponent } from "./discount-form-fields";
|
|
33
33
|
import { DiscountListCard, discountCells } from "./discount-list-card";
|
|
34
34
|
import type { DiscountsFormatters } from "./format";
|
|
35
|
-
import {
|
|
35
|
+
import { useDiscountRows, type DiscountListItem } from "./row";
|
|
36
|
+
import { toMenuBinding, usePagedServer } from "./screen-bindings";
|
|
36
37
|
import {
|
|
37
38
|
discountColumns,
|
|
38
39
|
discountExportColumns,
|
|
39
40
|
discountFilters,
|
|
40
41
|
discountsAppliedState,
|
|
41
|
-
discountsQueryToParams,
|
|
42
42
|
discountsSearch,
|
|
43
43
|
} from "./table-config";
|
|
44
44
|
import { useDiscountsData } from "./use-discounts-data";
|
|
@@ -55,6 +55,10 @@ export interface DiscountsScreenProps {
|
|
|
55
55
|
api: DiscountsApiClient;
|
|
56
56
|
copy: DiscountsWebCopy;
|
|
57
57
|
formatters: DiscountsFormatters;
|
|
58
|
+
/** The store's IANA zone, for the grid's live "ativa agora" dot (FUT-996). */
|
|
59
|
+
timezone?: string;
|
|
60
|
+
/** The store's timezone as a person says it, for the schedule editor. */
|
|
61
|
+
timezoneLabel?: string;
|
|
58
62
|
currencyField: CurrencyFieldComponent;
|
|
59
63
|
onError: (error: unknown, context: string) => void;
|
|
60
64
|
/** The crumbs above the title. The host owns its own information hierarchy. */
|
|
@@ -142,7 +146,7 @@ function HeaderControls({
|
|
|
142
146
|
}
|
|
143
147
|
|
|
144
148
|
/** Everything the row menu, both cards and the edit form are bound to. */
|
|
145
|
-
type MenuBinding = Omit<Parameters<typeof DiscountActionsMenu>[0], "row">;
|
|
149
|
+
export type MenuBinding = Omit<Parameters<typeof DiscountActionsMenu>[0], "row">;
|
|
146
150
|
|
|
147
151
|
/**
|
|
148
152
|
* The grid, split from the page container so neither crosses the size gate.
|
|
@@ -308,21 +312,14 @@ function ScreenBody({
|
|
|
308
312
|
}
|
|
309
313
|
|
|
310
314
|
export function DiscountsScreen(props: DiscountsScreenProps): JSX.Element {
|
|
311
|
-
const { api, copy, formatters,
|
|
315
|
+
const { api, copy, formatters, onError, breadcrumb, timezone } = props;
|
|
312
316
|
const [searchParams] = useSearchParams();
|
|
313
317
|
const data = useDiscountsData(api, discountsSearch(searchParams), onError);
|
|
314
318
|
const [createOpen, setCreateOpen] = useState(false);
|
|
315
319
|
const [visibleRows, setVisibleRows] = useState<DiscountListItem[]>([]);
|
|
316
320
|
const bulkDelete = useBulkDelete(api, copy, data.refresh, onError);
|
|
317
321
|
|
|
318
|
-
|
|
319
|
-
// "now"s could disagree about which of them is still running.
|
|
320
|
-
const rows = useMemo(() => {
|
|
321
|
-
const now = new Date();
|
|
322
|
-
return (data.page?.data ?? []).map((record) =>
|
|
323
|
-
toListItem(record, formatters, copy, now),
|
|
324
|
-
);
|
|
325
|
-
}, [data.page, formatters, copy]);
|
|
322
|
+
const rows = useDiscountRows(data.page?.data, formatters, copy, timezone);
|
|
326
323
|
|
|
327
324
|
// Seeded ONCE from the URL. Re-applying it on every render would wipe the
|
|
328
325
|
// operator's column-visibility choices each time the query synced back.
|
|
@@ -330,21 +327,8 @@ export function DiscountsScreen(props: DiscountsScreenProps): JSX.Element {
|
|
|
330
327
|
discountsAppliedState(searchParams, copy),
|
|
331
328
|
);
|
|
332
329
|
|
|
333
|
-
const server =
|
|
334
|
-
|
|
335
|
-
page: data.page?.pagination.page ?? 1,
|
|
336
|
-
pageSize: data.page?.pagination.pageSize ?? 20,
|
|
337
|
-
toParams: discountsQueryToParams,
|
|
338
|
-
});
|
|
339
|
-
|
|
340
|
-
const menu: MenuBinding = {
|
|
341
|
-
api,
|
|
342
|
-
copy,
|
|
343
|
-
formatters,
|
|
344
|
-
currencyField,
|
|
345
|
-
groups: data.groups,
|
|
346
|
-
onError,
|
|
347
|
-
};
|
|
330
|
+
const server = usePagedServer(data.page?.pagination);
|
|
331
|
+
const menu = toMenuBinding(props, data.groups);
|
|
348
332
|
|
|
349
333
|
if (data.loading) return <LoadingState dataTestId="discounts-loading" />;
|
|
350
334
|
if (data.error !== null) {
|
package/src/react/en-US.ts
CHANGED
|
@@ -88,6 +88,50 @@ export const EN_US_DISCOUNTS_WEB_COPY: DiscountsWebCopy = {
|
|
|
88
88
|
until: "Until {date}",
|
|
89
89
|
between: "{from} to {to}",
|
|
90
90
|
},
|
|
91
|
+
schedule: {
|
|
92
|
+
sectionTitle: "When it applies",
|
|
93
|
+
periodTitle: "Period",
|
|
94
|
+
periodHint: "Leave blank to run with no end date.",
|
|
95
|
+
repetitionTitle: "Repeats",
|
|
96
|
+
always: "Always, within the period",
|
|
97
|
+
specific: "On specific days and hours",
|
|
98
|
+
builderTitle: "Days and hours",
|
|
99
|
+
addWindow: "Add another time",
|
|
100
|
+
removeWindow: "Remove time",
|
|
101
|
+
fromLabel: "From",
|
|
102
|
+
toLabel: "to",
|
|
103
|
+
presetEveryDay: "Every day",
|
|
104
|
+
presetWeekdays: "Mon to Fri",
|
|
105
|
+
presetWeekend: "Weekend",
|
|
106
|
+
dayShort: ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
|
|
107
|
+
dayLong: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"],
|
|
108
|
+
dayEvery: [
|
|
109
|
+
"Every Monday",
|
|
110
|
+
"Every Tuesday",
|
|
111
|
+
"Every Wednesday",
|
|
112
|
+
"Every Thursday",
|
|
113
|
+
"Every Friday",
|
|
114
|
+
"Every Saturday",
|
|
115
|
+
"Every Sunday",
|
|
116
|
+
],
|
|
117
|
+
listSeparator: ", ",
|
|
118
|
+
listLast: " and ",
|
|
119
|
+
allDays: "Every day",
|
|
120
|
+
weekdays: "Monday to Friday",
|
|
121
|
+
weekend: "Saturday and Sunday",
|
|
122
|
+
summary: "{days}, from {from} to {to}.",
|
|
123
|
+
summaryOvernight: "{days}, from {from} to {to} the next day.",
|
|
124
|
+
timezoneNote: "Time zone: {timezone}.",
|
|
125
|
+
guaranteeNote: "The price is held from the moment the item went into the cart.",
|
|
126
|
+
orderScopeNote:
|
|
127
|
+
"Applies to orders placed within these hours. Unlike the other scopes, which follow the moment each item went into the cart.",
|
|
128
|
+
windowWithSchedule: "{window} · {schedule}",
|
|
129
|
+
moreWindows: "+{count}",
|
|
130
|
+
activeNow: "Running now",
|
|
131
|
+
daysRequired: "Pick at least one day.",
|
|
132
|
+
timesRequired: "Enter a start and end time that differ.",
|
|
133
|
+
windowsRequired: "Add at least one time.",
|
|
134
|
+
},
|
|
91
135
|
form: {
|
|
92
136
|
createTitle: "New discount",
|
|
93
137
|
editTitle: "Edit discount",
|
package/src/react/format.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { discountWindowState, type DiscountWindowState } from "../engine/kinds";
|
|
2
|
+
import type { DiscountSchedule } from "../engine/schedule";
|
|
2
3
|
|
|
3
4
|
import { fill, type DiscountsWebCopy } from "./copy";
|
|
5
|
+
import { formatScheduleCell } from "./schedule-summary";
|
|
4
6
|
|
|
5
7
|
/**
|
|
6
8
|
* Money, percentages and dates — as ONE locale's reader expects them, and as
|
|
@@ -103,6 +105,19 @@ export function formatDiscountValue(
|
|
|
103
105
|
* mean very different things when it is not running yet.
|
|
104
106
|
*/
|
|
105
107
|
export function formatWindow(
|
|
108
|
+
row: { startsAt: string | null; endsAt: string | null; schedule?: DiscountSchedule | null },
|
|
109
|
+
formatters: DiscountsFormatters,
|
|
110
|
+
copy: DiscountsWebCopy,
|
|
111
|
+
): string {
|
|
112
|
+
const period = formatPeriod(row, formatters, copy);
|
|
113
|
+
const schedule = formatScheduleCell(row.schedule, copy);
|
|
114
|
+
// Both facts in one cell (FUT-996): the campaign period answers "for how
|
|
115
|
+
// long", the schedule answers "and when within that", and a cell showing only
|
|
116
|
+
// the first would say a happy hour runs all day for three months.
|
|
117
|
+
return schedule === null ? period : fill(copy.schedule.windowWithSchedule, { window: period, schedule });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function formatPeriod(
|
|
106
121
|
row: { startsAt: string | null; endsAt: string | null },
|
|
107
122
|
formatters: DiscountsFormatters,
|
|
108
123
|
copy: DiscountsWebCopy,
|
package/src/react/pt-BR.ts
CHANGED
|
@@ -79,6 +79,50 @@ export const PT_BR_DISCOUNTS_WEB_COPY: DiscountsWebCopy = {
|
|
|
79
79
|
until: "Até {date}",
|
|
80
80
|
between: "{from} a {to}",
|
|
81
81
|
},
|
|
82
|
+
schedule: {
|
|
83
|
+
sectionTitle: "Quando vale",
|
|
84
|
+
periodTitle: "Período",
|
|
85
|
+
periodHint: "Deixe em branco para valer sem prazo.",
|
|
86
|
+
repetitionTitle: "Repetição",
|
|
87
|
+
always: "Sempre, dentro do período",
|
|
88
|
+
specific: "Em dias e horários específicos",
|
|
89
|
+
builderTitle: "Dias e horários",
|
|
90
|
+
addWindow: "Adicionar outro horário",
|
|
91
|
+
removeWindow: "Remover horário",
|
|
92
|
+
fromLabel: "Das",
|
|
93
|
+
toLabel: "às",
|
|
94
|
+
presetEveryDay: "Todos os dias",
|
|
95
|
+
presetWeekdays: "Seg a Sex",
|
|
96
|
+
presetWeekend: "Fim de semana",
|
|
97
|
+
dayShort: ["Seg", "Ter", "Qua", "Qui", "Sex", "Sáb", "Dom"],
|
|
98
|
+
dayLong: ["segunda", "terça", "quarta", "quinta", "sexta", "sábado", "domingo"],
|
|
99
|
+
dayEvery: [
|
|
100
|
+
"Toda segunda",
|
|
101
|
+
"Toda terça",
|
|
102
|
+
"Toda quarta",
|
|
103
|
+
"Toda quinta",
|
|
104
|
+
"Toda sexta",
|
|
105
|
+
"Todo sábado",
|
|
106
|
+
"Todo domingo",
|
|
107
|
+
],
|
|
108
|
+
listSeparator: ", ",
|
|
109
|
+
listLast: " e ",
|
|
110
|
+
allDays: "Todos os dias",
|
|
111
|
+
weekdays: "De segunda a sexta",
|
|
112
|
+
weekend: "Sábado e domingo",
|
|
113
|
+
summary: "{days}, das {from} às {to}.",
|
|
114
|
+
summaryOvernight: "{days}, das {from} às {to} do dia seguinte.",
|
|
115
|
+
timezoneNote: "Fuso horário: {timezone}.",
|
|
116
|
+
guaranteeNote: "O preço é garantido pelo horário em que o item entrou no carrinho.",
|
|
117
|
+
orderScopeNote:
|
|
118
|
+
"Vale para pedidos fechados dentro do horário. Diferente das outras abrangências, que valem pelo horário em que o item entrou no carrinho.",
|
|
119
|
+
windowWithSchedule: "{window} · {schedule}",
|
|
120
|
+
moreWindows: "+{count}",
|
|
121
|
+
activeNow: "Ativa agora",
|
|
122
|
+
daysRequired: "Escolha ao menos um dia.",
|
|
123
|
+
timesRequired: "Informe um horário de início e de fim diferentes.",
|
|
124
|
+
windowsRequired: "Adicione ao menos um horário.",
|
|
125
|
+
},
|
|
82
126
|
form: {
|
|
83
127
|
createTitle: "Novo desconto",
|
|
84
128
|
editTitle: "Editar desconto",
|
package/src/react/row.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
|
|
1
3
|
import type { DiscountWindowState } from "../engine/kinds";
|
|
4
|
+
import { scheduleCovers } from "../engine/schedule";
|
|
5
|
+
import { resolveLocalClock } from "../timezone";
|
|
2
6
|
|
|
3
7
|
import type { DiscountWireRecord } from "./api";
|
|
4
8
|
import type { DiscountsWebCopy } from "./copy";
|
|
@@ -40,6 +44,23 @@ export interface DiscountListItem extends Record<string, unknown> {
|
|
|
40
44
|
windowLabel: string;
|
|
41
45
|
/** Where it sits relative to its window, as a stable wire token. */
|
|
42
46
|
windowState: DiscountWindowState;
|
|
47
|
+
/**
|
|
48
|
+
* Whether this rule's weekly SCHEDULE is running at this moment (FUT-996).
|
|
49
|
+
*
|
|
50
|
+
* Deliberately not folded into {@link windowState}. Those three values are
|
|
51
|
+
* resolved by a store-side SQL predicate so the grid's filter pill and the
|
|
52
|
+
* badge on a row cannot disagree — and a recurrence predicate is not
|
|
53
|
+
* expressible there. Widening the enum would mean offering a filter the
|
|
54
|
+
* backend cannot serve.
|
|
55
|
+
*
|
|
56
|
+
* So the pill keeps meaning the CAMPAIGN, and this answers the operator's
|
|
57
|
+
* actual question ("is my happy hour on right now?") as a row-level dot
|
|
58
|
+
* computed here. The cost, stated: you cannot FILTER by it.
|
|
59
|
+
*
|
|
60
|
+
* `false` for a rule with no schedule — there is nothing intermittent to
|
|
61
|
+
* report, and a dot on every row would say nothing.
|
|
62
|
+
*/
|
|
63
|
+
activeNow: boolean;
|
|
43
64
|
/** The record itself, for the edit dialog to re-seed from. */
|
|
44
65
|
record: DiscountWireRecord;
|
|
45
66
|
}
|
|
@@ -54,6 +75,8 @@ export function toListItem(
|
|
|
54
75
|
formatters: DiscountsFormatters,
|
|
55
76
|
copy: DiscountsWebCopy,
|
|
56
77
|
now: Date,
|
|
78
|
+
/** The store's IANA zone. Omitted ⇒ no live dot; see {@link DiscountListItem.activeNow}. */
|
|
79
|
+
timezone?: string,
|
|
57
80
|
): DiscountListItem {
|
|
58
81
|
return {
|
|
59
82
|
...record,
|
|
@@ -61,6 +84,47 @@ export function toListItem(
|
|
|
61
84
|
valueLabel: formatDiscountValue(record, formatters),
|
|
62
85
|
windowLabel: formatWindow(record, formatters, copy),
|
|
63
86
|
windowState: windowStateOf(record, now),
|
|
87
|
+
activeNow: isRunningNow(record, now, timezone),
|
|
64
88
|
record,
|
|
65
89
|
};
|
|
66
90
|
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Is this rule's schedule running right now, in the STORE's timezone?
|
|
94
|
+
*
|
|
95
|
+
* Answered with the same `scheduleCovers` the evaluator screens with — never a
|
|
96
|
+
* second implementation, or the dot and the price a shopper is charged would
|
|
97
|
+
* disagree about the same minute.
|
|
98
|
+
*/
|
|
99
|
+
function isRunningNow(
|
|
100
|
+
record: DiscountWireRecord,
|
|
101
|
+
now: Date,
|
|
102
|
+
timezone: string | undefined,
|
|
103
|
+
): boolean {
|
|
104
|
+
if (record.schedule == null || timezone === undefined) return false;
|
|
105
|
+
const clock = resolveLocalClock(now, timezone);
|
|
106
|
+
// An unresolvable zone means "we do not know", which must not be drawn as a
|
|
107
|
+
// confident "on" — `scheduleCovers` answers TRUE for a null clock because a
|
|
108
|
+
// cart must keep pricing, and that is the wrong default for an indicator.
|
|
109
|
+
return clock !== null && scheduleCovers(record.schedule, clock);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The grid's rows, derived once per page load.
|
|
114
|
+
*
|
|
115
|
+
* ONE instant for the whole render: two rows judged against two different
|
|
116
|
+
* "now"s could disagree about which of them is still running, and on a list of
|
|
117
|
+
* forty that difference is invisible. A hook rather than an inline `useMemo`
|
|
118
|
+
* because {@link DiscountsScreen} sits at the size gate's ceiling.
|
|
119
|
+
*/
|
|
120
|
+
export function useDiscountRows(
|
|
121
|
+
records: readonly DiscountWireRecord[] | undefined,
|
|
122
|
+
formatters: DiscountsFormatters,
|
|
123
|
+
copy: DiscountsWebCopy,
|
|
124
|
+
timezone: string | undefined,
|
|
125
|
+
): DiscountListItem[] {
|
|
126
|
+
return useMemo(() => {
|
|
127
|
+
const now = new Date();
|
|
128
|
+
return (records ?? []).map((record) => toListItem(record, formatters, copy, now, timezone));
|
|
129
|
+
}, [records, formatters, copy, timezone]);
|
|
130
|
+
}
|