@12-apps/discounts 1.17.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 +3 -3
- package/src/react/api.ts +6 -2
- 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/copy.ts +7 -4
- package/src/react/discount-actions-menu.tsx +20 -26
- package/src/react/discount-dialog.tsx +67 -0
- package/src/react/discount-form-fields.tsx +4 -40
- package/src/react/discount-form-validate.ts +3 -2
- package/src/react/discount-form-values.ts +1 -1
- package/src/react/discount-form.tsx +61 -52
- package/src/react/discount-target-picker.tsx +1 -1
- package/src/react/discounts-screen.tsx +11 -17
- package/src/react/en-US.ts +27 -0
- package/src/react/form-kind.ts +42 -48
- package/src/react/kind-field.tsx +8 -4
- package/src/react/pt-BR.ts +27 -0
- package/src/react/table-config.ts +15 -1
- package/src/server/collections.ts +20 -0
- package/src/server/index.ts +1 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { ComboReward } from "./form-kind";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The combo reward's words (FUT-4417): the three choices, the sentence under
|
|
5
|
+
* each, and "Como fica no carrinho" — what the group's items sum to and what
|
|
6
|
+
* the buyer pays for them under this reward.
|
|
7
|
+
*
|
|
8
|
+
* A group of its own rather than more `form` keys because the preview is a
|
|
9
|
+
* small grammar of its own: which sentence is said depends on the reward, on
|
|
10
|
+
* whether the items' prices are one figure or a range, and on whether the form
|
|
11
|
+
* is wide enough to put the preview in its own column (where it reads in short
|
|
12
|
+
* sentences plus an example line) or under the field (one sentence carrying
|
|
13
|
+
* the range). Each placeholder is a figure the package has already formatted.
|
|
14
|
+
*/
|
|
15
|
+
export interface DiscountsComboPreviewCopy {
|
|
16
|
+
/** The box's heading. */
|
|
17
|
+
readonly title: string;
|
|
18
|
+
/** `{units}` ("5 itens"), `{min}`, `{max}`: what one combo's items sum to. */
|
|
19
|
+
readonly itemsRange: string;
|
|
20
|
+
/** The same when every item the groups can take costs the same. `{units}`, `{sum}`. */
|
|
21
|
+
readonly itemsExact: string;
|
|
22
|
+
/** `{min}`, `{max}`: what the buyer pays, as a range. */
|
|
23
|
+
readonly paysRange: string;
|
|
24
|
+
/** `{min}`, `{max}`, `{off}` (the rate, "10%"): the percentage's range, under the field. */
|
|
25
|
+
readonly paysRangeOff: string;
|
|
26
|
+
/** `{pays}`: one figure. */
|
|
27
|
+
readonly paysExact: string;
|
|
28
|
+
/** `{pays}`, `{off}`: one figure, at a rate. */
|
|
29
|
+
readonly paysExactOff: string;
|
|
30
|
+
/** `{off}` — "10%" or "R$ 15,00": the reward itself, in the wide layout. */
|
|
31
|
+
readonly paysOff: string;
|
|
32
|
+
/** Closed price under every items sum: `{price}`, `{min}`, `{max}` (the saving's range). */
|
|
33
|
+
readonly paysPriceSaving: string;
|
|
34
|
+
/** The same with one saving figure: `{price}`, `{saving}`. */
|
|
35
|
+
readonly paysPriceSavingExact: string;
|
|
36
|
+
/**
|
|
37
|
+
* Closed price EQUAL to the cheapest sum: the cheapest combo saves nothing,
|
|
38
|
+
* so the saving reads "up to" rather than "from R$ 0,00 to": `{price}`, `{max}`.
|
|
39
|
+
*/
|
|
40
|
+
readonly paysPriceSavingUpTo: string;
|
|
41
|
+
/** Closed price between the cheapest and dearest sums: `{min}`, `{price}`, `{max}` (most saved). */
|
|
42
|
+
readonly paysUpToPrice: string;
|
|
43
|
+
/** Closed price at or above the dearest sum: nothing is saved. */
|
|
44
|
+
readonly noSaving: string;
|
|
45
|
+
/** The wide layout's third line: `{cheapest}` and `{dearest}`, each an `example`. */
|
|
46
|
+
readonly examples: string;
|
|
47
|
+
/** `{quantity}`, `{name}` (as the catalog stores it), `{pays}`. */
|
|
48
|
+
readonly example: string;
|
|
49
|
+
/** The same with what it saves: `{quantity}`, `{name}`, `{pays}`, `{saving}`. */
|
|
50
|
+
readonly exampleSaving: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface DiscountsComboRewardsCopy {
|
|
54
|
+
/** The toggle's three choices, in `COMBO_REWARDS` order. */
|
|
55
|
+
readonly labels: Readonly<Record<ComboReward, string>>;
|
|
56
|
+
/** The sentence under the reward's field, one per choice. */
|
|
57
|
+
readonly hints: Readonly<Record<ComboReward, string>>;
|
|
58
|
+
readonly preview: DiscountsComboPreviewCopy;
|
|
59
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useLayoutEffect, useRef, useState, type JSX, type RefObject } from "react";
|
|
4
|
+
|
|
5
|
+
import { Fields, useFormContext } from "@12-apps/ui/form/total-form";
|
|
6
|
+
import { Box } from "@12-apps/ui/mui/Box";
|
|
7
|
+
import { Stack } from "@12-apps/ui/mui/Stack";
|
|
8
|
+
import { Text } from "@12-apps/ui/typography/Text";
|
|
9
|
+
|
|
10
|
+
import type { DiscountsWebCopy } from "./copy";
|
|
11
|
+
import { InlineComboCartPreview } from "./combo-cart-preview";
|
|
12
|
+
import type { CurrencyFieldComponent } from "./discount-form-fields";
|
|
13
|
+
import { COMBO_REWARDS, comboRewardOf } from "./form-kind";
|
|
14
|
+
import { useElementWidth } from "./kind-field";
|
|
15
|
+
import { PercentField } from "./number-fields";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A combo's reward: which of the three, how much, and what it comes to in the
|
|
19
|
+
* cart (FUT-4417).
|
|
20
|
+
*
|
|
21
|
+
* All of it is here rather than split between the top of the form and the
|
|
22
|
+
* bottom, because to a merchant it is one sentence — "este combo dá 15% de
|
|
23
|
+
* desconto", "…sai por R$ 120". "Preço fechado" is the third choice: the
|
|
24
|
+
* matched group costs exactly the price typed, whatever its items cost. That
|
|
25
|
+
* used to be refused as a choice because it goes on charging the same when an
|
|
26
|
+
* item's price changes — which is the point of a closed price, so the hint says
|
|
27
|
+
* it in those words and the preview shows it against the store's real prices.
|
|
28
|
+
*
|
|
29
|
+
* **One line at every width** (ADR 0190 in the host), like the kind question
|
|
30
|
+
* (`./kind-field`): a segmented control of three equal parts where they fit,
|
|
31
|
+
* the same field as a select where they do not. The value's field sits beside
|
|
32
|
+
* the choices where both fit on one row, and under them otherwise.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* What one part needs before a label wraps inside it, when nothing could
|
|
37
|
+
* measure it (a test DOM): the kind toggle's own figure for "Porcentagem".
|
|
38
|
+
*/
|
|
39
|
+
const FALLBACK_PART_PX = 136;
|
|
40
|
+
|
|
41
|
+
/** The gap between the choices and the value's field, `gap: 2`. */
|
|
42
|
+
const ROW_GAP_PX = 16;
|
|
43
|
+
|
|
44
|
+
const PART_SELECTOR = '[data-testid^="total-form-field-comboReward-item-"]';
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A part's width with its label on ONE line: a hidden, unwrapped clone of it,
|
|
48
|
+
* measured beside it so it inherits the same type.
|
|
49
|
+
*/
|
|
50
|
+
function naturalWidth(part: HTMLElement): number {
|
|
51
|
+
const clone = part.cloneNode(true) as HTMLElement;
|
|
52
|
+
Object.assign(clone.style, {
|
|
53
|
+
position: "absolute",
|
|
54
|
+
visibility: "hidden",
|
|
55
|
+
width: "auto",
|
|
56
|
+
minWidth: "0",
|
|
57
|
+
flex: "none",
|
|
58
|
+
whiteSpace: "nowrap",
|
|
59
|
+
});
|
|
60
|
+
(part.parentElement ?? document.body).appendChild(clone);
|
|
61
|
+
const width = clone.getBoundingClientRect().width;
|
|
62
|
+
clone.remove();
|
|
63
|
+
return width;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The width the three equal parts need, measured off the toggle the first time
|
|
68
|
+
* it is on screen — the theme decides the type size and the padding, so a
|
|
69
|
+
* figure typed here would be wrong in the next density mode. Parts are equal,
|
|
70
|
+
* so the widest label sizes all three; the group's own padding and gaps are
|
|
71
|
+
* added on top.
|
|
72
|
+
*/
|
|
73
|
+
function useTogglePartsWidth(box: RefObject<HTMLElement | null>): number {
|
|
74
|
+
const [measured, setMeasured] = useState<number | null>(null);
|
|
75
|
+
useLayoutEffect(() => {
|
|
76
|
+
if (measured !== null || box.current === null) return;
|
|
77
|
+
const parts = [...box.current.querySelectorAll<HTMLElement>(PART_SELECTOR)];
|
|
78
|
+
const group = parts[0]?.parentElement;
|
|
79
|
+
if (parts.length === 0 || !group) return;
|
|
80
|
+
const widest = Math.max(...parts.map(naturalWidth));
|
|
81
|
+
if (widest <= 0) return;
|
|
82
|
+
const style = getComputedStyle(group);
|
|
83
|
+
const chrome =
|
|
84
|
+
(parseFloat(style.paddingLeft) || 0) +
|
|
85
|
+
(parseFloat(style.paddingRight) || 0) +
|
|
86
|
+
(parseFloat(style.columnGap) || 0) * (parts.length - 1);
|
|
87
|
+
// +1 px a part: sub-pixel widths round the other way often enough to wrap.
|
|
88
|
+
setMeasured(Math.ceil((widest + 1) * parts.length + chrome));
|
|
89
|
+
});
|
|
90
|
+
return measured ?? COMBO_REWARDS.length * FALLBACK_PART_PX;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The value's input for the chosen reward; the other two are unmounted (`discounts_value_check`). */
|
|
94
|
+
function RewardValueField({
|
|
95
|
+
reward,
|
|
96
|
+
copy,
|
|
97
|
+
currencyField,
|
|
98
|
+
}: {
|
|
99
|
+
reward: string;
|
|
100
|
+
copy: DiscountsWebCopy;
|
|
101
|
+
currencyField: CurrencyFieldComponent;
|
|
102
|
+
}): JSX.Element {
|
|
103
|
+
const Currency = currencyField;
|
|
104
|
+
if (reward === "BUNDLE_PRICE")
|
|
105
|
+
return <Currency name="bundlePrice" label={copy.form.bundlePrice} />;
|
|
106
|
+
if (reward === "FIXED_AMOUNT") return <Currency name="amountOff" label={copy.form.amountOff} />;
|
|
107
|
+
return <PercentField copy={copy} />;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function ComboRewardFields({
|
|
111
|
+
copy,
|
|
112
|
+
currencyField,
|
|
113
|
+
}: {
|
|
114
|
+
copy: DiscountsWebCopy;
|
|
115
|
+
currencyField: CurrencyFieldComponent;
|
|
116
|
+
}): JSX.Element {
|
|
117
|
+
const { values } = useFormContext();
|
|
118
|
+
const box = useRef<HTMLDivElement>(null);
|
|
119
|
+
const width = useElementWidth(box);
|
|
120
|
+
const needed = useTogglePartsWidth(box);
|
|
121
|
+
const reward = comboRewardOf({ type: values.comboReward ?? "" });
|
|
122
|
+
const options = COMBO_REWARDS.map((value) => ({
|
|
123
|
+
value,
|
|
124
|
+
label: copy.comboRewards.labels[value],
|
|
125
|
+
}));
|
|
126
|
+
const sideBySide = width === null || width >= 2 * needed + ROW_GAP_PX;
|
|
127
|
+
const column = width === null ? null : sideBySide ? (width - ROW_GAP_PX) / 2 : width;
|
|
128
|
+
const fits = column === null || column >= needed;
|
|
129
|
+
return (
|
|
130
|
+
<Stack spacing={0.5} ref={box} sx={{ minWidth: 0 }}>
|
|
131
|
+
<Box
|
|
132
|
+
data-testid="combo-reward-row"
|
|
133
|
+
sx={{
|
|
134
|
+
display: "grid",
|
|
135
|
+
gap: 2,
|
|
136
|
+
gridTemplateColumns: sideBySide ? "1fr 1fr" : "1fr",
|
|
137
|
+
}}
|
|
138
|
+
>
|
|
139
|
+
<Box sx={{ minWidth: 0 }}>
|
|
140
|
+
{fits ? (
|
|
141
|
+
<Fields.ToggleField
|
|
142
|
+
name="comboReward"
|
|
143
|
+
label={copy.form.comboReward}
|
|
144
|
+
options={options}
|
|
145
|
+
/>
|
|
146
|
+
) : (
|
|
147
|
+
<Fields.SelectField
|
|
148
|
+
name="comboReward"
|
|
149
|
+
label={copy.form.comboReward}
|
|
150
|
+
options={options}
|
|
151
|
+
/>
|
|
152
|
+
)}
|
|
153
|
+
</Box>
|
|
154
|
+
<RewardValueField reward={reward} copy={copy} currencyField={currencyField} />
|
|
155
|
+
</Box>
|
|
156
|
+
<Text variant="caption" data-testid="combo-reward-hint">
|
|
157
|
+
{copy.comboRewards.hints[reward]}
|
|
158
|
+
</Text>
|
|
159
|
+
<InlineComboCartPreview />
|
|
160
|
+
</Stack>
|
|
161
|
+
);
|
|
162
|
+
}
|
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;
|
|
@@ -319,6 +321,7 @@ export interface DiscountsWebCopy {
|
|
|
319
321
|
readonly schedule: DiscountsScheduleCopy;
|
|
320
322
|
readonly form: DiscountsFormCopy;
|
|
321
323
|
readonly combo: DiscountsComboCopy;
|
|
324
|
+
readonly comboRewards: DiscountsComboRewardsCopy;
|
|
322
325
|
readonly targets: DiscountsTargetCopy;
|
|
323
326
|
readonly actions: DiscountsActionsCopy;
|
|
324
327
|
readonly card: DiscountsCardCopy;
|
|
@@ -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,8 @@ 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";
|
|
17
18
|
import { DateField } from "./date-field";
|
|
18
19
|
import { PercentField, WholeNumberField } from "./number-fields";
|
|
19
20
|
import { ScheduleBuilder } from "./schedule-builder";
|
|
@@ -75,43 +76,6 @@ export function optionsFor<TKey extends string>(
|
|
|
75
76
|
*/
|
|
76
77
|
export type CurrencyFieldComponent = (props: { name: string; label: string }) => JSX.Element;
|
|
77
78
|
|
|
78
|
-
/**
|
|
79
|
-
* A combo's reward: which of the two, and how much.
|
|
80
|
-
*
|
|
81
|
-
* Both are here rather than split between the top of the form and the bottom,
|
|
82
|
-
* because to a merchant they are one sentence — "este combo dá 15% de
|
|
83
|
-
* desconto". The two are the ONLY rewards a combo can give: a flat bundle price
|
|
84
|
-
* reprices the group and goes silently wrong the first time one of its items
|
|
85
|
-
* changes price, so it is not offered (see `./form-kind`).
|
|
86
|
-
*/
|
|
87
|
-
function ComboRewardFields({
|
|
88
|
-
copy,
|
|
89
|
-
currencyField,
|
|
90
|
-
}: {
|
|
91
|
-
copy: DiscountsWebCopy;
|
|
92
|
-
currencyField: CurrencyFieldComponent;
|
|
93
|
-
}): JSX.Element {
|
|
94
|
-
const { values } = useFormContext();
|
|
95
|
-
const Currency = currencyField;
|
|
96
|
-
return (
|
|
97
|
-
<Stack spacing={0.5}>
|
|
98
|
-
<Box sx={{ display: "grid", gap: 2, gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" } }}>
|
|
99
|
-
<Fields.ToggleField
|
|
100
|
-
name="comboReward"
|
|
101
|
-
label={copy.form.comboReward}
|
|
102
|
-
options={optionsFor(COMBO_REWARDS, copy.labels.type)}
|
|
103
|
-
/>
|
|
104
|
-
{values.comboReward === "FIXED_AMOUNT" ? (
|
|
105
|
-
<Currency name="amountOff" label={copy.form.amountOff} />
|
|
106
|
-
) : (
|
|
107
|
-
<PercentField copy={copy} />
|
|
108
|
-
)}
|
|
109
|
-
</Box>
|
|
110
|
-
<Text variant="caption">{copy.form.comboRewardHint}</Text>
|
|
111
|
-
</Stack>
|
|
112
|
-
);
|
|
113
|
-
}
|
|
114
|
-
|
|
115
79
|
/**
|
|
116
80
|
* The reward, in whichever unit the chosen KIND actually stores.
|
|
117
81
|
*
|
|
@@ -123,7 +87,8 @@ function ComboRewardFields({
|
|
|
123
87
|
*
|
|
124
88
|
* `FREE_UNITS` is the one kind with nothing here: its reward is a count of the
|
|
125
89
|
* units it gives away, which only reads as an offer beside the units it takes,
|
|
126
|
-
* 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`.
|
|
127
92
|
*/
|
|
128
93
|
export function RewardFields({
|
|
129
94
|
copy,
|
|
@@ -137,7 +102,6 @@ export function RewardFields({
|
|
|
137
102
|
const kind = values.kind ?? "PERCENTAGE";
|
|
138
103
|
if (kind === "COMBO") return <ComboRewardFields copy={copy} currencyField={currencyField} />;
|
|
139
104
|
if (kind === "FREE_UNITS") return null;
|
|
140
|
-
if (kind === "BUNDLE_PRICE") return <Currency name="bundlePrice" label={copy.form.bundlePrice} />;
|
|
141
105
|
if (kind === "FIXED_AMOUNT") return <Currency name="amountOff" label={copy.form.amountOff} />;
|
|
142
106
|
return <PercentField copy={copy} />;
|
|
143
107
|
}
|
|
@@ -108,8 +108,9 @@ function checkPercent(
|
|
|
108
108
|
/**
|
|
109
109
|
* The reward, checked against the input the chosen KIND actually mounted.
|
|
110
110
|
*
|
|
111
|
-
* `type` is derived rather than read: a combo's reward is a percentage
|
|
112
|
-
* 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
|
|
113
114
|
* the same mapping the submit does, and the two cannot disagree about which
|
|
114
115
|
* input holds the number.
|
|
115
116
|
*/
|
|
@@ -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;
|
|
@@ -27,12 +27,13 @@ import {
|
|
|
27
27
|
WindowFields,
|
|
28
28
|
type CurrencyFieldComponent,
|
|
29
29
|
} from "./discount-form-fields";
|
|
30
|
+
import { ComboPreviewLayout } from "./combo-cart-preview";
|
|
30
31
|
import { DiscountTargetPicker } from "./discount-target-picker";
|
|
31
32
|
import { KindField } from "./kind-field";
|
|
32
33
|
import { blankWindow } from "./schedule-builder";
|
|
33
34
|
import { validate, type FormTargets } from "./discount-form-validate";
|
|
34
35
|
import { initialValues, type DiscountFormValues } from "./discount-form-values";
|
|
35
|
-
import {
|
|
36
|
+
import { DISCOUNT_KINDS, typeAndScopeFor } from "./form-kind";
|
|
36
37
|
import type { DiscountsFormatters } from "./format";
|
|
37
38
|
|
|
38
39
|
/**
|
|
@@ -195,58 +196,66 @@ export function DiscountForm(props: DiscountFormProps): JSX.Element {
|
|
|
195
196
|
onSubmit={handleSubmit}
|
|
196
197
|
dataTestId="discount-form"
|
|
197
198
|
>
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
timezoneLabel={props.timezoneLabel ?? null}
|
|
222
|
-
onScheduleChange={extra.setSchedule}
|
|
223
|
-
onScheduleEnabledChange={extra.setScheduleEnabled}
|
|
224
|
-
/>
|
|
225
|
-
<LimitFields copy={copy} currencyField={currencyField} />
|
|
226
|
-
<FirstOrderField
|
|
227
|
-
copy={copy}
|
|
228
|
-
checked={extra.firstOrderOnly}
|
|
229
|
-
onChange={extra.setFirstOrderOnly}
|
|
230
|
-
/>
|
|
231
|
-
<DiscountSwitches
|
|
232
|
-
copy={copy}
|
|
233
|
-
active={extra.active}
|
|
234
|
-
stackable={extra.stackable}
|
|
235
|
-
onActive={extra.setActive}
|
|
236
|
-
onStackable={extra.setStackable}
|
|
237
|
-
/>
|
|
238
|
-
{error && (
|
|
239
|
-
<Alert
|
|
240
|
-
variant="danger"
|
|
241
|
-
title={copy.form.saveFailed}
|
|
242
|
-
description={error}
|
|
243
|
-
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
|
+
}}
|
|
244
222
|
/>
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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>
|
|
250
259
|
<FormErrorSnackbar message={copy.form.errorSnackbar} />
|
|
251
260
|
</FormContainer>
|
|
252
261
|
);
|