@12-apps/discounts 1.3.0 → 1.5.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 +38 -3
- package/package.json +2 -2
- package/src/react/api.ts +27 -3
- package/src/react/combo-slot-builder.tsx +214 -0
- package/src/react/copy.ts +76 -0
- package/src/react/discount-actions-menu.tsx +39 -15
- package/src/react/discount-form-fields.tsx +72 -1
- package/src/react/discount-form.tsx +134 -13
- package/src/react/discount-target-picker.tsx +37 -81
- package/src/react/discounts-screen.tsx +113 -39
- package/src/react/pt-BR.ts +34 -0
- package/src/react/target-picker-field.tsx +143 -0
|
@@ -12,8 +12,10 @@ 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 { ComboRequirement } from "../engine/types";
|
|
16
|
+
|
|
15
17
|
import type { DiscountFormPayload, DiscountsApiClient, DiscountWireRecord, WireTargetGroup } from "./api";
|
|
16
|
-
import type
|
|
18
|
+
import { fill, type DiscountsWebCopy } from "./copy";
|
|
17
19
|
import {
|
|
18
20
|
DiscountSwitches,
|
|
19
21
|
LimitFields,
|
|
@@ -22,6 +24,7 @@ import {
|
|
|
22
24
|
WindowFields,
|
|
23
25
|
type CurrencyFieldComponent,
|
|
24
26
|
} from "./discount-form-fields";
|
|
27
|
+
import { comboUnits } from "./combo-slot-builder";
|
|
25
28
|
import { DiscountTargetPicker } from "./discount-target-picker";
|
|
26
29
|
import type { DiscountsFormatters } from "./format";
|
|
27
30
|
|
|
@@ -46,6 +49,9 @@ interface DiscountFormValues extends Record<string, string> {
|
|
|
46
49
|
type: string;
|
|
47
50
|
percentOff: string;
|
|
48
51
|
amountOff: string;
|
|
52
|
+
bundlePrice: string;
|
|
53
|
+
freeUnits: string;
|
|
54
|
+
maxComboApplications: string;
|
|
49
55
|
scope: string;
|
|
50
56
|
trigger: string;
|
|
51
57
|
code: string;
|
|
@@ -67,6 +73,9 @@ const BLANK: DiscountFormValues = {
|
|
|
67
73
|
type: "PERCENTAGE",
|
|
68
74
|
percentOff: "",
|
|
69
75
|
amountOff: "",
|
|
76
|
+
bundlePrice: "",
|
|
77
|
+
freeUnits: "",
|
|
78
|
+
maxComboApplications: "",
|
|
70
79
|
scope: "ORDER",
|
|
71
80
|
trigger: "AUTOMATIC",
|
|
72
81
|
code: "",
|
|
@@ -89,6 +98,10 @@ function initialValues(
|
|
|
89
98
|
type: editing.type,
|
|
90
99
|
percentOff: editing.percentOffBp === null ? "" : formatters.toInput(editing.percentOffBp / 100),
|
|
91
100
|
amountOff: cents(editing.amountOffCents),
|
|
101
|
+
bundlePrice: cents(editing.bundlePriceCents ?? null),
|
|
102
|
+
freeUnits: editing.freeUnits == null ? "" : String(editing.freeUnits),
|
|
103
|
+
maxComboApplications:
|
|
104
|
+
editing.maxComboApplications == null ? "" : String(editing.maxComboApplications),
|
|
92
105
|
scope: editing.scope,
|
|
93
106
|
trigger: editing.trigger,
|
|
94
107
|
code: editing.code ?? "",
|
|
@@ -100,31 +113,125 @@ function initialValues(
|
|
|
100
113
|
};
|
|
101
114
|
}
|
|
102
115
|
|
|
103
|
-
/**
|
|
116
|
+
/** A whole number above zero, as typed. `null` when it is neither. */
|
|
117
|
+
function positiveCount(typed: string): number | null {
|
|
118
|
+
const trimmed = typed.trim();
|
|
119
|
+
if (trimmed === "") return null;
|
|
120
|
+
const value = Number(trimmed);
|
|
121
|
+
return Number.isInteger(value) && value > 0 ? value : null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** A money field that has to hold something above zero. */
|
|
125
|
+
function checkMoney(
|
|
126
|
+
typed: string,
|
|
127
|
+
field: string,
|
|
128
|
+
message: string,
|
|
129
|
+
formatters: DiscountsFormatters,
|
|
130
|
+
): Record<string, string> {
|
|
131
|
+
const amount = formatters.parseDecimal(typed);
|
|
132
|
+
return amount === null || amount <= 0 ? { [field]: message } : {};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The free count, which is the one reward bounded by another field.
|
|
137
|
+
*
|
|
138
|
+
* "Take 3, three free" is a giveaway rather than a promotion, so it is compared
|
|
139
|
+
* against what ONE application of the combo takes out of the cart. The ceiling
|
|
140
|
+
* is named in the message: an operator told only "too many" has to guess the
|
|
141
|
+
* number.
|
|
142
|
+
*/
|
|
143
|
+
function checkFreeUnits(
|
|
144
|
+
typed: string,
|
|
145
|
+
slots: readonly ComboRequirement[],
|
|
146
|
+
copy: DiscountsWebCopy,
|
|
147
|
+
): Record<string, string> {
|
|
148
|
+
const free = positiveCount(typed);
|
|
149
|
+
if (free === null) return { freeUnits: copy.form.invalidFreeUnits };
|
|
150
|
+
const units = comboUnits(slots);
|
|
151
|
+
if (free >= units) {
|
|
152
|
+
return {
|
|
153
|
+
freeUnits: fill(copy.form.freeUnitsExceedCombo, { units, max: Math.max(units - 1, 0) }),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
return {};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The rate, in the 0–100 an operator thinks in rather than basis points. */
|
|
160
|
+
function checkPercent(
|
|
161
|
+
typed: string,
|
|
162
|
+
formatters: DiscountsFormatters,
|
|
163
|
+
copy: DiscountsWebCopy,
|
|
164
|
+
): Record<string, string> {
|
|
165
|
+
const percent = formatters.parseDecimal(typed);
|
|
166
|
+
return percent === null || percent <= 0 || percent > 100
|
|
167
|
+
? { percentOff: copy.form.invalidPercent }
|
|
168
|
+
: {};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The reward, checked against the input the chosen type actually mounted. */
|
|
104
172
|
function checkValue(
|
|
105
173
|
values: DiscountFormValues,
|
|
174
|
+
slots: readonly ComboRequirement[],
|
|
106
175
|
formatters: DiscountsFormatters,
|
|
107
176
|
copy: DiscountsWebCopy,
|
|
108
177
|
): Record<string, string> {
|
|
109
|
-
if (values.type === "
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
return {};
|
|
178
|
+
if (values.type === "FIXED_AMOUNT") {
|
|
179
|
+
return checkMoney(values.amountOff, "amountOff", copy.form.invalidAmount, formatters);
|
|
180
|
+
}
|
|
181
|
+
if (values.type === "BUNDLE_PRICE") {
|
|
182
|
+
return checkMoney(values.bundlePrice, "bundlePrice", copy.form.invalidBundlePrice, formatters);
|
|
115
183
|
}
|
|
116
|
-
|
|
117
|
-
return
|
|
184
|
+
if (values.type === "FREE_UNITS") return checkFreeUnits(values.freeUnits, slots, copy);
|
|
185
|
+
return checkPercent(values.percentOff, formatters, copy);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The combo half: the groups themselves, and the per-cart cap.
|
|
190
|
+
*
|
|
191
|
+
* Every rule here is also the server's (`validate-combo.ts`), and deliberately
|
|
192
|
+
* so — this half exists to attach the refusal to the input the operator is
|
|
193
|
+
* looking at, never to be the authority.
|
|
194
|
+
*/
|
|
195
|
+
function checkCombo(
|
|
196
|
+
values: DiscountFormValues,
|
|
197
|
+
slots: readonly ComboRequirement[],
|
|
198
|
+
copy: DiscountsWebCopy,
|
|
199
|
+
): Record<string, string> {
|
|
200
|
+
if (values.scope !== "COMBO") return {};
|
|
201
|
+
if (slots.length === 0) return { scope: copy.form.comboSlotsRequired };
|
|
202
|
+
const errors: Record<string, string> = {};
|
|
203
|
+
if (slots.some((slot) => !Number.isInteger(slot.quantity) || slot.quantity <= 0)) {
|
|
204
|
+
errors.scope = copy.form.invalidComboQuantity;
|
|
205
|
+
} else if (
|
|
206
|
+
slots.some((slot) => slot.categoryIds.length === 0 && slot.menuItemIds.length === 0)
|
|
207
|
+
) {
|
|
208
|
+
errors.scope = copy.form.comboSlotTargetRequired;
|
|
209
|
+
}
|
|
210
|
+
if (values.maxComboApplications.trim() !== "" && positiveCount(values.maxComboApplications) === null) {
|
|
211
|
+
errors.maxComboApplications = copy.form.invalidMaxComboApplications;
|
|
212
|
+
}
|
|
213
|
+
return errors;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** What the pickers and the builder currently hold, in one argument. */
|
|
217
|
+
interface FormTargets {
|
|
218
|
+
categoryIds: string[];
|
|
219
|
+
menuItemIds: string[];
|
|
220
|
+
comboRequirements: ComboRequirement[];
|
|
118
221
|
}
|
|
119
222
|
|
|
120
223
|
/** The cross-field rules a per-input schema cannot express. */
|
|
121
224
|
function validate(
|
|
122
225
|
values: DiscountFormValues,
|
|
123
|
-
targets:
|
|
226
|
+
targets: FormTargets,
|
|
124
227
|
formatters: DiscountsFormatters,
|
|
125
228
|
copy: DiscountsWebCopy,
|
|
126
229
|
): Record<string, string> {
|
|
127
|
-
const
|
|
230
|
+
const slots = targets.comboRequirements;
|
|
231
|
+
const errors: Record<string, string> = {
|
|
232
|
+
...checkValue(values, slots, formatters, copy),
|
|
233
|
+
...checkCombo(values, slots, copy),
|
|
234
|
+
};
|
|
128
235
|
if (values.trigger === "CODE" && values.code.trim() === "") {
|
|
129
236
|
errors.code = copy.form.codeRequired;
|
|
130
237
|
}
|
|
@@ -148,15 +255,23 @@ function useNonStringState(editing: DiscountWireRecord | null) {
|
|
|
148
255
|
const [stackable, setStackable] = useState<boolean>(editing?.stackable ?? true);
|
|
149
256
|
const [categoryIds, setCategoryIds] = useState<string[]>(editing?.categoryIds ?? []);
|
|
150
257
|
const [menuItemIds, setMenuItemIds] = useState<string[]>(editing?.menuItemIds ?? []);
|
|
258
|
+
// The combo's groups are the third array, and the one whose ORDER matters:
|
|
259
|
+
// the list the operator built is the list a card reads back, and the server
|
|
260
|
+
// stamps each group's index as its stored `position`.
|
|
261
|
+
const [comboRequirements, setComboRequirements] = useState<ComboRequirement[]>(() =>
|
|
262
|
+
editing?.comboRequirements ? [...editing.comboRequirements] : [],
|
|
263
|
+
);
|
|
151
264
|
return {
|
|
152
265
|
active,
|
|
153
266
|
stackable,
|
|
154
267
|
categoryIds,
|
|
155
268
|
menuItemIds,
|
|
269
|
+
comboRequirements,
|
|
156
270
|
setActive,
|
|
157
271
|
setStackable,
|
|
158
272
|
setCategoryIds,
|
|
159
273
|
setMenuItemIds,
|
|
274
|
+
setComboRequirements,
|
|
160
275
|
};
|
|
161
276
|
}
|
|
162
277
|
|
|
@@ -190,7 +305,11 @@ function makeSubmit(
|
|
|
190
305
|
const { api, copy, formatters, editing, onSaved, onError } = props;
|
|
191
306
|
return async function handleSubmit(values, { setFieldErrors }): Promise<void> {
|
|
192
307
|
setError(null);
|
|
193
|
-
const targets = {
|
|
308
|
+
const targets: FormTargets = {
|
|
309
|
+
categoryIds: extra.categoryIds,
|
|
310
|
+
menuItemIds: extra.menuItemIds,
|
|
311
|
+
comboRequirements: extra.comboRequirements,
|
|
312
|
+
};
|
|
194
313
|
const invalid = validate(values, targets, formatters, copy);
|
|
195
314
|
if (Object.keys(invalid).length > 0) {
|
|
196
315
|
setFieldErrors(invalid);
|
|
@@ -241,8 +360,10 @@ export function DiscountForm(props: DiscountFormProps): JSX.Element {
|
|
|
241
360
|
selection={{
|
|
242
361
|
categoryIds: extra.categoryIds,
|
|
243
362
|
menuItemIds: extra.menuItemIds,
|
|
363
|
+
comboRequirements: extra.comboRequirements,
|
|
244
364
|
onCategoryIdsChange: extra.setCategoryIds,
|
|
245
365
|
onMenuItemIdsChange: extra.setMenuItemIds,
|
|
366
|
+
onComboRequirementsChange: extra.setComboRequirements,
|
|
246
367
|
}}
|
|
247
368
|
/>
|
|
248
369
|
<WindowFields copy={copy} />
|
|
@@ -1,23 +1,24 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import type { JSX } from "react";
|
|
4
4
|
|
|
5
|
-
import { Autocomplete } from "@12-apps/ui/form/Autocomplete";
|
|
6
|
-
import { CategorySelect } from "@12-apps/ui/form/CategorySelect";
|
|
7
|
-
import { FormControl, FormLabel, FormMessage } from "@12-apps/ui/form/Form";
|
|
8
5
|
import { useFormContext } from "@12-apps/ui/form/total-form";
|
|
9
6
|
|
|
7
|
+
import type { ComboRequirement } from "../engine/types";
|
|
8
|
+
|
|
9
|
+
import type { WireTargetGroup } from "./api";
|
|
10
|
+
import { ComboSlotBuilder } from "./combo-slot-builder";
|
|
10
11
|
import { fill, type DiscountsWebCopy } from "./copy";
|
|
11
|
-
import
|
|
12
|
+
import { TargetPickerField } from "./target-picker-field";
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* What a scoped discount points at, picked from the host's own collections.
|
|
15
16
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* `
|
|
20
|
-
*
|
|
17
|
+
* This file is the ROUTER: it reads the scope and decides which shape the
|
|
18
|
+
* targets take. The controls themselves live next door — one collection's
|
|
19
|
+
* picker in `target-picker-field`, the quantified group list in
|
|
20
|
+
* `combo-slot-builder` — because the combo builder needs the former per group
|
|
21
|
+
* and a file cannot import its own importer.
|
|
21
22
|
*
|
|
22
23
|
* Which collections exist is not decided here. The surface reads them from
|
|
23
24
|
* `GET /discounts/targets`, which answers whatever the host registered — so
|
|
@@ -32,62 +33,15 @@ import type { WireTarget, WireTargetGroup } from "./api";
|
|
|
32
33
|
* leaving a stale one on screen would suggest otherwise.
|
|
33
34
|
*/
|
|
34
35
|
|
|
35
|
-
/**
|
|
36
|
-
function TargetField({
|
|
37
|
-
label,
|
|
38
|
-
placeholder,
|
|
39
|
-
requiredMessage,
|
|
40
|
-
options,
|
|
41
|
-
ids,
|
|
42
|
-
onChange,
|
|
43
|
-
dataTestId,
|
|
44
|
-
}: {
|
|
45
|
-
label: string;
|
|
46
|
-
placeholder: string;
|
|
47
|
-
requiredMessage: string;
|
|
48
|
-
options: WireTarget[];
|
|
49
|
-
ids: readonly string[];
|
|
50
|
-
onChange: (next: string[]) => void;
|
|
51
|
-
dataTestId: string;
|
|
52
|
-
}): JSX.Element {
|
|
53
|
-
const [query, setQuery] = useState("");
|
|
54
|
-
const byId = new Map(options.map((option) => [option.id, option]));
|
|
55
|
-
// An id whose option is missing — a target archived since the rule was saved
|
|
56
|
-
// — still renders as a chip, so saving cannot silently drop it.
|
|
57
|
-
const selected = ids.map((id) => byId.get(id) ?? { id, name: id });
|
|
58
|
-
|
|
59
|
-
return (
|
|
60
|
-
<FormControl fullWidth>
|
|
61
|
-
<FormLabel error={ids.length === 0}>{label}</FormLabel>
|
|
62
|
-
<div data-testid={dataTestId}>
|
|
63
|
-
<Autocomplete<WireTarget>
|
|
64
|
-
multiple
|
|
65
|
-
value={query}
|
|
66
|
-
onChange={setQuery}
|
|
67
|
-
suggestions={options}
|
|
68
|
-
selectedItems={selected}
|
|
69
|
-
onSelectedItemsChange={(items) => onChange(items.map((item) => item.id))}
|
|
70
|
-
getKey={(option) => option.id}
|
|
71
|
-
getLabel={(option) => option.name}
|
|
72
|
-
placeholder={placeholder}
|
|
73
|
-
inputAriaLabel={label}
|
|
74
|
-
/>
|
|
75
|
-
</div>
|
|
76
|
-
{ids.length === 0 && (
|
|
77
|
-
<FormMessage error dataTestId={`${dataTestId}-message`}>
|
|
78
|
-
{requiredMessage}
|
|
79
|
-
</FormMessage>
|
|
80
|
-
)}
|
|
81
|
-
</FormControl>
|
|
82
|
-
);
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/** The ids currently chosen for one dimension. */
|
|
36
|
+
/** What the form currently has chosen, in every shape a scope can need. */
|
|
86
37
|
interface DiscountTargetSelection {
|
|
87
38
|
categoryIds: string[];
|
|
88
39
|
menuItemIds: string[];
|
|
40
|
+
/** The combo's groups, in the operator's own order. Empty at every other scope. */
|
|
41
|
+
comboRequirements: readonly ComboRequirement[];
|
|
89
42
|
onCategoryIdsChange: (next: string[]) => void;
|
|
90
43
|
onMenuItemIdsChange: (next: string[]) => void;
|
|
44
|
+
onComboRequirementsChange: (next: ComboRequirement[]) => void;
|
|
91
45
|
}
|
|
92
46
|
|
|
93
47
|
/** Which selection a dimension reads and writes. */
|
|
@@ -112,6 +66,12 @@ function scopeFor(targetType: string): string {
|
|
|
112
66
|
* Reads the LIVE `scope` from the form context, so flipping the toggle swaps
|
|
113
67
|
* the picker immediately without the parent form having to mirror the value
|
|
114
68
|
* into its own state.
|
|
69
|
+
*
|
|
70
|
+
* COMBO is the third branch (FUT-268) and it is a different SHAPE, not another
|
|
71
|
+
* picker: a combo is a list of quantified groups, so it gets the builder and
|
|
72
|
+
* every collection at once, while CATEGORY and ITEM each get exactly one
|
|
73
|
+
* collection's picker. ORDER still renders nothing — an order-wide discount
|
|
74
|
+
* covers everything, so a target list there is not empty, it is meaningless.
|
|
115
75
|
*/
|
|
116
76
|
export function DiscountTargetPicker({
|
|
117
77
|
groups,
|
|
@@ -124,36 +84,32 @@ export function DiscountTargetPicker({
|
|
|
124
84
|
}): JSX.Element | null {
|
|
125
85
|
const { values } = useFormContext();
|
|
126
86
|
const scope = values.scope ?? "ORDER";
|
|
127
|
-
const group = groups.find((entry) => scopeFor(entry.targetType) === scope);
|
|
128
|
-
if (!group) return null;
|
|
129
|
-
|
|
130
|
-
const { ids, onChange } = bind(group.targetType, selection);
|
|
131
|
-
const label = fill(copy.targets.pick, { collection: group.label });
|
|
132
|
-
const dataTestId = `discount-${group.slug}-targets`;
|
|
133
87
|
|
|
134
|
-
if (
|
|
88
|
+
if (scope === "COMBO") {
|
|
135
89
|
return (
|
|
136
|
-
<
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
value={ids}
|
|
142
|
-
onChange={onChange}
|
|
143
|
-
error={ids.length === 0 ? copy.targets.required : undefined}
|
|
144
|
-
dataTestId={dataTestId}
|
|
90
|
+
<ComboSlotBuilder
|
|
91
|
+
slots={selection.comboRequirements}
|
|
92
|
+
groups={groups}
|
|
93
|
+
copy={copy}
|
|
94
|
+
onChange={selection.onComboRequirementsChange}
|
|
145
95
|
/>
|
|
146
96
|
);
|
|
147
97
|
}
|
|
98
|
+
|
|
99
|
+
const group = groups.find((entry) => scopeFor(entry.targetType) === scope);
|
|
100
|
+
if (!group) return null;
|
|
101
|
+
|
|
102
|
+
const { ids, onChange } = bind(group.targetType, selection);
|
|
148
103
|
return (
|
|
149
|
-
<
|
|
150
|
-
|
|
104
|
+
<TargetPickerField
|
|
105
|
+
group={group}
|
|
106
|
+
label={fill(copy.targets.pick, { collection: group.label })}
|
|
151
107
|
placeholder={fill(copy.targets.search, { collection: group.label })}
|
|
152
108
|
requiredMessage={copy.targets.required}
|
|
153
|
-
options={group.targets}
|
|
154
109
|
ids={ids}
|
|
155
110
|
onChange={onChange}
|
|
156
|
-
|
|
111
|
+
copy={copy.categorySelect}
|
|
112
|
+
dataTestId={`discount-${group.slug}-targets`}
|
|
157
113
|
/>
|
|
158
114
|
);
|
|
159
115
|
}
|
|
@@ -6,8 +6,15 @@ import { useSearchParams } from "react-router-dom";
|
|
|
6
6
|
import AddIcon from "@mui/icons-material/Add";
|
|
7
7
|
|
|
8
8
|
import { useServerDataViews } from "@12-apps/app-shell/react";
|
|
9
|
-
import {
|
|
10
|
-
|
|
9
|
+
import {
|
|
10
|
+
CardActionsProvider,
|
|
11
|
+
useRowConfirm,
|
|
12
|
+
} from "@12-apps/ui/data-display/CardKit";
|
|
13
|
+
import {
|
|
14
|
+
DataViewsCopyProvider,
|
|
15
|
+
DataViewsGrid,
|
|
16
|
+
type DataViewState,
|
|
17
|
+
} from "@12-apps/ui/data-display/DataViews";
|
|
11
18
|
import type { RowAction } from "@12-apps/ui/data-display/DataViews";
|
|
12
19
|
import { ErrorState } from "@12-apps/ui/data-display/ErrorState";
|
|
13
20
|
import { LoadingState } from "@12-apps/ui/data-display/LoadingState";
|
|
@@ -87,6 +94,7 @@ function useBulkDelete(
|
|
|
87
94
|
confirmText: copy.actions.delete,
|
|
88
95
|
},
|
|
89
96
|
errorText: copy.actions.deleteFailed,
|
|
97
|
+
copy: copy.confirmAction,
|
|
90
98
|
dataTestId: "discount-bulk-delete-confirm",
|
|
91
99
|
});
|
|
92
100
|
}
|
|
@@ -103,7 +111,9 @@ function HeaderControls({
|
|
|
103
111
|
}): JSX.Element {
|
|
104
112
|
return (
|
|
105
113
|
<>
|
|
106
|
-
<Dashboard.Info title={copy.screen.aboutTitle}>
|
|
114
|
+
<Dashboard.Info title={copy.screen.aboutTitle}>
|
|
115
|
+
{copy.screen.aboutBody}
|
|
116
|
+
</Dashboard.Info>
|
|
107
117
|
<Dashboard.Spacer />
|
|
108
118
|
<Dashboard.Export
|
|
109
119
|
formats={[
|
|
@@ -234,6 +244,68 @@ function CreateDialog({
|
|
|
234
244
|
);
|
|
235
245
|
}
|
|
236
246
|
|
|
247
|
+
/**
|
|
248
|
+
* Everything below the header: the create dialog and the grid.
|
|
249
|
+
*
|
|
250
|
+
* Split out of {@link DiscountsScreen} only to keep that component inside the
|
|
251
|
+
* size gate — this is one tree, not a second concern.
|
|
252
|
+
*/
|
|
253
|
+
function ScreenBody({
|
|
254
|
+
createOpen,
|
|
255
|
+
setCreateOpen,
|
|
256
|
+
menu,
|
|
257
|
+
refresh,
|
|
258
|
+
rows,
|
|
259
|
+
copy,
|
|
260
|
+
appliedState,
|
|
261
|
+
server,
|
|
262
|
+
onVisibleRowsChange,
|
|
263
|
+
bulkDelete,
|
|
264
|
+
}: {
|
|
265
|
+
createOpen: boolean;
|
|
266
|
+
setCreateOpen: (open: boolean) => void;
|
|
267
|
+
menu: MenuBinding;
|
|
268
|
+
refresh: () => void;
|
|
269
|
+
rows: DiscountListItem[];
|
|
270
|
+
copy: DiscountsWebCopy;
|
|
271
|
+
appliedState: DataViewState;
|
|
272
|
+
server: ReturnType<typeof useServerDataViews>;
|
|
273
|
+
onVisibleRowsChange: (rows: DiscountListItem[]) => void;
|
|
274
|
+
bulkDelete: ReturnType<typeof useBulkDelete>;
|
|
275
|
+
}): JSX.Element {
|
|
276
|
+
return (
|
|
277
|
+
<Dashboard.Body>
|
|
278
|
+
<CreateDialog
|
|
279
|
+
open={createOpen}
|
|
280
|
+
menu={menu}
|
|
281
|
+
onClose={() => setCreateOpen(false)}
|
|
282
|
+
onSaved={() => {
|
|
283
|
+
setCreateOpen(false);
|
|
284
|
+
refresh();
|
|
285
|
+
}}
|
|
286
|
+
/>
|
|
287
|
+
<CardActionsProvider
|
|
288
|
+
// The menus read the refresh and the error channel from here; the
|
|
289
|
+
// tenant is already baked into `apiBase`, so there is nothing for a
|
|
290
|
+
// slug to do but be a second place it could disagree.
|
|
291
|
+
tenantSlug=""
|
|
292
|
+
onRefresh={refresh}
|
|
293
|
+
errorTitle={copy.actions.actionFailed}
|
|
294
|
+
>
|
|
295
|
+
<DiscountsGrid
|
|
296
|
+
rows={rows}
|
|
297
|
+
copy={copy}
|
|
298
|
+
menu={menu}
|
|
299
|
+
appliedState={appliedState}
|
|
300
|
+
server={server}
|
|
301
|
+
onVisibleRowsChange={onVisibleRowsChange}
|
|
302
|
+
bulkDelete={bulkDelete}
|
|
303
|
+
/>
|
|
304
|
+
</CardActionsProvider>
|
|
305
|
+
</Dashboard.Body>
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
237
309
|
export function DiscountsScreen(props: DiscountsScreenProps): JSX.Element {
|
|
238
310
|
const { api, copy, formatters, currencyField, onError, breadcrumb } = props;
|
|
239
311
|
const [searchParams] = useSearchParams();
|
|
@@ -246,12 +318,16 @@ export function DiscountsScreen(props: DiscountsScreenProps): JSX.Element {
|
|
|
246
318
|
// "now"s could disagree about which of them is still running.
|
|
247
319
|
const rows = useMemo(() => {
|
|
248
320
|
const now = new Date();
|
|
249
|
-
return (data.page?.data ?? []).map((record) =>
|
|
321
|
+
return (data.page?.data ?? []).map((record) =>
|
|
322
|
+
toListItem(record, formatters, copy, now),
|
|
323
|
+
);
|
|
250
324
|
}, [data.page, formatters, copy]);
|
|
251
325
|
|
|
252
326
|
// Seeded ONCE from the URL. Re-applying it on every render would wipe the
|
|
253
327
|
// operator's column-visibility choices each time the query synced back.
|
|
254
|
-
const [appliedState] = useState<DataViewState>(() =>
|
|
328
|
+
const [appliedState] = useState<DataViewState>(() =>
|
|
329
|
+
discountsAppliedState(searchParams, copy),
|
|
330
|
+
);
|
|
255
331
|
|
|
256
332
|
const server = useServerDataViews({
|
|
257
333
|
totalCount: data.page?.pagination.total ?? 0,
|
|
@@ -260,7 +336,14 @@ export function DiscountsScreen(props: DiscountsScreenProps): JSX.Element {
|
|
|
260
336
|
toParams: discountsQueryToParams,
|
|
261
337
|
});
|
|
262
338
|
|
|
263
|
-
const menu: MenuBinding = {
|
|
339
|
+
const menu: MenuBinding = {
|
|
340
|
+
api,
|
|
341
|
+
copy,
|
|
342
|
+
formatters,
|
|
343
|
+
currencyField,
|
|
344
|
+
groups: data.groups,
|
|
345
|
+
onError,
|
|
346
|
+
};
|
|
264
347
|
|
|
265
348
|
if (data.loading) return <LoadingState dataTestId="discounts-loading" />;
|
|
266
349
|
if (data.error !== null) {
|
|
@@ -275,40 +358,31 @@ export function DiscountsScreen(props: DiscountsScreenProps): JSX.Element {
|
|
|
275
358
|
}
|
|
276
359
|
|
|
277
360
|
return (
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
menu={menu}
|
|
287
|
-
onClose={() => setCreateOpen(false)}
|
|
288
|
-
onSaved={() => {
|
|
289
|
-
setCreateOpen(false);
|
|
290
|
-
data.refresh();
|
|
291
|
-
}}
|
|
292
|
-
/>
|
|
293
|
-
<CardActionsProvider
|
|
294
|
-
// The menus read the refresh and the error channel from here; the
|
|
295
|
-
// tenant is already baked into `apiBase`, so there is nothing for a
|
|
296
|
-
// slug to do but be a second place it could disagree.
|
|
297
|
-
tenantSlug=""
|
|
298
|
-
onRefresh={data.refresh}
|
|
299
|
-
errorTitle={copy.actions.actionFailed}
|
|
300
|
-
>
|
|
301
|
-
<DiscountsGrid
|
|
302
|
-
rows={rows}
|
|
361
|
+
// The grid, its toolbar and its saved-view dialogs read their words from
|
|
362
|
+
// here: `@12-apps/ui` ships none and throws rather than falling back.
|
|
363
|
+
<DataViewsCopyProvider copy={copy.dataViews}>
|
|
364
|
+
<Dashboard testIdPrefix="discounts-dashboard">
|
|
365
|
+
{breadcrumb && <Dashboard.Breadcrumb items={[...breadcrumb]} />}
|
|
366
|
+
<Dashboard.Header title={copy.screen.title}>
|
|
367
|
+
<HeaderControls
|
|
368
|
+
rows={visibleRows}
|
|
303
369
|
copy={copy}
|
|
304
|
-
|
|
305
|
-
appliedState={appliedState}
|
|
306
|
-
server={server}
|
|
307
|
-
onVisibleRowsChange={setVisibleRows}
|
|
308
|
-
bulkDelete={bulkDelete}
|
|
370
|
+
onCreate={() => setCreateOpen(true)}
|
|
309
371
|
/>
|
|
310
|
-
</
|
|
311
|
-
|
|
312
|
-
|
|
372
|
+
</Dashboard.Header>
|
|
373
|
+
<ScreenBody
|
|
374
|
+
createOpen={createOpen}
|
|
375
|
+
setCreateOpen={setCreateOpen}
|
|
376
|
+
menu={menu}
|
|
377
|
+
refresh={data.refresh}
|
|
378
|
+
rows={rows}
|
|
379
|
+
copy={copy}
|
|
380
|
+
appliedState={appliedState}
|
|
381
|
+
server={server}
|
|
382
|
+
onVisibleRowsChange={setVisibleRows}
|
|
383
|
+
bulkDelete={bulkDelete}
|
|
384
|
+
/>
|
|
385
|
+
</Dashboard>
|
|
386
|
+
</DataViewsCopyProvider>
|
|
313
387
|
);
|
|
314
388
|
}
|
package/src/react/pt-BR.ts
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
import {
|
|
2
|
+
PT_BR_CATEGORY_SELECT_COPY,
|
|
3
|
+
PT_BR_CONFIRM_ACTION_COPY,
|
|
4
|
+
PT_BR_DATA_VIEWS_COPY,
|
|
5
|
+
} from "@12-apps/ui/pt-BR";
|
|
6
|
+
|
|
1
7
|
import type { DiscountsWebCopy } from "./copy";
|
|
2
8
|
|
|
3
9
|
/**
|
|
@@ -75,6 +81,10 @@ export const PT_BR_DISCOUNTS_WEB_COPY: DiscountsWebCopy = {
|
|
|
75
81
|
percentOff: "Desconto (%)",
|
|
76
82
|
percentPlaceholder: "10",
|
|
77
83
|
amountOff: "Valor do desconto",
|
|
84
|
+
bundlePrice: "Preço do combo",
|
|
85
|
+
freeUnits: "Itens grátis",
|
|
86
|
+
freeUnitsHint:
|
|
87
|
+
"Quantas unidades do combo saem de graça. Em um grupo de 3, deixar 1 grátis é o clássico \"leve 3, pague 2\".",
|
|
78
88
|
trigger: "Ativação",
|
|
79
89
|
code: "Código do cupom",
|
|
80
90
|
codePlaceholder: "BEMVINDO10",
|
|
@@ -84,6 +94,8 @@ export const PT_BR_DISCOUNTS_WEB_COPY: DiscountsWebCopy = {
|
|
|
84
94
|
minSubtotal: "Pedido mínimo",
|
|
85
95
|
usageLimit: "Limite de usos",
|
|
86
96
|
perBuyerLimit: "Limite por cliente",
|
|
97
|
+
maxComboApplications: "Combos por pedido",
|
|
98
|
+
maxComboApplicationsHint: "Deixe em branco para valer quantas vezes couber no carrinho.",
|
|
87
99
|
active: "Ativo",
|
|
88
100
|
activeHint: "Desligue para pausar a promoção sem excluí-la.",
|
|
89
101
|
stackable: "Acumulável",
|
|
@@ -94,11 +106,30 @@ export const PT_BR_DISCOUNTS_WEB_COPY: DiscountsWebCopy = {
|
|
|
94
106
|
nameRequired: "Informe o nome da promoção.",
|
|
95
107
|
invalidPercent: "Informe uma porcentagem maior que 0 e no máximo 100.",
|
|
96
108
|
invalidAmount: "Informe um valor de desconto maior que zero.",
|
|
109
|
+
invalidBundlePrice: "Informe o preço do combo, maior que zero.",
|
|
110
|
+
invalidFreeUnits: "Informe quantos itens saem de graça, a partir de 1.",
|
|
111
|
+
freeUnitsExceedCombo:
|
|
112
|
+
"O combo tem {units} itens; deixe grátis no máximo {max} para ainda ser uma promoção.",
|
|
97
113
|
codeRequired: "Informe o código do cupom que o cliente vai digitar.",
|
|
98
114
|
categoryTargetRequired: "Selecione ao menos uma categoria para este desconto.",
|
|
99
115
|
itemTargetRequired: "Selecione ao menos um produto para este desconto.",
|
|
116
|
+
comboSlotsRequired: "Monte o combo: adicione ao menos um grupo de itens.",
|
|
117
|
+
comboSlotTargetRequired: "Escolha o que pode entrar em cada grupo do combo.",
|
|
118
|
+
invalidComboQuantity: "Informe quantas unidades cada grupo leva, a partir de 1.",
|
|
119
|
+
invalidMaxComboApplications: "Informe quantos combos cabem por pedido, a partir de 1.",
|
|
100
120
|
endsBeforeStarts: "A data de término deve ser posterior à data de início.",
|
|
101
121
|
},
|
|
122
|
+
combo: {
|
|
123
|
+
title: "Monte o combo",
|
|
124
|
+
hint: "Cada grupo é uma parte do combo: quantas unidades leva e o que pode entrar nela.",
|
|
125
|
+
addSlot: "Adicionar grupo",
|
|
126
|
+
removeSlot: "Remover o grupo {position}",
|
|
127
|
+
slot: "Grupo {position}",
|
|
128
|
+
quantity: "Quantidade",
|
|
129
|
+
pick: "{collection} deste grupo",
|
|
130
|
+
empty: "Nenhum grupo ainda. Adicione o primeiro para montar o combo.",
|
|
131
|
+
summary: "O combo leva {units} itens em {groups} grupos.",
|
|
132
|
+
},
|
|
102
133
|
targets: {
|
|
103
134
|
pick: "{collection} com desconto",
|
|
104
135
|
search: "Buscar em {collection}…",
|
|
@@ -129,4 +160,7 @@ export const PT_BR_DISCOUNTS_WEB_COPY: DiscountsWebCopy = {
|
|
|
129
160
|
perBuyerLimit: "Limite por cliente",
|
|
130
161
|
unlimited: "Sem limite",
|
|
131
162
|
},
|
|
163
|
+
confirmAction: PT_BR_CONFIRM_ACTION_COPY,
|
|
164
|
+
dataViews: PT_BR_DATA_VIEWS_COPY,
|
|
165
|
+
categorySelect: PT_BR_CATEGORY_SELECT_COPY,
|
|
132
166
|
};
|