@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
package/src/react/copy.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type {
|
|
|
7
7
|
DiscountWindowState,
|
|
8
8
|
} from "../engine/kinds";
|
|
9
9
|
|
|
10
|
+
import type { DiscountsComboRewardsCopy } from "./combo-reward-copy";
|
|
10
11
|
import type { DiscountsScheduleCopy } from "./schedule-copy";
|
|
11
12
|
|
|
12
13
|
/**
|
|
@@ -80,8 +81,8 @@ export interface DiscountsVocabularyCopy {
|
|
|
80
81
|
/**
|
|
81
82
|
* The KIND toggle's words — the one question the form actually asks (see
|
|
82
83
|
* `./form-kind`). It is keyed by `DiscountType` because every kind is named
|
|
83
|
-
* after the type it stands for
|
|
84
|
-
*
|
|
84
|
+
* after the type it stands for. `BUNDLE_PRICE` is no longer a kind on offer
|
|
85
|
+
* (FUT-4417: a combo reward, `comboRewards.labels`); its entry keeps the shape.
|
|
85
86
|
*/
|
|
86
87
|
readonly kind: LabelsFor<DiscountType | "COMBO">;
|
|
87
88
|
readonly type: LabelsFor<DiscountType>;
|
|
@@ -106,20 +107,21 @@ export interface DiscountsWindowCopy {
|
|
|
106
107
|
export interface DiscountsFormCopy {
|
|
107
108
|
readonly createTitle: string;
|
|
108
109
|
readonly editTitle: string;
|
|
110
|
+
readonly dialog: { readonly close: string; readonly back: string };
|
|
109
111
|
readonly submitCreate: string;
|
|
110
112
|
readonly submitEdit: string;
|
|
111
113
|
readonly name: string;
|
|
112
114
|
readonly type: string;
|
|
113
115
|
/** The kind toggle's own label — the form's first real question. */
|
|
114
116
|
readonly kind: string;
|
|
115
|
-
/**
|
|
117
|
+
/** The combo reward's label; its choices and hints are `comboRewards` (FUT-4417). */
|
|
116
118
|
readonly comboReward: string;
|
|
117
119
|
readonly comboRewardHint: string;
|
|
118
120
|
readonly percentOff: string;
|
|
119
121
|
/** The example inside the empty percentage input. */
|
|
120
122
|
readonly percentPlaceholder: string;
|
|
121
123
|
readonly amountOff: string;
|
|
122
|
-
/** The combo's
|
|
124
|
+
/** The combo's closed price, under the "Preço fechado" reward (FUT-4417). */
|
|
123
125
|
readonly bundlePrice: string;
|
|
124
126
|
/** How many of the matched units are given away, for a FREE_UNITS rule. */
|
|
125
127
|
readonly freeUnits: string;
|
|
@@ -132,6 +134,8 @@ export interface DiscountsFormCopy {
|
|
|
132
134
|
readonly scope: string;
|
|
133
135
|
readonly startsAt: string;
|
|
134
136
|
readonly endsAt: string;
|
|
137
|
+
/** Inside an empty Início / Término: the day-first order the box reads (FUT-4156). */
|
|
138
|
+
readonly datePlaceholder: string;
|
|
135
139
|
readonly minSubtotal: string;
|
|
136
140
|
readonly usageLimit: string;
|
|
137
141
|
readonly perBuyerLimit: string;
|
|
@@ -147,12 +151,24 @@ export interface DiscountsFormCopy {
|
|
|
147
151
|
/** The first-purchase condition (FUT-2825), and what it does to the menu. */
|
|
148
152
|
readonly firstOrderOnly: string;
|
|
149
153
|
readonly firstOrderOnlyHint: string;
|
|
150
|
-
/**
|
|
154
|
+
/**
|
|
155
|
+
* The banner over a form the operator must fix before it will send. No
|
|
156
|
+
* longer rendered (FUT-4143): a client refusal says it once, in the toast
|
|
157
|
+
* (`errorSnackbar`). Kept so a host's own pack still type-checks.
|
|
158
|
+
*/
|
|
151
159
|
readonly reviewFields: string;
|
|
160
|
+
/**
|
|
161
|
+
* The toast a failed submit raises over the whole form (FUT-4143). The packs
|
|
162
|
+
* take it from `@12-apps/ui`'s own labels; `FormErrorSnackbar`'s fallback is
|
|
163
|
+
* English, so a form that does not pass one shows English on any screen.
|
|
164
|
+
*/
|
|
165
|
+
readonly errorSnackbar: string;
|
|
152
166
|
/** The banner over a write the SERVER refused. */
|
|
153
167
|
readonly saveFailed: string;
|
|
154
168
|
/** Per-input refusals, each attached to the input it names. */
|
|
155
169
|
readonly nameRequired: string;
|
|
170
|
+
/** A name of one character — the two-character floor this form has always held. */
|
|
171
|
+
readonly nameTooShort: string;
|
|
156
172
|
readonly invalidPercent: string;
|
|
157
173
|
readonly invalidAmount: string;
|
|
158
174
|
readonly invalidBundlePrice: string;
|
|
@@ -174,7 +190,20 @@ export interface DiscountsFormCopy {
|
|
|
174
190
|
readonly comboSlotTargetRequired: string;
|
|
175
191
|
readonly invalidComboQuantity: string;
|
|
176
192
|
readonly invalidMaxComboApplications: string;
|
|
193
|
+
/** The two redemption caps: whole numbers from 1, or blank for "no cap". */
|
|
194
|
+
readonly invalidUsageLimit: string;
|
|
195
|
+
readonly invalidPerBuyerLimit: string;
|
|
196
|
+
/**
|
|
197
|
+
* Under a unit count typed with anything but digits ("2,5"), said while it is
|
|
198
|
+
* typed: a count of units has no decimals, and a browser that read the comma
|
|
199
|
+
* away stored 25.
|
|
200
|
+
*/
|
|
201
|
+
readonly wholeNumberOnly: string;
|
|
177
202
|
readonly endsBeforeStarts: string;
|
|
203
|
+
/** A date that is not a whole, real day — "31/02/2026", or "12/0" at Criar. */
|
|
204
|
+
readonly invalidDate: string;
|
|
205
|
+
/** A date left half-typed ("25/10/202"): shown once the box is left, or at Criar. */
|
|
206
|
+
readonly dateIncomplete: string;
|
|
178
207
|
}
|
|
179
208
|
|
|
180
209
|
/**
|
|
@@ -205,9 +234,17 @@ export interface DiscountsComboCopy {
|
|
|
205
234
|
readonly empty: string;
|
|
206
235
|
/**
|
|
207
236
|
* The offer read back in the operator's own numbers, so a combo can be
|
|
208
|
-
* checked without saving it. `{units}` and `{groups}
|
|
237
|
+
* checked without saving it. `{units}` and `{groups}` are the two counted
|
|
238
|
+
* nouns below, already filled — "4 itens", "1 grupo" (FUT-4143: the sentence
|
|
239
|
+
* used to say "1 grupos").
|
|
209
240
|
*/
|
|
210
241
|
readonly summary: string;
|
|
242
|
+
/** "1 item" / "{count} itens": one for exactly 1, the other for every other count. */
|
|
243
|
+
readonly unitsOne: string;
|
|
244
|
+
readonly unitsOther: string;
|
|
245
|
+
/** "1 grupo" / "{count} grupos", the same rule. */
|
|
246
|
+
readonly groupsOne: string;
|
|
247
|
+
readonly groupsOther: string;
|
|
211
248
|
/**
|
|
212
249
|
* The "leve 3, pague 2" builder (`./free-units-builder`), which is the same
|
|
213
250
|
* stored shape wearing the sentence an operator would actually say. Its own
|
|
@@ -284,6 +321,7 @@ export interface DiscountsWebCopy {
|
|
|
284
321
|
readonly schedule: DiscountsScheduleCopy;
|
|
285
322
|
readonly form: DiscountsFormCopy;
|
|
286
323
|
readonly combo: DiscountsComboCopy;
|
|
324
|
+
readonly comboRewards: DiscountsComboRewardsCopy;
|
|
287
325
|
readonly targets: DiscountsTargetCopy;
|
|
288
326
|
readonly actions: DiscountsActionsCopy;
|
|
289
327
|
readonly card: DiscountsCardCopy;
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, type JSX } from "react";
|
|
4
|
+
|
|
5
|
+
import { FormControl, FormLabel, FormMessage } from "@12-apps/ui/form/Form";
|
|
6
|
+
import { Input } from "@12-apps/ui/form/Input";
|
|
7
|
+
import { useFormContext } from "@12-apps/ui/form/total-form";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A day-first `DD/MM/AAAA` box that is NOT `<input type="date">` (FUT-4156).
|
|
11
|
+
*
|
|
12
|
+
* The native control follows the BROWSER's language: an English Chromium shows
|
|
13
|
+
* Início as `mm/dd/yyyy` on a Portuguese screen, the same trap `ClockInput`
|
|
14
|
+
* avoids for the hours. This is the same family: text, masked, numeric keypad,
|
|
15
|
+
* a complete day that does not exist (31/02/2026) marked as it is typed.
|
|
16
|
+
*
|
|
17
|
+
* **The form value stays ISO** (`YYYY-MM-DD`), exactly what the native input
|
|
18
|
+
* held, so the wire and the validator's date comparison are unchanged. While
|
|
19
|
+
* the box holds anything that is not a whole real day the value IS that text,
|
|
20
|
+
* and `validate` refuses it at Criar — a half-typed date is never sent, and
|
|
21
|
+
* never silently dropped to "no date" either.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const ISO_DAY = /^(\d{4})-(\d{2})-(\d{2})$/u;
|
|
25
|
+
const MASKED_DAY = /^(\d{2})\/(\d{2})\/(\d{4})$/u;
|
|
26
|
+
const PART_LIMITS = [2, 2, 4];
|
|
27
|
+
|
|
28
|
+
/** `YYYY-MM-DD` that names a real calendar day. */
|
|
29
|
+
export function isIsoDay(value: string): boolean {
|
|
30
|
+
const match = ISO_DAY.exec(value);
|
|
31
|
+
if (match === null) return false;
|
|
32
|
+
const [year, month, day] = [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
33
|
+
const date = new Date(Date.UTC(year, month - 1, day));
|
|
34
|
+
return (
|
|
35
|
+
date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A whole `DD/MM/AAAA`, real or not. */
|
|
40
|
+
export function isCompleteMaskedDay(value: string): boolean {
|
|
41
|
+
return MASKED_DAY.test(value);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** `DD/MM/AAAA` → `YYYY-MM-DD`, or `null` when it is not a complete real day. */
|
|
45
|
+
function maskedToIso(masked: string): string | null {
|
|
46
|
+
const match = MASKED_DAY.exec(masked);
|
|
47
|
+
if (match === null) return null;
|
|
48
|
+
const iso = `${match[3]}-${match[2]}-${match[1]}`;
|
|
49
|
+
return isIsoDay(iso) ? iso : null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function isoToMasked(iso: string): string {
|
|
53
|
+
const match = ISO_DAY.exec(iso);
|
|
54
|
+
return match === null ? iso : `${match[3]}/${match[2]}/${match[1]}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Up to eight digits, day first, with the slashes put in as they are typed. */
|
|
58
|
+
function maskDigits(raw: string): string {
|
|
59
|
+
const digits = raw.replace(/\D/gu, "").slice(0, 8);
|
|
60
|
+
if (digits.length <= 2) return digits;
|
|
61
|
+
if (digits.length <= 4) return `${digits.slice(0, 2)}/${digits.slice(2)}`;
|
|
62
|
+
return `${digits.slice(0, 2)}/${digits.slice(2, 4)}/${digits.slice(4)}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The typed text, masked.
|
|
67
|
+
*
|
|
68
|
+
* - A pasted ISO day reads as that day, not as eight digits split day-first.
|
|
69
|
+
* - Editing a date that already has its slashes keeps each part's own digits:
|
|
70
|
+
* select "25" in "25/10/2026", type "0" and the box holds "0/10/2026", then
|
|
71
|
+
* "05/10/2026" — re-masking the digits would read "01/02/026".
|
|
72
|
+
* - A part typed past its length moves on to the next part ("12/032" →
|
|
73
|
+
* "12/03/2"), which is plain typing, not an edit.
|
|
74
|
+
*/
|
|
75
|
+
function maskDate(raw: string, previous: string): string {
|
|
76
|
+
if (ISO_DAY.test(raw.trim())) return isoToMasked(raw.trim());
|
|
77
|
+
if (previous.includes("/") && /\D/u.test(raw)) {
|
|
78
|
+
const parts = raw.split(/\D/u);
|
|
79
|
+
const fits =
|
|
80
|
+
parts.length <= 3 && parts.every((part, index) => part.length <= (PART_LIMITS[index] ?? 0));
|
|
81
|
+
if (fits) return parts.join("/");
|
|
82
|
+
}
|
|
83
|
+
return maskDigits(raw);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** A form-bound day: the value is ISO when whole and real, the typed text otherwise. */
|
|
87
|
+
export function DateField({
|
|
88
|
+
name,
|
|
89
|
+
label,
|
|
90
|
+
placeholder,
|
|
91
|
+
invalidMessage,
|
|
92
|
+
incompleteMessage,
|
|
93
|
+
}: {
|
|
94
|
+
name: string;
|
|
95
|
+
label: string;
|
|
96
|
+
placeholder: string;
|
|
97
|
+
invalidMessage: string;
|
|
98
|
+
/** Under a half-typed date once the box is left — never while typing. */
|
|
99
|
+
incompleteMessage: string;
|
|
100
|
+
}): JSX.Element {
|
|
101
|
+
const { values, errors, setFieldValue } = useFormContext();
|
|
102
|
+
const [left, setLeft] = useState(false);
|
|
103
|
+
const value = values[name] ?? "";
|
|
104
|
+
const shown = isIsoDay(value) ? isoToMasked(value) : value;
|
|
105
|
+
// A complete day that no calendar has is marked while typed; anything still
|
|
106
|
+
// being typed waits for Criar, like every other field.
|
|
107
|
+
const impossible = MASKED_DAY.test(shown) && maskedToIso(shown) === null;
|
|
108
|
+
const unfinished = left && shown !== "" && !MASKED_DAY.test(shown);
|
|
109
|
+
const message =
|
|
110
|
+
errors[name] ??
|
|
111
|
+
(impossible ? invalidMessage : unfinished ? incompleteMessage : undefined);
|
|
112
|
+
const testId = `total-form-field-${name}`;
|
|
113
|
+
return (
|
|
114
|
+
<FormControl fullWidth>
|
|
115
|
+
<FormLabel htmlFor={name} error={message !== undefined}>
|
|
116
|
+
{label}
|
|
117
|
+
</FormLabel>
|
|
118
|
+
<Input
|
|
119
|
+
id={name}
|
|
120
|
+
name={name}
|
|
121
|
+
type="text"
|
|
122
|
+
placeholder={placeholder}
|
|
123
|
+
value={shown}
|
|
124
|
+
error={message !== undefined}
|
|
125
|
+
inputProps={{ inputMode: "numeric", maxLength: 10, autoComplete: "off", "data-testid": testId }}
|
|
126
|
+
data-testid={testId}
|
|
127
|
+
onBlur={() => setLeft(true)}
|
|
128
|
+
onChange={(event) => {
|
|
129
|
+
setLeft(false);
|
|
130
|
+
const masked = maskDate(event.target.value, shown);
|
|
131
|
+
setFieldValue(name, maskedToIso(masked) ?? masked);
|
|
132
|
+
}}
|
|
133
|
+
/>
|
|
134
|
+
{message !== undefined && (
|
|
135
|
+
<FormMessage error dataTestId={`${testId}-message`}>
|
|
136
|
+
{message}
|
|
137
|
+
</FormMessage>
|
|
138
|
+
)}
|
|
139
|
+
</FormControl>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
@@ -7,11 +7,11 @@ import {
|
|
|
7
7
|
useCardActions,
|
|
8
8
|
useRemoveConfirm,
|
|
9
9
|
} from "@12-apps/ui/data-display/CardKit";
|
|
10
|
-
import { Dialog, DialogContent } from "@12-apps/ui/feedback/Dialog";
|
|
11
10
|
import type { DropdownMenuItem } from "@12-apps/ui/navigation/DropdownMenu";
|
|
12
11
|
|
|
13
12
|
import type { DiscountsApiClient, WireTargetGroup } from "./api";
|
|
14
13
|
import type { DiscountsWebCopy } from "./copy";
|
|
14
|
+
import { DiscountDialog } from "./discount-dialog";
|
|
15
15
|
import { DiscountForm } from "./discount-form";
|
|
16
16
|
import type { CurrencyFieldComponent } from "./discount-form-fields";
|
|
17
17
|
import type { DiscountsFormatters } from "./format";
|
|
@@ -125,36 +125,30 @@ export function DiscountActionsMenu({
|
|
|
125
125
|
/>
|
|
126
126
|
{remove.dialog}
|
|
127
127
|
{canEdit && (
|
|
128
|
-
<
|
|
128
|
+
<DiscountDialog
|
|
129
129
|
open={editOpen}
|
|
130
130
|
onClose={() => setEditOpen(false)}
|
|
131
131
|
title={copy.form.editTitle}
|
|
132
|
-
|
|
133
|
-
showCloseButton
|
|
134
|
-
dataTestId="discount-dialog"
|
|
132
|
+
copy={copy}
|
|
135
133
|
>
|
|
136
|
-
{/*
|
|
134
|
+
{/* Each opening mounts a fresh form, so it re-seeds from the row
|
|
137
135
|
instead of keeping the last attempt's values. */}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
/>
|
|
155
|
-
</DialogContent>
|
|
156
|
-
)}
|
|
157
|
-
</Dialog>
|
|
136
|
+
<DiscountForm
|
|
137
|
+
key={row.id}
|
|
138
|
+
api={api}
|
|
139
|
+
copy={copy}
|
|
140
|
+
formatters={formatters}
|
|
141
|
+
currencyField={currencyField}
|
|
142
|
+
groups={groups}
|
|
143
|
+
editing={row.record}
|
|
144
|
+
onError={onError}
|
|
145
|
+
{...(timezoneLabel ? { timezoneLabel } : {})}
|
|
146
|
+
onSaved={() => {
|
|
147
|
+
setEditOpen(false);
|
|
148
|
+
onRefresh();
|
|
149
|
+
}}
|
|
150
|
+
/>
|
|
151
|
+
</DiscountDialog>
|
|
158
152
|
)}
|
|
159
153
|
</>
|
|
160
154
|
);
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Fragment, useState, type JSX, type ReactNode } from "react";
|
|
4
|
+
|
|
5
|
+
import { UsableModal } from "@12-apps/ui/feedback/UsableModal";
|
|
6
|
+
import { Box } from "@12-apps/ui/mui/Box";
|
|
7
|
+
|
|
8
|
+
import type { DiscountsWebCopy } from "./copy";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The surface the create and edit forms open in (FUT-4417): a stacked modal on
|
|
12
|
+
* a computer, a bottom sheet on a phone (`UsableModal`).
|
|
13
|
+
*
|
|
14
|
+
* `size="wide"` — the panel the library keeps for a dense two-column editor —
|
|
15
|
+
* because the combo form lays out "Como fica no carrinho" in its own column
|
|
16
|
+
* beside the fields when the form has the room (`PREVIEW_ASIDE_MIN_PX`), and
|
|
17
|
+
* the 800 px dialog it replaced never had it. The owner's call, 2026-11-05:
|
|
18
|
+
* "Substitui o modal pelo stacked modal no desktop".
|
|
19
|
+
*
|
|
20
|
+
* Every opening mounts a fresh form, so it re-seeds instead of keeping the
|
|
21
|
+
* last attempt's values — even when it reopens inside the modal's 400 ms way
|
|
22
|
+
* out, while the closing body is still mounted (hence the key per opening).
|
|
23
|
+
* The body stays mounted while it leaves, so the panel does not slide out
|
|
24
|
+
* empty. The submit stays in the form: it acts on the fields above it.
|
|
25
|
+
*
|
|
26
|
+
* `copy.form.dialog` names the ✕ and the back arrow, which carry a glyph only;
|
|
27
|
+
* the arrow shows when the modal opens over another stacked panel.
|
|
28
|
+
*/
|
|
29
|
+
export function DiscountDialog({
|
|
30
|
+
open,
|
|
31
|
+
onClose,
|
|
32
|
+
title,
|
|
33
|
+
copy,
|
|
34
|
+
children,
|
|
35
|
+
}: {
|
|
36
|
+
open: boolean;
|
|
37
|
+
onClose: () => void;
|
|
38
|
+
title: string;
|
|
39
|
+
copy: DiscountsWebCopy;
|
|
40
|
+
children: ReactNode;
|
|
41
|
+
}): JSX.Element {
|
|
42
|
+
const [opening, setOpening] = useState(open ? 1 : 0);
|
|
43
|
+
const [wasOpen, setWasOpen] = useState(open);
|
|
44
|
+
if (open !== wasOpen) {
|
|
45
|
+
setWasOpen(open);
|
|
46
|
+
if (open) setOpening(opening + 1);
|
|
47
|
+
}
|
|
48
|
+
return (
|
|
49
|
+
<UsableModal
|
|
50
|
+
open={open}
|
|
51
|
+
onClose={onClose}
|
|
52
|
+
title={title}
|
|
53
|
+
size="wide"
|
|
54
|
+
closeLabel={copy.form.dialog.close}
|
|
55
|
+
backLabel={copy.form.dialog.back}
|
|
56
|
+
dataTestId="discount-dialog"
|
|
57
|
+
>
|
|
58
|
+
{/* `clip`, not `hidden`: a checked switch's hit area reaches 24 px past
|
|
59
|
+
its track (the input is three tracks wide), which the sheet's 16 px
|
|
60
|
+
gutter turned into an 8 px sideways scroll of the whole form. Clipping
|
|
61
|
+
keeps that overflow out of the scroller without making a second one. */}
|
|
62
|
+
<Box sx={{ minWidth: 0, overflowX: "clip" }}>
|
|
63
|
+
<Fragment key={opening}>{children}</Fragment>
|
|
64
|
+
</Box>
|
|
65
|
+
</UsableModal>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
@@ -13,7 +13,10 @@ import { DISCOUNT_TRIGGERS } from "../engine/kinds";
|
|
|
13
13
|
import type { DiscountSchedule } from "../engine/schedule";
|
|
14
14
|
|
|
15
15
|
import type { DiscountsWebCopy } from "./copy";
|
|
16
|
-
import {
|
|
16
|
+
import { ComboRewardFields } from "./combo-reward-fields";
|
|
17
|
+
import { isComboKind, SELECTABLE_DISCOUNT_SCOPES } from "./form-kind";
|
|
18
|
+
import { DateField } from "./date-field";
|
|
19
|
+
import { PercentField, WholeNumberField } from "./number-fields";
|
|
17
20
|
import { ScheduleBuilder } from "./schedule-builder";
|
|
18
21
|
|
|
19
22
|
/**
|
|
@@ -49,14 +52,14 @@ function HintedField({
|
|
|
49
52
|
}): JSX.Element {
|
|
50
53
|
return (
|
|
51
54
|
<Stack spacing={0.5}>
|
|
52
|
-
<
|
|
55
|
+
<WholeNumberField name={name} label={label} />
|
|
53
56
|
<Text variant="caption">{hint}</Text>
|
|
54
57
|
</Stack>
|
|
55
58
|
);
|
|
56
59
|
}
|
|
57
60
|
|
|
58
61
|
/** One closed set as a toggle's options, labelled by the pack. */
|
|
59
|
-
function optionsFor<TKey extends string>(
|
|
62
|
+
export function optionsFor<TKey extends string>(
|
|
60
63
|
values: readonly TKey[],
|
|
61
64
|
labels: Readonly<Record<TKey, string>>,
|
|
62
65
|
): { value: string; label: string }[] {
|
|
@@ -73,70 +76,6 @@ function optionsFor<TKey extends string>(
|
|
|
73
76
|
*/
|
|
74
77
|
export type CurrencyFieldComponent = (props: { name: string; label: string }) => JSX.Element;
|
|
75
78
|
|
|
76
|
-
/**
|
|
77
|
-
* What kind of promotion this is — the form's first and most consequential
|
|
78
|
-
* question, and the only one that decides what the rest of it looks like.
|
|
79
|
-
*
|
|
80
|
-
* The option list comes from the caller rather than from the constant, because
|
|
81
|
-
* it is one longer when a legacy `BUNDLE_PRICE` rule is being edited.
|
|
82
|
-
*/
|
|
83
|
-
export function KindField({
|
|
84
|
-
copy,
|
|
85
|
-
kinds,
|
|
86
|
-
}: {
|
|
87
|
-
copy: DiscountsWebCopy;
|
|
88
|
-
kinds: readonly DiscountKind[];
|
|
89
|
-
}): JSX.Element {
|
|
90
|
-
return (
|
|
91
|
-
<Fields.ToggleField
|
|
92
|
-
name="kind"
|
|
93
|
-
label={copy.form.kind}
|
|
94
|
-
options={optionsFor(kinds, copy.labels.kind)}
|
|
95
|
-
/>
|
|
96
|
-
);
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* A combo's reward: which of the two, and how much.
|
|
101
|
-
*
|
|
102
|
-
* Both are here rather than split between the top of the form and the bottom,
|
|
103
|
-
* because to a merchant they are one sentence — "este combo dá 15% de
|
|
104
|
-
* desconto". The two are the ONLY rewards a combo can give: a flat bundle price
|
|
105
|
-
* reprices the group and goes silently wrong the first time one of its items
|
|
106
|
-
* changes price, so it is not offered (see `./form-kind`).
|
|
107
|
-
*/
|
|
108
|
-
function ComboRewardFields({
|
|
109
|
-
copy,
|
|
110
|
-
currencyField,
|
|
111
|
-
}: {
|
|
112
|
-
copy: DiscountsWebCopy;
|
|
113
|
-
currencyField: CurrencyFieldComponent;
|
|
114
|
-
}): JSX.Element {
|
|
115
|
-
const { values } = useFormContext();
|
|
116
|
-
const Currency = currencyField;
|
|
117
|
-
return (
|
|
118
|
-
<Stack spacing={0.5}>
|
|
119
|
-
<Box sx={{ display: "grid", gap: 2, gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" } }}>
|
|
120
|
-
<Fields.ToggleField
|
|
121
|
-
name="comboReward"
|
|
122
|
-
label={copy.form.comboReward}
|
|
123
|
-
options={optionsFor(COMBO_REWARDS, copy.labels.type)}
|
|
124
|
-
/>
|
|
125
|
-
{values.comboReward === "FIXED_AMOUNT" ? (
|
|
126
|
-
<Currency name="amountOff" label={copy.form.amountOff} />
|
|
127
|
-
) : (
|
|
128
|
-
<Fields.TextField
|
|
129
|
-
name="percentOff"
|
|
130
|
-
label={copy.form.percentOff}
|
|
131
|
-
placeholder={copy.form.percentPlaceholder}
|
|
132
|
-
/>
|
|
133
|
-
)}
|
|
134
|
-
</Box>
|
|
135
|
-
<Text variant="caption">{copy.form.comboRewardHint}</Text>
|
|
136
|
-
</Stack>
|
|
137
|
-
);
|
|
138
|
-
}
|
|
139
|
-
|
|
140
79
|
/**
|
|
141
80
|
* The reward, in whichever unit the chosen KIND actually stores.
|
|
142
81
|
*
|
|
@@ -148,7 +87,8 @@ function ComboRewardFields({
|
|
|
148
87
|
*
|
|
149
88
|
* `FREE_UNITS` is the one kind with nothing here: its reward is a count of the
|
|
150
89
|
* units it gives away, which only reads as an offer beside the units it takes,
|
|
151
|
-
* so the free-units builder carries both.
|
|
90
|
+
* so the free-units builder carries both. A `COMBO`'s reward is one of three —
|
|
91
|
+
* a rate, an amount or a closed price (FUT-4417) — see `./combo-reward-fields`.
|
|
152
92
|
*/
|
|
153
93
|
export function RewardFields({
|
|
154
94
|
copy,
|
|
@@ -162,15 +102,8 @@ export function RewardFields({
|
|
|
162
102
|
const kind = values.kind ?? "PERCENTAGE";
|
|
163
103
|
if (kind === "COMBO") return <ComboRewardFields copy={copy} currencyField={currencyField} />;
|
|
164
104
|
if (kind === "FREE_UNITS") return null;
|
|
165
|
-
if (kind === "BUNDLE_PRICE") return <Currency name="bundlePrice" label={copy.form.bundlePrice} />;
|
|
166
105
|
if (kind === "FIXED_AMOUNT") return <Currency name="amountOff" label={copy.form.amountOff} />;
|
|
167
|
-
return
|
|
168
|
-
<Fields.TextField
|
|
169
|
-
name="percentOff"
|
|
170
|
-
label={copy.form.percentOff}
|
|
171
|
-
placeholder={copy.form.percentPlaceholder}
|
|
172
|
-
/>
|
|
173
|
-
);
|
|
106
|
+
return <PercentField copy={copy} />;
|
|
174
107
|
}
|
|
175
108
|
|
|
176
109
|
/** The coupon input, shown only for a CODE-triggered rule. */
|
|
@@ -252,6 +185,24 @@ export function ComboCapField({ copy }: { copy: DiscountsWebCopy }): JSX.Element
|
|
|
252
185
|
);
|
|
253
186
|
}
|
|
254
187
|
|
|
188
|
+
/** Início and Término, day-first on every browser (FUT-4156). */
|
|
189
|
+
function PeriodFields({ copy }: { copy: DiscountsWebCopy }): JSX.Element {
|
|
190
|
+
return (
|
|
191
|
+
<Box sx={{ display: "grid", gap: 2, gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" } }}>
|
|
192
|
+
{(["startsAt", "endsAt"] as const).map((name) => (
|
|
193
|
+
<DateField
|
|
194
|
+
key={name}
|
|
195
|
+
name={name}
|
|
196
|
+
label={copy.form[name]}
|
|
197
|
+
placeholder={copy.form.datePlaceholder}
|
|
198
|
+
invalidMessage={copy.form.invalidDate}
|
|
199
|
+
incompleteMessage={copy.form.dateIncomplete}
|
|
200
|
+
/>
|
|
201
|
+
))}
|
|
202
|
+
</Box>
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
255
206
|
/**
|
|
256
207
|
* "Quando vale" — the campaign PERIOD, and the weekly schedule inside it
|
|
257
208
|
* (FUT-996).
|
|
@@ -281,14 +232,19 @@ export function WindowFields({
|
|
|
281
232
|
onScheduleChange: (next: DiscountSchedule) => void;
|
|
282
233
|
onScheduleEnabledChange: (next: boolean) => void;
|
|
283
234
|
}): JSX.Element {
|
|
235
|
+
// The schedule is not a form value, so nothing paints `errors.schedule` for
|
|
236
|
+
// it: it is handed to the builder, and cleared on the next edit the way a
|
|
237
|
+
// field's own error is (FUT-4155).
|
|
238
|
+
const { errors, setFieldValue } = useFormContext();
|
|
239
|
+
const scheduleError = errors.schedule;
|
|
240
|
+
const clearScheduleError = (): void => {
|
|
241
|
+
if (scheduleError !== undefined) setFieldValue("schedule", "");
|
|
242
|
+
};
|
|
284
243
|
return (
|
|
285
244
|
<Stack spacing={1.5}>
|
|
286
245
|
<Text variant="heading">{copy.schedule.sectionTitle}</Text>
|
|
287
246
|
<Stack spacing={0.5}>
|
|
288
|
-
<
|
|
289
|
-
<Fields.TextField name="startsAt" label={copy.form.startsAt} type="date" />
|
|
290
|
-
<Fields.TextField name="endsAt" label={copy.form.endsAt} type="date" />
|
|
291
|
-
</Box>
|
|
247
|
+
<PeriodFields copy={copy} />
|
|
292
248
|
<Text variant="caption">{copy.schedule.periodHint}</Text>
|
|
293
249
|
</Stack>
|
|
294
250
|
<FormControl>
|
|
@@ -303,7 +259,10 @@ export function WindowFields({
|
|
|
303
259
|
type="radio"
|
|
304
260
|
name="discount-repetition"
|
|
305
261
|
checked={scheduleEnabled === value}
|
|
306
|
-
onChange={() =>
|
|
262
|
+
onChange={() => {
|
|
263
|
+
clearScheduleError();
|
|
264
|
+
onScheduleEnabledChange(value);
|
|
265
|
+
}}
|
|
307
266
|
data-testid={value ? "repetition-specific" : "repetition-always"}
|
|
308
267
|
/>
|
|
309
268
|
<Text variant="body">{label}</Text>
|
|
@@ -317,7 +276,11 @@ export function WindowFields({
|
|
|
317
276
|
schedule={schedule}
|
|
318
277
|
copy={copy}
|
|
319
278
|
timezone={timezoneLabel ?? ""}
|
|
320
|
-
|
|
279
|
+
error={scheduleError}
|
|
280
|
+
onChange={(next) => {
|
|
281
|
+
clearScheduleError();
|
|
282
|
+
onScheduleChange(next);
|
|
283
|
+
}}
|
|
321
284
|
/>
|
|
322
285
|
<OrderScopeScheduleNote copy={copy} />
|
|
323
286
|
</>
|
|
@@ -355,8 +318,8 @@ export function LimitFields({
|
|
|
355
318
|
return (
|
|
356
319
|
<Box sx={{ display: "grid", gap: 2, gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr 1fr" } }}>
|
|
357
320
|
<Currency name="minSubtotal" label={copy.form.minSubtotal} />
|
|
358
|
-
<
|
|
359
|
-
<
|
|
321
|
+
<WholeNumberField name="usageLimit" label={copy.form.usageLimit} />
|
|
322
|
+
<WholeNumberField name="perBuyerLimit" label={copy.form.perBuyerLimit} />
|
|
360
323
|
</Box>
|
|
361
324
|
);
|
|
362
325
|
}
|