@12-apps/discounts 1.4.0 → 1.6.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 +35 -6
- package/package.json +3 -3
- package/src/engine/combo-match.ts +21 -0
- package/src/engine/combo-offer.ts +26 -0
- package/src/index.ts +7 -0
- package/src/react/combo-slot-builder.tsx +1 -0
- package/src/react/copy.ts +49 -0
- package/src/react/discount-actions-menu.tsx +39 -15
- package/src/react/discount-form-fields.tsx +128 -61
- package/src/react/discount-form-validate.ts +172 -0
- package/src/react/discount-form-values.ts +90 -0
- package/src/react/discount-form.tsx +16 -209
- package/src/react/discount-target-picker.tsx +32 -14
- package/src/react/discounts-screen.tsx +113 -39
- package/src/react/form-kind.ts +130 -0
- package/src/react/free-units-builder.tsx +180 -0
- package/src/react/pt-BR.ts +27 -0
- package/src/react/target-picker-field.tsx +5 -0
|
@@ -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
|
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { DISCOUNT_SCOPES, type DiscountScope, type DiscountType } from "../engine/kinds";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The KIND of promotion an operator chooses, and the (type, scope) pair it
|
|
5
|
+
* stands for.
|
|
6
|
+
*
|
|
7
|
+
* The engine stores two independent columns — `type` (how the money is
|
|
8
|
+
* computed) and `scope` (what it is computed over) — and that is right for an
|
|
9
|
+
* evaluator, which has to answer both questions separately. It is wrong for the
|
|
10
|
+
* person creating the promotion: most of the sixteen combinations are not
|
|
11
|
+
* offers anybody sells, and the form used to present all of them. An operator
|
|
12
|
+
* could pick "Preço de combo" with scope `ORDER`, which the write path then
|
|
13
|
+
* refused, or pick scope `COMBO` and hunt for the reward input three screens
|
|
14
|
+
* up.
|
|
15
|
+
*
|
|
16
|
+
* So the form asks ONE question — what kind of promotion is this? — and derives
|
|
17
|
+
* the pair:
|
|
18
|
+
*
|
|
19
|
+
* | kind | type | scope | the operator still chooses |
|
|
20
|
+
* |---|---|---|---|
|
|
21
|
+
* | `PERCENTAGE` | `PERCENTAGE` | ORDER / CATEGORY / ITEM | the rate, and what it covers |
|
|
22
|
+
* | `FIXED_AMOUNT` | `FIXED_AMOUNT` | ORDER / CATEGORY / ITEM | the amount, and what it covers |
|
|
23
|
+
* | `COMBO` | `PERCENTAGE` or `FIXED_AMOUNT` | `COMBO` | the groups, and which of the two rewards |
|
|
24
|
+
* | `FREE_UNITS` | `FREE_UNITS` | `COMBO` | the products, how many, how many free |
|
|
25
|
+
*
|
|
26
|
+
* Two consequences worth stating, because both are deliberate:
|
|
27
|
+
*
|
|
28
|
+
* **A combo is a DISCOUNT off its items, never a price.** "2 refrigerantes, 2
|
|
29
|
+
* hambúrgueres e 2 batatas com 15% de desconto" and "…menos R$ 5,00" are the
|
|
30
|
+
* two offers a merchant describes; a flat "por R$ 49,90" reprices the group and
|
|
31
|
+
* silently goes wrong the moment one of its items changes price. `BUNDLE_PRICE`
|
|
32
|
+
* is still a legal type in the engine and in the database — an older rule that
|
|
33
|
+
* uses it keeps working and keeps being editable, see {@link kindOptions} — it
|
|
34
|
+
* is simply not offered for a new promotion.
|
|
35
|
+
*
|
|
36
|
+
* **`COMBO` scope is not a choice any more.** It was never one: a combo is
|
|
37
|
+
* defined by its groups, so "combo scope, but with an order-wide discount" is
|
|
38
|
+
* not an offer. The scope toggle therefore lists the three scopes that are
|
|
39
|
+
* genuinely a choice, and the two combo kinds carry `COMBO` implicitly.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/** The kinds offered for a NEW promotion, in the order the toggle shows them. */
|
|
43
|
+
export const DISCOUNT_KINDS = ["PERCENTAGE", "FIXED_AMOUNT", "COMBO", "FREE_UNITS"] as const;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Every kind the form can HOLD, which is one more than it offers: a rule saved
|
|
47
|
+
* as `BUNDLE_PRICE` before combos became a discount still has to open, edit and
|
|
48
|
+
* save without losing what it is.
|
|
49
|
+
*/
|
|
50
|
+
export type DiscountKind = (typeof DISCOUNT_KINDS)[number] | "BUNDLE_PRICE";
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* True when this kind's targets are groups — a builder, not a flat picker, and
|
|
54
|
+
* `COMBO` scope rather than a scope the operator chose.
|
|
55
|
+
*
|
|
56
|
+
* The list is inline rather than an exported constant: nothing outside this
|
|
57
|
+
* predicate should be branching on the set, and an exported one invites a
|
|
58
|
+
* second `includes` that drifts from this one.
|
|
59
|
+
*/
|
|
60
|
+
export function isComboKind(kind: string): boolean {
|
|
61
|
+
return kind === "COMBO" || kind === "FREE_UNITS" || kind === "BUNDLE_PRICE";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The scopes the toggle offers: every scope except the one a kind implies. */
|
|
65
|
+
export const SELECTABLE_DISCOUNT_SCOPES: readonly DiscountScope[] = DISCOUNT_SCOPES.filter(
|
|
66
|
+
(scope) => scope !== "COMBO",
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Which kind an existing rule is, read back from the pair it was stored as.
|
|
71
|
+
*
|
|
72
|
+
* `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
|
+
*/
|
|
75
|
+
export function kindOf(rule: { type: string; scope: string } | null): DiscountKind {
|
|
76
|
+
if (rule === null) return "PERCENTAGE";
|
|
77
|
+
if (rule.type === "BUNDLE_PRICE") return "BUNDLE_PRICE";
|
|
78
|
+
if (rule.type === "FREE_UNITS") return "FREE_UNITS";
|
|
79
|
+
if (rule.scope === "COMBO") return "COMBO";
|
|
80
|
+
return rule.type === "FIXED_AMOUNT" ? "FIXED_AMOUNT" : "PERCENTAGE";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The kinds this particular form may show — the four, plus `BUNDLE_PRICE` when
|
|
85
|
+
* that is what is being edited.
|
|
86
|
+
*
|
|
87
|
+
* Appended rather than inserted so the four keep the positions an operator has
|
|
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.
|
|
100
|
+
*/
|
|
101
|
+
export const COMBO_REWARDS = ["PERCENTAGE", "FIXED_AMOUNT"] as const;
|
|
102
|
+
|
|
103
|
+
/** Which reward an existing combo was saved with. */
|
|
104
|
+
export function comboRewardOf(rule: { type: string } | null): string {
|
|
105
|
+
return rule?.type === "FIXED_AMOUNT" ? "FIXED_AMOUNT" : "PERCENTAGE";
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The engine pair this form currently describes.
|
|
110
|
+
*
|
|
111
|
+
* The one place the four questions on screen become the two columns the API
|
|
112
|
+
* takes, so nothing downstream has to know that `COMBO` is a kind rather than a
|
|
113
|
+
* type. A stale `scope` left over from a kind switch is clamped to `ORDER`
|
|
114
|
+
* rather than trusted: the toggle is unmounted at combo kinds, so its value is
|
|
115
|
+
* whatever it was before the operator changed their mind.
|
|
116
|
+
*/
|
|
117
|
+
export function typeAndScopeFor(values: {
|
|
118
|
+
kind: string;
|
|
119
|
+
comboReward: string;
|
|
120
|
+
scope: string;
|
|
121
|
+
}): { type: DiscountType; scope: DiscountScope } {
|
|
122
|
+
if (values.kind === "COMBO") {
|
|
123
|
+
const type = values.comboReward === "FIXED_AMOUNT" ? "FIXED_AMOUNT" : "PERCENTAGE";
|
|
124
|
+
return { type, scope: "COMBO" };
|
|
125
|
+
}
|
|
126
|
+
if (values.kind === "FREE_UNITS") return { type: "FREE_UNITS", scope: "COMBO" };
|
|
127
|
+
if (values.kind === "BUNDLE_PRICE") return { type: "BUNDLE_PRICE", scope: "COMBO" };
|
|
128
|
+
const type = values.kind === "FIXED_AMOUNT" ? "FIXED_AMOUNT" : "PERCENTAGE";
|
|
129
|
+
return { type, scope: values.scope === "COMBO" ? "ORDER" : (values.scope as DiscountScope) };
|
|
130
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import 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 { Box } from "@12-apps/ui/mui/Box";
|
|
8
|
+
import { Stack } from "@12-apps/ui/mui/Stack";
|
|
9
|
+
import { Text } from "@12-apps/ui/typography/Text";
|
|
10
|
+
|
|
11
|
+
import type { ComboRequirement } from "../engine/types";
|
|
12
|
+
|
|
13
|
+
import type { WireTargetGroup } from "./api";
|
|
14
|
+
import { blankSlot } from "./combo-slot-builder";
|
|
15
|
+
import { fill, type DiscountsWebCopy } from "./copy";
|
|
16
|
+
import { TargetPickerField } from "./target-picker-field";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* "Leve 3, pague 2" — the free-units builder (FUT-268).
|
|
20
|
+
*
|
|
21
|
+
* Underneath it is a combo like any other: ONE group of three units, one of
|
|
22
|
+
* which is free. But an operator describing "leve 3, pague 2" is not building a
|
|
23
|
+
* combo, and making them add a group, name it, and then find `freeUnits`
|
|
24
|
+
* somewhere else turns a one-sentence offer into the hardest form on the
|
|
25
|
+
* screen. So this kind gets a shape of its own: how many the customer takes,
|
|
26
|
+
* which products count, how many are free.
|
|
27
|
+
*
|
|
28
|
+
* **Products only, never categories.** A single group is the whole promotion
|
|
29
|
+
* here, so "the customer takes 3" has to name things the customer recognises.
|
|
30
|
+
* The multi-group builder next door still offers both collections — there a
|
|
31
|
+
* group naming a category is exactly right ("2 refrigerantes").
|
|
32
|
+
*
|
|
33
|
+
* The state it edits is the same `ComboRequirement[]` the combo builder edits,
|
|
34
|
+
* with the list capped at one. That is what lets the two kinds share a
|
|
35
|
+
* validator, a write path and a stored shape: switching a rule between them
|
|
36
|
+
* changes which control is on screen, not what is saved.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/** The one group this kind edits, or a blank one when nothing is chosen yet. */
|
|
40
|
+
function onlySlot(slots: readonly ComboRequirement[]): ComboRequirement {
|
|
41
|
+
return slots[0] ?? blankSlot();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** How many the customer pays for: what they take, minus what is free. */
|
|
45
|
+
function paidUnits(taken: number, free: number): number {
|
|
46
|
+
return Math.max(taken - free, 0);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The read-back sentence, in the operator's own numbers.
|
|
51
|
+
*
|
|
52
|
+
* Only once BOTH numbers make one — "leve 3, pague 0" is a giveaway and "leve
|
|
53
|
+
* 1, pague 1" is not a promotion, and the validator refuses both. Showing the
|
|
54
|
+
* sentence anyway would read as confirmation of an offer that cannot be saved.
|
|
55
|
+
*/
|
|
56
|
+
function summaryOf(taken: number, free: number, copy: DiscountsWebCopy): string | null {
|
|
57
|
+
if (!Number.isInteger(taken) || taken < 2 || !Number.isInteger(free) || free < 1) return null;
|
|
58
|
+
if (free >= taken) return null;
|
|
59
|
+
return fill(copy.combo.freeUnitsSummary, { units: taken, paid: paidUnits(taken, free) });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The two numbers that ARE the offer: how many the customer takes, and how many
|
|
64
|
+
* of those are free.
|
|
65
|
+
*
|
|
66
|
+
* Split from the builder purely for the function-size gate, but the seam is the
|
|
67
|
+
* right one anyway — this pair is the sentence, and the picker below it is the
|
|
68
|
+
* list of things the sentence is about.
|
|
69
|
+
*/
|
|
70
|
+
function FreeUnitsCounts({
|
|
71
|
+
quantity,
|
|
72
|
+
freeUnits,
|
|
73
|
+
copy,
|
|
74
|
+
error,
|
|
75
|
+
onQuantity,
|
|
76
|
+
onFreeUnits,
|
|
77
|
+
}: {
|
|
78
|
+
quantity: number;
|
|
79
|
+
freeUnits: string;
|
|
80
|
+
copy: DiscountsWebCopy;
|
|
81
|
+
error: string | undefined;
|
|
82
|
+
onQuantity: (next: number) => void;
|
|
83
|
+
onFreeUnits: (next: string) => void;
|
|
84
|
+
}): JSX.Element {
|
|
85
|
+
return (
|
|
86
|
+
<Box sx={{ display: "grid", gap: 2, gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" } }}>
|
|
87
|
+
<Input
|
|
88
|
+
type="number"
|
|
89
|
+
label={copy.combo.buyQuantity}
|
|
90
|
+
value={String(quantity)}
|
|
91
|
+
onChange={(event) => onQuantity(Number(event.target.value))}
|
|
92
|
+
data-testid="free-units-quantity"
|
|
93
|
+
/>
|
|
94
|
+
<Input
|
|
95
|
+
type="number"
|
|
96
|
+
label={copy.form.freeUnits}
|
|
97
|
+
value={freeUnits}
|
|
98
|
+
onChange={(event) => onFreeUnits(event.target.value)}
|
|
99
|
+
error={error !== undefined}
|
|
100
|
+
data-testid="free-units-free"
|
|
101
|
+
/>
|
|
102
|
+
</Box>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function FreeUnitsBuilder({
|
|
107
|
+
slots,
|
|
108
|
+
groups,
|
|
109
|
+
copy,
|
|
110
|
+
freeUnits,
|
|
111
|
+
error,
|
|
112
|
+
onChange,
|
|
113
|
+
onFreeUnitsChange,
|
|
114
|
+
}: {
|
|
115
|
+
slots: readonly ComboRequirement[];
|
|
116
|
+
groups: readonly WireTargetGroup[];
|
|
117
|
+
copy: DiscountsWebCopy;
|
|
118
|
+
/** The reward, as typed. Held by the form so the validator can read it. */
|
|
119
|
+
freeUnits: string;
|
|
120
|
+
/**
|
|
121
|
+
* The validator's refusal for that reward, since the input above is a raw
|
|
122
|
+
* `Input` rather than a bound `Fields.TextField` — a bound field would paint
|
|
123
|
+
* this itself, but it would also sit outside the sentence it belongs to.
|
|
124
|
+
*/
|
|
125
|
+
error: string | undefined;
|
|
126
|
+
onChange: (next: ComboRequirement[]) => void;
|
|
127
|
+
onFreeUnitsChange: (next: string) => void;
|
|
128
|
+
}): JSX.Element | null {
|
|
129
|
+
const items = groups.find((group) => group.targetType === "ITEM");
|
|
130
|
+
if (!items) return null;
|
|
131
|
+
|
|
132
|
+
const slot = onlySlot(slots);
|
|
133
|
+
const summary = summaryOf(slot.quantity, Number(freeUnits), copy);
|
|
134
|
+
const empty = slot.menuItemIds.length === 0;
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<FormControl fullWidth>
|
|
138
|
+
<FormLabel>{copy.combo.freeUnitsTitle}</FormLabel>
|
|
139
|
+
<Text variant="caption">{copy.combo.freeUnitsHint}</Text>
|
|
140
|
+
<Stack spacing={2} sx={{ mt: 1 }} data-testid="free-units-builder">
|
|
141
|
+
<FreeUnitsCounts
|
|
142
|
+
quantity={slot.quantity}
|
|
143
|
+
freeUnits={freeUnits}
|
|
144
|
+
copy={copy}
|
|
145
|
+
error={error}
|
|
146
|
+
onQuantity={(quantity) => onChange([{ ...slot, quantity }])}
|
|
147
|
+
onFreeUnits={onFreeUnitsChange}
|
|
148
|
+
/>
|
|
149
|
+
<TargetPickerField
|
|
150
|
+
group={items}
|
|
151
|
+
label={fill(copy.combo.freeUnitsPick, { collection: items.label })}
|
|
152
|
+
placeholder={fill(copy.targets.search, { collection: items.label })}
|
|
153
|
+
// The refusal is the BUILDER's, not the picker's — the same call the
|
|
154
|
+
// multi-group builder makes, so the two read alike when a rule is
|
|
155
|
+
// switched between them.
|
|
156
|
+
requiredMessage={undefined}
|
|
157
|
+
ids={slot.menuItemIds}
|
|
158
|
+
onChange={(ids) => onChange([{ ...slot, menuItemIds: ids }])}
|
|
159
|
+
copy={copy.categorySelect}
|
|
160
|
+
dataTestId="free-units-items"
|
|
161
|
+
/>
|
|
162
|
+
{error !== undefined && (
|
|
163
|
+
<FormMessage error dataTestId="free-units-free-message">
|
|
164
|
+
{error}
|
|
165
|
+
</FormMessage>
|
|
166
|
+
)}
|
|
167
|
+
{empty ? (
|
|
168
|
+
<FormMessage error dataTestId="free-units-message">
|
|
169
|
+
{copy.form.freeUnitsTargetRequired}
|
|
170
|
+
</FormMessage>
|
|
171
|
+
) : null}
|
|
172
|
+
{summary !== null && (
|
|
173
|
+
<Text variant="caption" data-testid="free-units-summary">
|
|
174
|
+
{summary}
|
|
175
|
+
</Text>
|
|
176
|
+
)}
|
|
177
|
+
</Stack>
|
|
178
|
+
</FormControl>
|
|
179
|
+
);
|
|
180
|
+
}
|
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
|
/**
|
|
@@ -37,6 +43,13 @@ export const PT_BR_DISCOUNTS_WEB_COPY: DiscountsWebCopy = {
|
|
|
37
43
|
no: "Não",
|
|
38
44
|
},
|
|
39
45
|
labels: {
|
|
46
|
+
kind: {
|
|
47
|
+
PERCENTAGE: "Porcentagem",
|
|
48
|
+
FIXED_AMOUNT: "Valor fixo",
|
|
49
|
+
COMBO: "Combo",
|
|
50
|
+
FREE_UNITS: "Itens grátis",
|
|
51
|
+
BUNDLE_PRICE: "Preço de combo",
|
|
52
|
+
},
|
|
40
53
|
type: {
|
|
41
54
|
PERCENTAGE: "Porcentagem",
|
|
42
55
|
FIXED_AMOUNT: "Valor fixo",
|
|
@@ -72,6 +85,9 @@ export const PT_BR_DISCOUNTS_WEB_COPY: DiscountsWebCopy = {
|
|
|
72
85
|
submitEdit: "Salvar alterações",
|
|
73
86
|
name: "Nome da promoção",
|
|
74
87
|
type: "Tipo",
|
|
88
|
+
kind: "Tipo de promoção",
|
|
89
|
+
comboReward: "Desconto do combo",
|
|
90
|
+
comboRewardHint: "O combo sai pelo preço dos itens menos este desconto.",
|
|
75
91
|
percentOff: "Desconto (%)",
|
|
76
92
|
percentPlaceholder: "10",
|
|
77
93
|
amountOff: "Valor do desconto",
|
|
@@ -90,6 +106,7 @@ export const PT_BR_DISCOUNTS_WEB_COPY: DiscountsWebCopy = {
|
|
|
90
106
|
perBuyerLimit: "Limite por cliente",
|
|
91
107
|
maxComboApplications: "Combos por pedido",
|
|
92
108
|
maxComboApplicationsHint: "Deixe em branco para valer quantas vezes couber no carrinho.",
|
|
109
|
+
maxFreeUnitsApplications: "Vezes por pedido",
|
|
93
110
|
active: "Ativo",
|
|
94
111
|
activeHint: "Desligue para pausar a promoção sem excluí-la.",
|
|
95
112
|
stackable: "Acumulável",
|
|
@@ -104,6 +121,7 @@ export const PT_BR_DISCOUNTS_WEB_COPY: DiscountsWebCopy = {
|
|
|
104
121
|
invalidFreeUnits: "Informe quantos itens saem de graça, a partir de 1.",
|
|
105
122
|
freeUnitsExceedCombo:
|
|
106
123
|
"O combo tem {units} itens; deixe grátis no máximo {max} para ainda ser uma promoção.",
|
|
124
|
+
freeUnitsTargetRequired: "Escolha ao menos um produto para a promoção.",
|
|
107
125
|
codeRequired: "Informe o código do cupom que o cliente vai digitar.",
|
|
108
126
|
categoryTargetRequired: "Selecione ao menos uma categoria para este desconto.",
|
|
109
127
|
itemTargetRequired: "Selecione ao menos um produto para este desconto.",
|
|
@@ -123,6 +141,12 @@ export const PT_BR_DISCOUNTS_WEB_COPY: DiscountsWebCopy = {
|
|
|
123
141
|
pick: "{collection} deste grupo",
|
|
124
142
|
empty: "Nenhum grupo ainda. Adicione o primeiro para montar o combo.",
|
|
125
143
|
summary: "O combo leva {units} itens em {groups} grupos.",
|
|
144
|
+
freeUnitsTitle: "Leve mais, pague menos",
|
|
145
|
+
freeUnitsHint:
|
|
146
|
+
"Escolha os produtos, quantos o cliente leva e quantos saem de graça — é o clássico \"leve 3, pague 2\".",
|
|
147
|
+
buyQuantity: "O cliente leva",
|
|
148
|
+
freeUnitsPick: "{collection} da promoção",
|
|
149
|
+
freeUnitsSummary: "Leve {units}, pague {paid}.",
|
|
126
150
|
},
|
|
127
151
|
targets: {
|
|
128
152
|
pick: "{collection} com desconto",
|
|
@@ -154,4 +178,7 @@ export const PT_BR_DISCOUNTS_WEB_COPY: DiscountsWebCopy = {
|
|
|
154
178
|
perBuyerLimit: "Limite por cliente",
|
|
155
179
|
unlimited: "Sem limite",
|
|
156
180
|
},
|
|
181
|
+
confirmAction: PT_BR_CONFIRM_ACTION_COPY,
|
|
182
|
+
dataViews: PT_BR_DATA_VIEWS_COPY,
|
|
183
|
+
categorySelect: PT_BR_CATEGORY_SELECT_COPY,
|
|
157
184
|
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import type { CategorySelectCopy } from "@12-apps/ui/copy";
|
|
3
4
|
import { useState, type JSX } from "react";
|
|
4
5
|
|
|
5
6
|
import { Autocomplete } from "@12-apps/ui/form/Autocomplete";
|
|
@@ -100,6 +101,7 @@ export function TargetPickerField({
|
|
|
100
101
|
ids,
|
|
101
102
|
onChange,
|
|
102
103
|
dataTestId,
|
|
104
|
+
copy,
|
|
103
105
|
}: {
|
|
104
106
|
group: WireTargetGroup;
|
|
105
107
|
label: string;
|
|
@@ -108,6 +110,8 @@ export function TargetPickerField({
|
|
|
108
110
|
ids: readonly string[];
|
|
109
111
|
onChange: (next: string[]) => void;
|
|
110
112
|
dataTestId: string;
|
|
113
|
+
/** `CategorySelect`'s own words — required since FUT-760, never defaulted. */
|
|
114
|
+
copy: CategorySelectCopy;
|
|
111
115
|
}): JSX.Element {
|
|
112
116
|
if (group.nests) {
|
|
113
117
|
return (
|
|
@@ -119,6 +123,7 @@ export function TargetPickerField({
|
|
|
119
123
|
value={[...ids]}
|
|
120
124
|
onChange={onChange}
|
|
121
125
|
error={requiredMessage !== undefined && ids.length === 0 ? requiredMessage : undefined}
|
|
126
|
+
copy={copy}
|
|
122
127
|
dataTestId={dataTestId}
|
|
123
128
|
/>
|
|
124
129
|
);
|