@12-apps/discounts 1.16.0 → 1.18.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/README.md +23 -7
- package/package.json +6 -6
- package/src/react/api.ts +6 -2
- package/src/react/clock-input.tsx +107 -0
- package/src/react/combo-cart-preview.tsx +189 -0
- package/src/react/combo-preview-lines.ts +171 -0
- package/src/react/combo-preview.ts +217 -0
- package/src/react/combo-reward-copy.ts +59 -0
- package/src/react/combo-reward-fields.tsx +162 -0
- package/src/react/combo-slot-builder.tsx +73 -27
- package/src/react/copy.ts +44 -6
- package/src/react/date-field.tsx +141 -0
- package/src/react/discount-actions-menu.tsx +20 -26
- package/src/react/discount-dialog.tsx +67 -0
- package/src/react/discount-form-fields.tsx +47 -84
- package/src/react/discount-form-validate.ts +78 -17
- package/src/react/discount-form-values.ts +1 -1
- package/src/react/discount-form.tsx +68 -57
- package/src/react/discount-target-picker.tsx +1 -1
- package/src/react/discounts-screen.tsx +11 -17
- package/src/react/en-US.ts +43 -1
- package/src/react/form-kind.ts +42 -48
- package/src/react/free-units-builder.tsx +20 -13
- package/src/react/kind-field.tsx +71 -0
- package/src/react/number-fields.tsx +142 -0
- package/src/react/pt-BR.ts +44 -2
- package/src/react/schedule-builder.tsx +181 -37
- package/src/react/schedule-copy.ts +4 -0
- package/src/react/table-config.ts +15 -1
- package/src/server/collections.ts +20 -0
- package/src/server/index.ts +1 -0
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import { minLength, required } from "@12-apps/forms-core";
|
|
2
|
+
|
|
1
3
|
import { toMinutes, type DiscountSchedule } from "../engine/schedule";
|
|
2
4
|
import type { ComboRequirement } from "../engine/types";
|
|
3
5
|
|
|
4
6
|
import { comboUnits } from "./combo-slot-builder";
|
|
5
7
|
import { fill, type DiscountsWebCopy } from "./copy";
|
|
8
|
+
import { isCompleteMaskedDay, isIsoDay } from "./date-field";
|
|
6
9
|
import type { DiscountFormValues } from "./discount-form-values";
|
|
7
10
|
import { isComboKind, typeAndScopeFor } from "./form-kind";
|
|
8
11
|
import type { DiscountsFormatters } from "./format";
|
|
@@ -19,12 +22,40 @@ import type { DiscountsFormatters } from "./format";
|
|
|
19
22
|
* inputs red.
|
|
20
23
|
*/
|
|
21
24
|
|
|
22
|
-
/**
|
|
25
|
+
/**
|
|
26
|
+
* A whole number above zero, as typed. `null` when it is neither.
|
|
27
|
+
*
|
|
28
|
+
* Digits only (FUT-4143): `Number` reads "2.0" and "1e1" as whole numbers, and
|
|
29
|
+
* a count an operator typed with a separator in it was meant as something else.
|
|
30
|
+
*/
|
|
23
31
|
function positiveCount(typed: string): number | null {
|
|
24
32
|
const trimmed = typed.trim();
|
|
25
|
-
if (trimmed
|
|
33
|
+
if (!/^\d+$/u.test(trimmed)) return null;
|
|
26
34
|
const value = Number(trimmed);
|
|
27
|
-
return
|
|
35
|
+
return value > 0 ? value : null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The name: present, and at least two characters, in the pack's words. */
|
|
39
|
+
function checkName(typed: string, copy: DiscountsWebCopy): Record<string, string> {
|
|
40
|
+
const refused =
|
|
41
|
+
required(copy.form.nameRequired)(typed) ?? minLength(2, copy.form.nameTooShort)(typed.trim());
|
|
42
|
+
return refused === undefined ? {} : { name: refused };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The two redemption caps. Blank is "no cap"; anything else is a whole number
|
|
47
|
+
* from 1, so "2,5" is refused here instead of reaching the wire as 2 — or, in a
|
|
48
|
+
* browser that read the comma away, as 25.
|
|
49
|
+
*/
|
|
50
|
+
function checkLimits(values: DiscountFormValues, copy: DiscountsWebCopy): Record<string, string> {
|
|
51
|
+
const errors: Record<string, string> = {};
|
|
52
|
+
if (values.usageLimit.trim() !== "" && positiveCount(values.usageLimit) === null) {
|
|
53
|
+
errors.usageLimit = copy.form.invalidUsageLimit;
|
|
54
|
+
}
|
|
55
|
+
if (values.perBuyerLimit.trim() !== "" && positiveCount(values.perBuyerLimit) === null) {
|
|
56
|
+
errors.perBuyerLimit = copy.form.invalidPerBuyerLimit;
|
|
57
|
+
}
|
|
58
|
+
return errors;
|
|
28
59
|
}
|
|
29
60
|
|
|
30
61
|
/** A money field that has to hold something above zero. */
|
|
@@ -77,8 +108,9 @@ function checkPercent(
|
|
|
77
108
|
/**
|
|
78
109
|
* The reward, checked against the input the chosen KIND actually mounted.
|
|
79
110
|
*
|
|
80
|
-
* `type` is derived rather than read: a combo's reward is a percentage
|
|
81
|
-
* amount, and which one it is lives in
|
|
111
|
+
* `type` is derived rather than read: a combo's reward is a percentage, an
|
|
112
|
+
* amount or a closed price (FUT-4417), and which one it is lives in
|
|
113
|
+
* `comboReward` — so the check runs off
|
|
82
114
|
* the same mapping the submit does, and the two cannot disagree about which
|
|
83
115
|
* input holds the number.
|
|
84
116
|
*/
|
|
@@ -134,6 +166,28 @@ function checkCombo(
|
|
|
134
166
|
return errors;
|
|
135
167
|
}
|
|
136
168
|
|
|
169
|
+
/**
|
|
170
|
+
* Início and Término. Each is blank or a whole real ISO day — the date box
|
|
171
|
+
* holds its typed text until it is one (FUT-4156) — and only two real days are
|
|
172
|
+
* compared. The window is half-open [start, end): equal dates are an EMPTY
|
|
173
|
+
* window, so the rule is "after", not "not before".
|
|
174
|
+
*/
|
|
175
|
+
function checkPeriod(values: DiscountFormValues, copy: DiscountsWebCopy): Record<string, string> {
|
|
176
|
+
const errors: Record<string, string> = {};
|
|
177
|
+
for (const name of ["startsAt", "endsAt"] as const) {
|
|
178
|
+
if (values[name] === "" || isIsoDay(values[name])) continue;
|
|
179
|
+
// A whole DD/MM/AAAA that is no day does not exist; anything shorter is
|
|
180
|
+
// simply not finished, and is told so.
|
|
181
|
+
errors[name] = isCompleteMaskedDay(values[name])
|
|
182
|
+
? copy.form.invalidDate
|
|
183
|
+
: copy.form.dateIncomplete;
|
|
184
|
+
}
|
|
185
|
+
if (isIsoDay(values.startsAt) && isIsoDay(values.endsAt) && values.endsAt <= values.startsAt) {
|
|
186
|
+
errors.endsAt = copy.form.endsBeforeStarts;
|
|
187
|
+
}
|
|
188
|
+
return errors;
|
|
189
|
+
}
|
|
190
|
+
|
|
137
191
|
/** What the pickers and the builder currently hold, in one argument. */
|
|
138
192
|
export interface FormTargets {
|
|
139
193
|
categoryIds: string[];
|
|
@@ -164,13 +218,19 @@ function checkSchedule(
|
|
|
164
218
|
if (windows.some((window) => window.days.length === 0)) {
|
|
165
219
|
return { schedule: copy.schedule.daysRequired };
|
|
166
220
|
}
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
221
|
+
const ends = windows.flatMap((window) => [window.from, window.to]);
|
|
222
|
+
// Unfinished first: "1:00" mid-edit is not a pair of equal ends, it is a
|
|
223
|
+
// time nobody has finished typing.
|
|
224
|
+
if (ends.some((end) => !/^\d{2}:\d{2}$/u.test(end))) {
|
|
225
|
+
return { schedule: copy.schedule.timeIncomplete };
|
|
226
|
+
}
|
|
227
|
+
// Then a whole HH:MM that no day has (25:00), in the clock's own words —
|
|
228
|
+
// "start and end must differ" would be untrue of it.
|
|
229
|
+
if (ends.some((end) => toMinutes(end) === null)) {
|
|
230
|
+
return { schedule: copy.schedule.invalidTime };
|
|
231
|
+
}
|
|
232
|
+
const sameEnds = windows.some((window) => window.from === window.to);
|
|
233
|
+
return sameEnds ? { schedule: copy.schedule.timesRequired } : {};
|
|
174
234
|
}
|
|
175
235
|
|
|
176
236
|
/** The schedule editor's state: its switch and its rows, kept separate. */
|
|
@@ -187,8 +247,13 @@ export function validate(
|
|
|
187
247
|
scheduleState: ScheduleState,
|
|
188
248
|
): Record<string, string> {
|
|
189
249
|
const slots = targets.comboRequirements;
|
|
250
|
+
// Every rule in ONE pass, the name's included (FUT-4143): the name used to be
|
|
251
|
+
// the container's schema, which stops before this function runs, so a 150%
|
|
252
|
+
// sat unmarked beside a red name until the name was fixed.
|
|
190
253
|
const errors: Record<string, string> = {
|
|
254
|
+
...checkName(values.name, copy),
|
|
191
255
|
...checkValue(values, slots, formatters, copy),
|
|
256
|
+
...checkLimits(values, copy),
|
|
192
257
|
...checkCombo(values, slots, copy),
|
|
193
258
|
...checkSchedule(scheduleState, copy),
|
|
194
259
|
};
|
|
@@ -202,11 +267,7 @@ export function validate(
|
|
|
202
267
|
if (scope === "ITEM" && targets.menuItemIds.length === 0) {
|
|
203
268
|
errors.scope = copy.form.itemTargetRequired;
|
|
204
269
|
}
|
|
205
|
-
|
|
206
|
-
// the rule is "after", not "not before".
|
|
207
|
-
if (values.startsAt !== "" && values.endsAt !== "" && values.endsAt <= values.startsAt) {
|
|
208
|
-
errors.endsAt = copy.form.endsBeforeStarts;
|
|
209
|
-
}
|
|
270
|
+
Object.assign(errors, checkPeriod(values, copy));
|
|
210
271
|
return errors;
|
|
211
272
|
}
|
|
212
273
|
|
|
@@ -18,7 +18,7 @@ export interface DiscountFormValues extends Record<string, string> {
|
|
|
18
18
|
name: string;
|
|
19
19
|
/** What kind of promotion this is. Decides `type` and `scope` — see `./form-kind`. */
|
|
20
20
|
kind: string;
|
|
21
|
-
/** Which of the
|
|
21
|
+
/** Which of the three rewards a COMBO gives (FUT-4417). Ignored by every other kind. */
|
|
22
22
|
comboReward: string;
|
|
23
23
|
percentOff: string;
|
|
24
24
|
amountOff: string;
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
import { useState, type JSX } from "react";
|
|
4
4
|
|
|
5
|
-
import { minLength, required } from "@12-apps/forms-core";
|
|
6
5
|
import { Alert } from "@12-apps/ui/data-display/Alert";
|
|
7
6
|
import {
|
|
8
7
|
Fields,
|
|
@@ -21,7 +20,6 @@ import type { DiscountsWebCopy } from "./copy";
|
|
|
21
20
|
import {
|
|
22
21
|
ComboCapField,
|
|
23
22
|
DiscountSwitches,
|
|
24
|
-
KindField,
|
|
25
23
|
LimitFields,
|
|
26
24
|
RewardFields,
|
|
27
25
|
ScopeField,
|
|
@@ -29,11 +27,13 @@ import {
|
|
|
29
27
|
WindowFields,
|
|
30
28
|
type CurrencyFieldComponent,
|
|
31
29
|
} from "./discount-form-fields";
|
|
30
|
+
import { ComboPreviewLayout } from "./combo-cart-preview";
|
|
32
31
|
import { DiscountTargetPicker } from "./discount-target-picker";
|
|
32
|
+
import { KindField } from "./kind-field";
|
|
33
33
|
import { blankWindow } from "./schedule-builder";
|
|
34
34
|
import { validate, type FormTargets } from "./discount-form-validate";
|
|
35
35
|
import { initialValues, type DiscountFormValues } from "./discount-form-values";
|
|
36
|
-
import {
|
|
36
|
+
import { DISCOUNT_KINDS, typeAndScopeFor } from "./form-kind";
|
|
37
37
|
import type { DiscountsFormatters } from "./format";
|
|
38
38
|
|
|
39
39
|
/**
|
|
@@ -147,8 +147,9 @@ function makeSubmit(
|
|
|
147
147
|
schedule: extra.schedule,
|
|
148
148
|
});
|
|
149
149
|
if (Object.keys(invalid).length > 0) {
|
|
150
|
+
// ONE message for a client refusal: the red fields and the toast
|
|
151
|
+
// (FUT-4143). The `saveFailed` alert is the SERVER's, below.
|
|
150
152
|
setFieldErrors(invalid);
|
|
151
|
-
setError(copy.form.reviewFields);
|
|
152
153
|
return;
|
|
153
154
|
}
|
|
154
155
|
const payload: DiscountFormPayload = {
|
|
@@ -188,64 +189,74 @@ export function DiscountForm(props: DiscountFormProps): JSX.Element {
|
|
|
188
189
|
|
|
189
190
|
return (
|
|
190
191
|
<FormContainer<DiscountFormValues>
|
|
192
|
+
// No `schema`: the container runs one before `onSubmit` and stops when
|
|
193
|
+
// it fails, which judged the name alone. Every rule, the name's too,
|
|
194
|
+
// lives in `validate` so one Criar marks every field (FUT-4143).
|
|
191
195
|
initialValues={initialValues(editing, formatters)}
|
|
192
|
-
schema={{ name: [required(copy.form.nameRequired), minLength(2)] }}
|
|
193
196
|
onSubmit={handleSubmit}
|
|
194
197
|
dataTestId="discount-form"
|
|
195
198
|
>
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
timezoneLabel={props.timezoneLabel ?? null}
|
|
220
|
-
onScheduleChange={extra.setSchedule}
|
|
221
|
-
onScheduleEnabledChange={extra.setScheduleEnabled}
|
|
222
|
-
/>
|
|
223
|
-
<LimitFields copy={copy} currencyField={currencyField} />
|
|
224
|
-
<FirstOrderField
|
|
225
|
-
copy={copy}
|
|
226
|
-
checked={extra.firstOrderOnly}
|
|
227
|
-
onChange={extra.setFirstOrderOnly}
|
|
228
|
-
/>
|
|
229
|
-
<DiscountSwitches
|
|
230
|
-
copy={copy}
|
|
231
|
-
active={extra.active}
|
|
232
|
-
stackable={extra.stackable}
|
|
233
|
-
onActive={extra.setActive}
|
|
234
|
-
onStackable={extra.setStackable}
|
|
235
|
-
/>
|
|
236
|
-
{error && (
|
|
237
|
-
<Alert
|
|
238
|
-
variant="danger"
|
|
239
|
-
title={copy.form.saveFailed}
|
|
240
|
-
description={error}
|
|
241
|
-
data-testid="discount-form-error"
|
|
199
|
+
{/* The combo preview's column (FUT-4417) beside a wide form; nothing for other kinds. */}
|
|
200
|
+
<ComboPreviewLayout
|
|
201
|
+
copy={copy}
|
|
202
|
+
formatters={formatters}
|
|
203
|
+
groups={groups}
|
|
204
|
+
slots={extra.comboRequirements}
|
|
205
|
+
>
|
|
206
|
+
<Stack spacing={2}>
|
|
207
|
+
<Fields.TextField name="name" label={copy.form.name} />
|
|
208
|
+
<KindField copy={copy} kinds={DISCOUNT_KINDS} />
|
|
209
|
+
<RewardFields copy={copy} currencyField={currencyField} />
|
|
210
|
+
<ScopeField copy={copy} />
|
|
211
|
+
<DiscountTargetPicker
|
|
212
|
+
groups={groups}
|
|
213
|
+
copy={copy}
|
|
214
|
+
selection={{
|
|
215
|
+
categoryIds: extra.categoryIds,
|
|
216
|
+
menuItemIds: extra.menuItemIds,
|
|
217
|
+
comboRequirements: extra.comboRequirements,
|
|
218
|
+
onCategoryIdsChange: extra.setCategoryIds,
|
|
219
|
+
onMenuItemIdsChange: extra.setMenuItemIds,
|
|
220
|
+
onComboRequirementsChange: extra.setComboRequirements,
|
|
221
|
+
}}
|
|
242
222
|
/>
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
223
|
+
<ComboCapField copy={copy} />
|
|
224
|
+
<TriggerFields copy={copy} />
|
|
225
|
+
<WindowFields
|
|
226
|
+
copy={copy}
|
|
227
|
+
schedule={extra.schedule}
|
|
228
|
+
scheduleEnabled={extra.scheduleEnabled}
|
|
229
|
+
timezoneLabel={props.timezoneLabel ?? null}
|
|
230
|
+
onScheduleChange={extra.setSchedule}
|
|
231
|
+
onScheduleEnabledChange={extra.setScheduleEnabled}
|
|
232
|
+
/>
|
|
233
|
+
<LimitFields copy={copy} currencyField={currencyField} />
|
|
234
|
+
<FirstOrderField
|
|
235
|
+
copy={copy}
|
|
236
|
+
checked={extra.firstOrderOnly}
|
|
237
|
+
onChange={extra.setFirstOrderOnly}
|
|
238
|
+
/>
|
|
239
|
+
<DiscountSwitches
|
|
240
|
+
copy={copy}
|
|
241
|
+
active={extra.active}
|
|
242
|
+
stackable={extra.stackable}
|
|
243
|
+
onActive={extra.setActive}
|
|
244
|
+
onStackable={extra.setStackable}
|
|
245
|
+
/>
|
|
246
|
+
{error && (
|
|
247
|
+
<Alert
|
|
248
|
+
variant="danger"
|
|
249
|
+
title={copy.form.saveFailed}
|
|
250
|
+
description={error}
|
|
251
|
+
data-testid="discount-form-error"
|
|
252
|
+
/>
|
|
253
|
+
)}
|
|
254
|
+
<SubmitButton dataTestId="discount-form-submit">
|
|
255
|
+
{editing ? copy.form.submitEdit : copy.form.submitCreate}
|
|
256
|
+
</SubmitButton>
|
|
257
|
+
</Stack>
|
|
258
|
+
</ComboPreviewLayout>
|
|
259
|
+
<FormErrorSnackbar message={copy.form.errorSnackbar} />
|
|
249
260
|
</FormContainer>
|
|
250
261
|
);
|
|
251
262
|
}
|
|
@@ -18,7 +18,6 @@ import {
|
|
|
18
18
|
import type { RowAction } from "@12-apps/ui/data-display/DataViews";
|
|
19
19
|
import { ErrorState } from "@12-apps/ui/data-display/ErrorState";
|
|
20
20
|
import { LoadingState } from "@12-apps/ui/data-display/LoadingState";
|
|
21
|
-
import { Dialog, DialogContent } from "@12-apps/ui/feedback/Dialog";
|
|
22
21
|
import { HeaderButton } from "@12-apps/ui/form/HeaderButton";
|
|
23
22
|
import { Dashboard } from "@12-apps/ui/layout/Dashboard";
|
|
24
23
|
import { Text } from "@12-apps/ui/typography/Text";
|
|
@@ -28,6 +27,7 @@ import type { DiscountsApiClient } from "./api";
|
|
|
28
27
|
import { fill, type DiscountsWebCopy } from "./copy";
|
|
29
28
|
import { DiscountActionsMenu } from "./discount-actions-menu";
|
|
30
29
|
import { DiscountCard } from "./discount-card";
|
|
30
|
+
import { DiscountDialog } from "./discount-dialog";
|
|
31
31
|
import { DiscountForm } from "./discount-form";
|
|
32
32
|
import type { CurrencyFieldComponent } from "./discount-form-fields";
|
|
33
33
|
import { DiscountListCard, discountCells } from "./discount-list-card";
|
|
@@ -214,7 +214,7 @@ function DiscountsGrid({
|
|
|
214
214
|
);
|
|
215
215
|
}
|
|
216
216
|
|
|
217
|
-
/** The create dialog
|
|
217
|
+
/** The create dialog; each opening mounts a fresh form, so it re-seeds empty. */
|
|
218
218
|
function CreateDialog({
|
|
219
219
|
open,
|
|
220
220
|
onClose,
|
|
@@ -227,25 +227,19 @@ function CreateDialog({
|
|
|
227
227
|
menu: MenuBinding;
|
|
228
228
|
}): JSX.Element {
|
|
229
229
|
return (
|
|
230
|
-
<
|
|
230
|
+
<DiscountDialog
|
|
231
231
|
open={open}
|
|
232
232
|
onClose={onClose}
|
|
233
233
|
title={menu.copy.form.createTitle}
|
|
234
|
-
|
|
235
|
-
showCloseButton
|
|
236
|
-
dataTestId="discount-dialog"
|
|
234
|
+
copy={menu.copy}
|
|
237
235
|
>
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
/>
|
|
246
|
-
</DialogContent>
|
|
247
|
-
)}
|
|
248
|
-
</Dialog>
|
|
236
|
+
<DiscountForm
|
|
237
|
+
{...menu}
|
|
238
|
+
groups={menu.groups ?? []}
|
|
239
|
+
editing={null}
|
|
240
|
+
onSaved={onSaved}
|
|
241
|
+
/>
|
|
242
|
+
</DiscountDialog>
|
|
249
243
|
);
|
|
250
244
|
}
|
|
251
245
|
|
package/src/react/en-US.ts
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
EN_US_CATEGORY_SELECT_COPY,
|
|
4
4
|
EN_US_CONFIRM_ACTION_COPY,
|
|
5
5
|
EN_US_DATA_VIEWS_COPY,
|
|
6
|
+
EN_US_UI_LABELS,
|
|
6
7
|
} from "@12-apps/ui/en-US";
|
|
7
8
|
|
|
8
9
|
import type { DiscountsWebCopy } from "./copy";
|
|
@@ -131,11 +132,14 @@ export const EN_US_DISCOUNTS_WEB_COPY: DiscountsWebCopy = {
|
|
|
131
132
|
activeNow: "Running now",
|
|
132
133
|
daysRequired: "Pick at least one day.",
|
|
133
134
|
timesRequired: "Enter a start and end time that differ.",
|
|
135
|
+
invalidTime: "That time does not exist. Use 00:00 to 23:59.",
|
|
136
|
+
timeIncomplete: "Finish the time: HH:MM.",
|
|
134
137
|
windowsRequired: "Add at least one time.",
|
|
135
138
|
},
|
|
136
139
|
form: {
|
|
137
140
|
createTitle: "New discount",
|
|
138
141
|
editTitle: "Edit discount",
|
|
142
|
+
dialog: { close: EN_US_UI_LABELS.common.close, back: "Back" },
|
|
139
143
|
submitCreate: "Create discount",
|
|
140
144
|
submitEdit: "Save changes",
|
|
141
145
|
name: "Promotion name",
|
|
@@ -172,8 +176,13 @@ export const EN_US_DISCOUNTS_WEB_COPY: DiscountsWebCopy = {
|
|
|
172
176
|
firstOrderOnlyHint:
|
|
173
177
|
"Only for customers who have never bought here. Menu prices do not change: the discount shows in the cart of whoever can use it.",
|
|
174
178
|
reviewFields: "Check the highlighted fields.",
|
|
179
|
+
errorSnackbar: EN_US_UI_LABELS.formErrorSnackbar.message,
|
|
175
180
|
saveFailed: "Could not save the discount",
|
|
176
181
|
nameRequired: "Give the promotion a name.",
|
|
182
|
+
nameTooShort: "The name needs at least 2 characters.",
|
|
183
|
+
datePlaceholder: "DD/MM/YYYY",
|
|
184
|
+
invalidDate: "That date does not exist. Use DD/MM/YYYY, such as 25/12/2026.",
|
|
185
|
+
dateIncomplete: "Finish the date: DD/MM/YYYY.",
|
|
177
186
|
invalidPercent: "Enter a percentage greater than 0 and at most 100.",
|
|
178
187
|
invalidAmount: "Enter a discount amount greater than zero.",
|
|
179
188
|
invalidBundlePrice: "Enter the bundle price, greater than zero.",
|
|
@@ -188,8 +197,37 @@ export const EN_US_DISCOUNTS_WEB_COPY: DiscountsWebCopy = {
|
|
|
188
197
|
comboSlotTargetRequired: "Choose what can go in each group of the bundle.",
|
|
189
198
|
invalidComboQuantity: "Enter how many units each group takes, from 1 upwards.",
|
|
190
199
|
invalidMaxComboApplications: "Enter how many bundles fit in one order, from 1 upwards.",
|
|
200
|
+
invalidUsageLimit: "Enter a whole number of uses from 1 upwards, or leave it blank.",
|
|
201
|
+
invalidPerBuyerLimit: "Enter a whole number per customer from 1 upwards, or leave it blank.",
|
|
202
|
+
wholeNumberOnly: "Whole numbers only, no decimals.",
|
|
191
203
|
endsBeforeStarts: "The end date must be after the start date.",
|
|
192
204
|
},
|
|
205
|
+
comboRewards: {
|
|
206
|
+
labels: { PERCENTAGE: "Percentage", FIXED_AMOUNT: "Fixed amount", BUNDLE_PRICE: "Fixed price" },
|
|
207
|
+
hints: {
|
|
208
|
+
PERCENTAGE: "The bundle sells for the price of its items minus this discount.",
|
|
209
|
+
FIXED_AMOUNT: "The bundle sells for the price of its items minus this amount.",
|
|
210
|
+
BUNDLE_PRICE: "The bundle sells for this price, whatever its items cost.",
|
|
211
|
+
},
|
|
212
|
+
preview: {
|
|
213
|
+
title: "In the cart",
|
|
214
|
+
itemsRange: "{units} add up to {min}–{max}",
|
|
215
|
+
itemsExact: "{units} add up to {sum}",
|
|
216
|
+
paysRange: "The customer pays {min}–{max}",
|
|
217
|
+
paysRangeOff: "The customer pays {min}–{max} ({off} off)",
|
|
218
|
+
paysExact: "The customer pays {pays}",
|
|
219
|
+
paysExactOff: "The customer pays {pays} ({off} off)",
|
|
220
|
+
paysOff: "The customer pays {off} less",
|
|
221
|
+
paysPriceSaving: "The customer pays {price} — saves {min}–{max}",
|
|
222
|
+
paysPriceSavingExact: "The customer pays {price} — saves {saving}",
|
|
223
|
+
paysPriceSavingUpTo: "The customer pays {price} — saves up to {max}",
|
|
224
|
+
paysUpToPrice: "The customer pays {min}–{price} — saves up to {max}",
|
|
225
|
+
noSaving: "The customer pays the items' price: the bundle price is not lower than theirs.",
|
|
226
|
+
examples: "Examples: {cheapest} · {dearest}",
|
|
227
|
+
example: "{quantity} × {name} → {pays}",
|
|
228
|
+
exampleSaving: "{quantity} × {name} → {pays} (saves {saving})",
|
|
229
|
+
},
|
|
230
|
+
},
|
|
193
231
|
combo: {
|
|
194
232
|
title: "Build the bundle",
|
|
195
233
|
hint: "Each group is one part of the bundle: how many units it takes, and what can go in it.",
|
|
@@ -201,7 +239,11 @@ export const EN_US_DISCOUNTS_WEB_COPY: DiscountsWebCopy = {
|
|
|
201
239
|
// sentence is built around it rather than naming a noun of its own.
|
|
202
240
|
pick: "{collection} in this group",
|
|
203
241
|
empty: "No groups yet. Add the first one to build the bundle.",
|
|
204
|
-
summary: "The bundle takes {units}
|
|
242
|
+
summary: "The bundle takes {units} across {groups}.",
|
|
243
|
+
unitsOne: "{count} item",
|
|
244
|
+
unitsOther: "{count} items",
|
|
245
|
+
groupsOne: "{count} group",
|
|
246
|
+
groupsOther: "{count} groups",
|
|
205
247
|
freeUnitsTitle: "Buy more, pay less",
|
|
206
248
|
freeUnitsHint:
|
|
207
249
|
'Choose the products, how many the customer takes and how many are free — the classic "buy 3, pay for 2".',
|
package/src/react/form-kind.ts
CHANGED
|
@@ -20,18 +20,20 @@ import { DISCOUNT_SCOPES, type DiscountScope, type DiscountType } from "../engin
|
|
|
20
20
|
* |---|---|---|---|
|
|
21
21
|
* | `PERCENTAGE` | `PERCENTAGE` | ORDER / CATEGORY / ITEM | the rate, and what it covers |
|
|
22
22
|
* | `FIXED_AMOUNT` | `FIXED_AMOUNT` | ORDER / CATEGORY / ITEM | the amount, and what it covers |
|
|
23
|
-
* | `COMBO` | `PERCENTAGE` or `
|
|
23
|
+
* | `COMBO` | `PERCENTAGE`, `FIXED_AMOUNT` or `BUNDLE_PRICE` | `COMBO` | the groups, and which of the three rewards |
|
|
24
24
|
* | `FREE_UNITS` | `FREE_UNITS` | `COMBO` | the products, how many, how many free |
|
|
25
25
|
*
|
|
26
26
|
* Two consequences worth stating, because both are deliberate:
|
|
27
27
|
*
|
|
28
|
-
* **A combo is a
|
|
29
|
-
* hambúrgueres e 2 batatas com 15% de
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
28
|
+
* **A combo's reward is a discount off its items OR a closed price**
|
|
29
|
+
* (FUT-4417). "2 refrigerantes, 2 hambúrgueres e 2 batatas com 15% de
|
|
30
|
+
* desconto" and "…menos R$ 5,00" are two offers a merchant describes; "5
|
|
31
|
+
* marmitas por R$ 120" is the third, and the one the form used to refuse. A
|
|
32
|
+
* closed price reprices the matched group whatever its items cost — which is
|
|
33
|
+
* exactly what the merchant asked for, and why the form says so under the price
|
|
34
|
+
* field and previews it against the store's real prices ("Como fica no
|
|
35
|
+
* carrinho"). `BUNDLE_PRICE` is therefore a combo REWARD, not a kind: a rule
|
|
36
|
+
* saved with it before FUT-4417 opens as a combo with "Preço fechado" chosen.
|
|
35
37
|
*
|
|
36
38
|
* **`COMBO` scope is not a choice any more.** It was never one: a combo is
|
|
37
39
|
* defined by its groups, so "combo scope, but with an order-wide discount" is
|
|
@@ -39,15 +41,14 @@ import { DISCOUNT_SCOPES, type DiscountScope, type DiscountType } from "../engin
|
|
|
39
41
|
* genuinely a choice, and the two combo kinds carry `COMBO` implicitly.
|
|
40
42
|
*/
|
|
41
43
|
|
|
42
|
-
/** The kinds
|
|
44
|
+
/** The kinds the form offers, in the order the toggle shows them. */
|
|
43
45
|
export const DISCOUNT_KINDS = ["PERCENTAGE", "FIXED_AMOUNT", "COMBO", "FREE_UNITS"] as const;
|
|
44
46
|
|
|
45
47
|
/**
|
|
46
|
-
* Every kind the form can
|
|
47
|
-
*
|
|
48
|
-
* save without losing what it is.
|
|
48
|
+
* Every kind the form can hold. `BUNDLE_PRICE` is not one any more (FUT-4417):
|
|
49
|
+
* it is the third reward of a `COMBO`, see {@link COMBO_REWARDS}.
|
|
49
50
|
*/
|
|
50
|
-
export type DiscountKind = (typeof DISCOUNT_KINDS)[number]
|
|
51
|
+
export type DiscountKind = (typeof DISCOUNT_KINDS)[number];
|
|
51
52
|
|
|
52
53
|
/**
|
|
53
54
|
* True when this kind's targets are groups — a builder, not a flat picker, and
|
|
@@ -58,7 +59,7 @@ export type DiscountKind = (typeof DISCOUNT_KINDS)[number] | "BUNDLE_PRICE";
|
|
|
58
59
|
* second `includes` that drifts from this one.
|
|
59
60
|
*/
|
|
60
61
|
export function isComboKind(kind: string): boolean {
|
|
61
|
-
return kind === "COMBO" || kind === "FREE_UNITS"
|
|
62
|
+
return kind === "COMBO" || kind === "FREE_UNITS";
|
|
62
63
|
}
|
|
63
64
|
|
|
64
65
|
/** The scopes the toggle offers: every scope except the one a kind implies. */
|
|
@@ -70,39 +71,30 @@ export const SELECTABLE_DISCOUNT_SCOPES: readonly DiscountScope[] = DISCOUNT_SCO
|
|
|
70
71
|
* Which kind an existing rule is, read back from the pair it was stored as.
|
|
71
72
|
*
|
|
72
73
|
* `FREE_UNITS` is checked before the scope because it is the narrower fact: it
|
|
73
|
-
* is only ever legal at `COMBO` scope, so the type alone identifies it.
|
|
74
|
+
* is only ever legal at `COMBO` scope, so the type alone identifies it. A
|
|
75
|
+
* `BUNDLE_PRICE` rule is a combo (FUT-4417) — whatever scope it carries, since
|
|
76
|
+
* the type is only legal at `COMBO`.
|
|
74
77
|
*/
|
|
75
78
|
export function kindOf(rule: { type: string; scope: string } | null): DiscountKind {
|
|
76
79
|
if (rule === null) return "PERCENTAGE";
|
|
77
|
-
if (rule.type === "BUNDLE_PRICE") return "BUNDLE_PRICE";
|
|
78
80
|
if (rule.type === "FREE_UNITS") return "FREE_UNITS";
|
|
79
|
-
if (rule.scope === "COMBO") return "COMBO";
|
|
81
|
+
if (rule.type === "BUNDLE_PRICE" || rule.scope === "COMBO") return "COMBO";
|
|
80
82
|
return rule.type === "FIXED_AMOUNT" ? "FIXED_AMOUNT" : "PERCENTAGE";
|
|
81
83
|
}
|
|
82
84
|
|
|
83
85
|
/**
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
* learned, and present ONLY on the legacy rule's own form: an operator editing
|
|
89
|
-
* a percentage promotion is not offered a bundle price they cannot create.
|
|
90
|
-
*/
|
|
91
|
-
export function kindOptions(editing: { type: string; scope: string } | null): DiscountKind[] {
|
|
92
|
-
const kind = kindOf(editing);
|
|
93
|
-
return kind === "BUNDLE_PRICE" ? [...DISCOUNT_KINDS, kind] : [...DISCOUNT_KINDS];
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* A combo's reward is one of the two plain types, chosen separately from the
|
|
98
|
-
* kind — "15% off the combo" and "R$ 5,00 off the combo" are the same kind of
|
|
99
|
-
* promotion with different arithmetic.
|
|
86
|
+
* A combo's reward, chosen separately from the kind — "15% off the combo",
|
|
87
|
+
* "R$ 5,00 off the combo" and "the combo for R$ 120" (FUT-4417) are the same
|
|
88
|
+
* kind of promotion with different arithmetic. In the order the toggle shows
|
|
89
|
+
* them.
|
|
100
90
|
*/
|
|
101
|
-
export const COMBO_REWARDS = ["PERCENTAGE", "FIXED_AMOUNT"] as const;
|
|
91
|
+
export const COMBO_REWARDS = ["PERCENTAGE", "FIXED_AMOUNT", "BUNDLE_PRICE"] as const;
|
|
92
|
+
export type ComboReward = (typeof COMBO_REWARDS)[number];
|
|
102
93
|
|
|
103
|
-
/** Which reward an existing combo was saved with. */
|
|
104
|
-
export function comboRewardOf(rule: { type: string } | null):
|
|
105
|
-
|
|
94
|
+
/** Which reward an existing combo was saved with; a new one starts on the percentage. */
|
|
95
|
+
export function comboRewardOf(rule: { type: string } | null): ComboReward {
|
|
96
|
+
if (rule?.type === "FIXED_AMOUNT" || rule?.type === "BUNDLE_PRICE") return rule.type;
|
|
97
|
+
return "PERCENTAGE";
|
|
106
98
|
}
|
|
107
99
|
|
|
108
100
|
/**
|
|
@@ -114,17 +106,19 @@ export function comboRewardOf(rule: { type: string } | null): string {
|
|
|
114
106
|
* rather than trusted: the toggle is unmounted at combo kinds, so its value is
|
|
115
107
|
* whatever it was before the operator changed their mind.
|
|
116
108
|
*/
|
|
117
|
-
export function typeAndScopeFor(values: {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
109
|
+
export function typeAndScopeFor(values: { kind: string; comboReward: string; scope: string }): {
|
|
110
|
+
type: DiscountType;
|
|
111
|
+
scope: DiscountScope;
|
|
112
|
+
} {
|
|
113
|
+
if (values.kind === "COMBO")
|
|
114
|
+
return {
|
|
115
|
+
type: comboRewardOf({ type: values.comboReward }),
|
|
116
|
+
scope: "COMBO",
|
|
117
|
+
};
|
|
126
118
|
if (values.kind === "FREE_UNITS") return { type: "FREE_UNITS", scope: "COMBO" };
|
|
127
|
-
if (values.kind === "BUNDLE_PRICE") return { type: "BUNDLE_PRICE", scope: "COMBO" };
|
|
128
119
|
const type = values.kind === "FIXED_AMOUNT" ? "FIXED_AMOUNT" : "PERCENTAGE";
|
|
129
|
-
return {
|
|
120
|
+
return {
|
|
121
|
+
type,
|
|
122
|
+
scope: values.scope === "COMBO" ? "ORDER" : (values.scope as DiscountScope),
|
|
123
|
+
};
|
|
130
124
|
}
|