@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
package/README.md
CHANGED
|
@@ -349,21 +349,50 @@ between them.
|
|
|
349
349
|
### Authoring a combo
|
|
350
350
|
|
|
351
351
|
The four discount types and the four scopes are not independent, and the form
|
|
352
|
-
is where that stops being a table in a README
|
|
352
|
+
is where that stops being a table in a README. It asks ONE question — what KIND
|
|
353
|
+
of promotion is this — and derives the pair (`src/react/form-kind.ts`):
|
|
353
354
|
|
|
354
|
-
| the
|
|
355
|
+
| the operator picks | type | scope | and is then asked |
|
|
355
356
|
|---|---|---|---|
|
|
356
|
-
|
|
|
357
|
-
|
|
|
358
|
-
|
|
|
357
|
+
| Porcentagem | `PERCENTAGE` | ORDER / CATEGORY / ITEM | the rate, and what it covers |
|
|
358
|
+
| Valor fixo | `FIXED_AMOUNT` | ORDER / CATEGORY / ITEM | the amount, and what it covers |
|
|
359
|
+
| Combo | `PERCENTAGE` or `FIXED_AMOUNT` | `COMBO` | the groups, and which of the two rewards |
|
|
360
|
+
| Itens grátis | `FREE_UNITS` | `COMBO` | the products, how many, how many free |
|
|
359
361
|
|
|
360
|
-
|
|
362
|
+
So the offers a merchant describes map one to one onto what they fill in:
|
|
363
|
+
|
|
364
|
+
| the offer | kind | what the operator fills in |
|
|
365
|
+
|---|---|---|
|
|
366
|
+
| "leve 3, pague 2" | Itens grátis | 3 hambúrgueres, 1 free |
|
|
367
|
+
| "2 refrigerantes, 2 hambúrgueres e 2 batatas com 20% de desconto" | Combo | three groups of 2, and the rate |
|
|
368
|
+
| the same six items at R$ 5,00 off | Combo | three groups of 2, and the amount |
|
|
369
|
+
|
|
370
|
+
Two consequences of asking the kind instead of the pair:
|
|
371
|
+
|
|
372
|
+
- **`COMBO` is not a scope anybody picks.** A combo covers the groups it is
|
|
373
|
+
made of, so the scope toggle offers the three that are genuinely a choice.
|
|
374
|
+
Offering the fourth is what let an operator build "preço de combo,
|
|
375
|
+
abrangência: pedido" and have the write path refuse it.
|
|
376
|
+
- **`BUNDLE_PRICE` is no longer offered.** A flat price for the group reprices
|
|
377
|
+
it, and goes silently wrong the first time one of its items changes price —
|
|
378
|
+
so a combo gives a percentage or an amount OFF what its items add up to. The
|
|
379
|
+
type is still legal in the engine and in the database, and the kind toggle
|
|
380
|
+
grows a fifth option on the form of a rule that already carries it, so an
|
|
381
|
+
older promotion opens, edits and saves as what it is.
|
|
382
|
+
|
|
383
|
+
Picking Combo swaps the single target picker for the **group builder**: each
|
|
361
384
|
group is a quantity plus the rows that can fill it, and a group may name
|
|
362
385
|
categories AND products at once, because a real menu mixes them — "any drink,
|
|
363
386
|
or specifically the large fries" is one group. Every registered
|
|
364
387
|
`DiscountableCollection` gets a picker inside every group, so a host that
|
|
365
388
|
registers a third dimension gets it here with no change to this package.
|
|
366
389
|
|
|
390
|
+
"Itens grátis" gets a builder of its own instead (`free-units-builder.tsx`):
|
|
391
|
+
one group, PRODUCTS only, and the two numbers side by side — "o cliente leva 3,
|
|
392
|
+
1 sai de graça". It is the same stored shape as a one-group combo, which is
|
|
393
|
+
what lets the two kinds share a validator, a write path and a row; nobody
|
|
394
|
+
describing "leve 3, pague 2" is building a combo, so nobody is asked to.
|
|
395
|
+
|
|
367
396
|
Three consequences worth stating, because each is a bug this shape prevents:
|
|
368
397
|
|
|
369
398
|
- **The reward input is MOUNTED, not hidden.** The four value columns are
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12-apps/discounts",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The discount/promotion domain: a host-agnostic evaluator (percentage or fixed amount, order/category/item scope, automatic or coupon-triggered, stacking and exclusivity, integer-cent allocation) plus the admin CRUD surface as wiring-contract route descriptors over a store port. No database, no framework, no baked user copy.",
|
|
6
6
|
"exports": {
|
|
@@ -28,10 +28,10 @@
|
|
|
28
28
|
"typecheck": "tsc --noEmit"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@12-apps/app-shell": "^5.
|
|
31
|
+
"@12-apps/app-shell": "^5.2.0",
|
|
32
32
|
"@12-apps/forms-core": "^1.20.0",
|
|
33
33
|
"@12-apps/shared-helpers": "^2.0.2",
|
|
34
|
-
"@12-apps/ui": "^6.
|
|
34
|
+
"@12-apps/ui": "^6.3.1",
|
|
35
35
|
"@mui/icons-material": "^6.5.0",
|
|
36
36
|
"zod": "^4.3.5"
|
|
37
37
|
},
|
|
@@ -368,3 +368,24 @@ export function comboCoveredLineIds(
|
|
|
368
368
|
export function comboRuleAcceptsLine(rule: DiscountRule, line: DiscountCartLine): boolean {
|
|
369
369
|
return prepareSlots(rule.comboRequirements ?? []).some((slot) => slotAccepts(slot, line));
|
|
370
370
|
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Whether ONE slot accepts this line — the same predicate, asked per group.
|
|
374
|
+
*
|
|
375
|
+
* A storefront advertising a combo has to answer a question the cart never
|
|
376
|
+
* does: not "does this product take part" but "which GROUP does it fill", so
|
|
377
|
+
* that "escolha 2 refrigerantes" offers sodas and not burgers. That is
|
|
378
|
+
* `slotAccepts` with nothing added, and it is exported rather than reimplemented
|
|
379
|
+
* because a second copy is how a menu ends up offering a product the matcher
|
|
380
|
+
* will not accept — a combo the buyer can assemble on screen and never earn.
|
|
381
|
+
*
|
|
382
|
+
* It takes a `ComboRequirement` rather than a whole rule, because the caller is
|
|
383
|
+
* iterating a rule's groups and needs to know which one it is looking at.
|
|
384
|
+
*/
|
|
385
|
+
export function comboSlotAcceptsLine(
|
|
386
|
+
requirement: ComboRequirement,
|
|
387
|
+
line: DiscountCartLine,
|
|
388
|
+
): boolean {
|
|
389
|
+
const [slot] = prepareSlots([requirement]);
|
|
390
|
+
return slot !== undefined && slotAccepts(slot, line);
|
|
391
|
+
}
|
|
@@ -120,6 +120,32 @@ function toOffer(rule: DiscountRule): ComboOffer {
|
|
|
120
120
|
};
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
/**
|
|
124
|
+
* Every combo the store is currently advertising, in the evaluator's canonical
|
|
125
|
+
* rule order.
|
|
126
|
+
*
|
|
127
|
+
* The STORE-level sibling of {@link comboOffersForItem}: same predicate, no
|
|
128
|
+
* item. A storefront that shows combos as their own shelf — "here are the
|
|
129
|
+
* bundles this shop is running" rather than "this card takes part in one" —
|
|
130
|
+
* needs the list before it has a product in hand, and answering it host-side
|
|
131
|
+
* means a second copy of "live enough to advertise" that drifts from this one
|
|
132
|
+
* the first time a rule gains a way to be over.
|
|
133
|
+
*
|
|
134
|
+
* It says nothing about whether the store can still SELL them: a combo whose
|
|
135
|
+
* group has no orderable product left is a question about the host's catalog,
|
|
136
|
+
* which this package cannot see. The caller resolves the groups and drops what
|
|
137
|
+
* it cannot fill.
|
|
138
|
+
*/
|
|
139
|
+
export function advertisableCombos(
|
|
140
|
+
rules: readonly DiscountRule[],
|
|
141
|
+
now: Date,
|
|
142
|
+
): readonly ComboOffer[] {
|
|
143
|
+
return rules
|
|
144
|
+
.filter((rule) => isAdvertisable(rule, now))
|
|
145
|
+
.sort(compareRuleOrder)
|
|
146
|
+
.map(toOffer);
|
|
147
|
+
}
|
|
148
|
+
|
|
123
149
|
/**
|
|
124
150
|
* Every live combo one of whose slots this item can fill, in the evaluator's
|
|
125
151
|
* canonical rule order so a card and a cart never disagree about which combo
|
package/src/index.ts
CHANGED
|
@@ -77,6 +77,7 @@ export {
|
|
|
77
77
|
* say about one.
|
|
78
78
|
*/
|
|
79
79
|
export {
|
|
80
|
+
advertisableCombos,
|
|
80
81
|
comboOffersForItem,
|
|
81
82
|
type ComboOffer,
|
|
82
83
|
type ComboOffersInput,
|
|
@@ -87,8 +88,14 @@ export {
|
|
|
87
88
|
* "aplicado 2x", "faltam 2 refrigerantes" — needs the same match the evaluator
|
|
88
89
|
* priced, and re-deriving it host-side is how a receipt and a cart start
|
|
89
90
|
* disagreeing about what was in the bundle.
|
|
91
|
+
*
|
|
92
|
+
* `comboSlotAcceptsLine` is the same reason one level down: a storefront
|
|
93
|
+
* ADVERTISING a combo has to fill "escolha 2 refrigerantes" with sodas rather
|
|
94
|
+
* than with everything the combo names, and a host answering that itself is how
|
|
95
|
+
* a menu offers a product the matcher will not accept.
|
|
90
96
|
*/
|
|
91
97
|
export {
|
|
98
|
+
comboSlotAcceptsLine,
|
|
92
99
|
freshComboPool,
|
|
93
100
|
matchCombo,
|
|
94
101
|
type ComboMatch,
|
|
@@ -136,6 +136,7 @@ function ComboSlotRow({
|
|
|
136
136
|
requiredMessage={undefined}
|
|
137
137
|
ids={idsFor(slot, group.targetType)}
|
|
138
138
|
onChange={(ids) => onChange(withIds(slot, group.targetType, ids))}
|
|
139
|
+
copy={copy.categorySelect}
|
|
139
140
|
dataTestId={`combo-slot-${index}-${group.slug}`}
|
|
140
141
|
/>
|
|
141
142
|
))}
|
package/src/react/copy.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { CategorySelectCopy, ConfirmActionCopy } from "@12-apps/ui/copy";
|
|
2
|
+
import type { DataViewsCopy } from "@12-apps/ui/data-display/DataViews";
|
|
1
3
|
import type {
|
|
2
4
|
DiscountScope,
|
|
3
5
|
DiscountTrigger,
|
|
@@ -71,6 +73,13 @@ export interface DiscountsScreenCopy {
|
|
|
71
73
|
|
|
72
74
|
/** The four closed sets, each labelled in full. */
|
|
73
75
|
export interface DiscountsVocabularyCopy {
|
|
76
|
+
/**
|
|
77
|
+
* The KIND toggle's words — the one question the form actually asks (see
|
|
78
|
+
* `./form-kind`). It is keyed by `DiscountType` because every kind is named
|
|
79
|
+
* after the type it stands for, `COMBO` included: a combo IS a discount, and
|
|
80
|
+
* `BUNDLE_PRICE`'s label survives for the legacy rules that still carry it.
|
|
81
|
+
*/
|
|
82
|
+
readonly kind: LabelsFor<DiscountType | "COMBO">;
|
|
74
83
|
readonly type: LabelsFor<DiscountType>;
|
|
75
84
|
readonly scope: LabelsFor<DiscountScope>;
|
|
76
85
|
readonly trigger: LabelsFor<DiscountTrigger>;
|
|
@@ -97,6 +106,11 @@ export interface DiscountsFormCopy {
|
|
|
97
106
|
readonly submitEdit: string;
|
|
98
107
|
readonly name: string;
|
|
99
108
|
readonly type: string;
|
|
109
|
+
/** The kind toggle's own label — the form's first real question. */
|
|
110
|
+
readonly kind: string;
|
|
111
|
+
/** Which of the two rewards a COMBO gives, and the sentence under it. */
|
|
112
|
+
readonly comboReward: string;
|
|
113
|
+
readonly comboRewardHint: string;
|
|
100
114
|
readonly percentOff: string;
|
|
101
115
|
/** The example inside the empty percentage input. */
|
|
102
116
|
readonly percentPlaceholder: string;
|
|
@@ -120,6 +134,8 @@ export interface DiscountsFormCopy {
|
|
|
120
134
|
/** How many times ONE cart may claim the combo. Blank = as often as it fits. */
|
|
121
135
|
readonly maxComboApplications: string;
|
|
122
136
|
readonly maxComboApplicationsHint: string;
|
|
137
|
+
/** The same cap for "leve 3, pague 2", which nobody calls a combo. */
|
|
138
|
+
readonly maxFreeUnitsApplications: string;
|
|
123
139
|
readonly active: string;
|
|
124
140
|
readonly activeHint: string;
|
|
125
141
|
readonly stackable: string;
|
|
@@ -140,6 +156,8 @@ export interface DiscountsFormCopy {
|
|
|
140
156
|
* that number, so the operator is told the ceiling rather than just refused.
|
|
141
157
|
*/
|
|
142
158
|
readonly freeUnitsExceedCombo: string;
|
|
159
|
+
/** The free-units builder names PRODUCTS, so its refusal says products. */
|
|
160
|
+
readonly freeUnitsTargetRequired: string;
|
|
143
161
|
readonly codeRequired: string;
|
|
144
162
|
readonly categoryTargetRequired: string;
|
|
145
163
|
readonly itemTargetRequired: string;
|
|
@@ -183,6 +201,20 @@ export interface DiscountsComboCopy {
|
|
|
183
201
|
* checked without saving it. `{units}` and `{groups}`.
|
|
184
202
|
*/
|
|
185
203
|
readonly summary: string;
|
|
204
|
+
/**
|
|
205
|
+
* The "leve 3, pague 2" builder (`./free-units-builder`), which is the same
|
|
206
|
+
* stored shape wearing the sentence an operator would actually say. Its own
|
|
207
|
+
* keys rather than the group builder's: "Grupo 1" and "Quantidade" are right
|
|
208
|
+
* for a combo of several groups and wrong for one offer about one shelf.
|
|
209
|
+
*/
|
|
210
|
+
readonly freeUnitsTitle: string;
|
|
211
|
+
readonly freeUnitsHint: string;
|
|
212
|
+
/** How many units the customer takes to earn the free ones. */
|
|
213
|
+
readonly buyQuantity: string;
|
|
214
|
+
/** That builder's picker label, `{collection}` — one offer, not one group. */
|
|
215
|
+
readonly freeUnitsPick: string;
|
|
216
|
+
/** The read-back, once both numbers make an offer. `{units}` and `{paid}`. */
|
|
217
|
+
readonly freeUnitsSummary: string;
|
|
186
218
|
}
|
|
187
219
|
|
|
188
220
|
/** The target pickers, and what an empty selection says. */
|
|
@@ -245,6 +277,17 @@ export interface DiscountsWebCopy {
|
|
|
245
277
|
readonly targets: DiscountsTargetCopy;
|
|
246
278
|
readonly actions: DiscountsActionsCopy;
|
|
247
279
|
readonly card: DiscountsCardCopy;
|
|
280
|
+
/**
|
|
281
|
+
* The words of the `@12-apps/ui` components these screens MOUNT but do not
|
|
282
|
+
* own — the confirmation popup and the category picker.
|
|
283
|
+
*
|
|
284
|
+
* They arrive here because that package stopped shipping defaults for them
|
|
285
|
+
* (FUT-760): a design system's Portuguese reached every adopter silently,
|
|
286
|
+
* so its copy is required config now and this surface is one of its hosts.
|
|
287
|
+
*/
|
|
288
|
+
readonly confirmAction: ConfirmActionCopy;
|
|
289
|
+
readonly categorySelect: CategorySelectCopy;
|
|
290
|
+
readonly dataViews: DataViewsCopy;
|
|
248
291
|
}
|
|
249
292
|
|
|
250
293
|
/**
|
|
@@ -291,6 +334,12 @@ export function missingWebCopy(
|
|
|
291
334
|
): string[] {
|
|
292
335
|
return leafPaths(reference).filter((path) => {
|
|
293
336
|
const value = read(copy, path);
|
|
337
|
+
// A leaf is satisfied by a non-blank string OR a function. The `@12-apps/ui`
|
|
338
|
+
// packs folded in below carry entries that take the interpolated value as
|
|
339
|
+
// an argument — `noResults.title(query)`, `footer.selectedCount(count)` —
|
|
340
|
+
// because Portuguese decides word order and plural agreement per sentence.
|
|
341
|
+
// Demanding a string here would report every one of them as missing.
|
|
342
|
+
if (typeof value === "function") return false;
|
|
294
343
|
return typeof value !== "string" || value.trim() === "";
|
|
295
344
|
});
|
|
296
345
|
}
|
|
@@ -2,7 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
import { useState, type JSX } from "react";
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
CardKebab,
|
|
7
|
+
useCardActions,
|
|
8
|
+
useRemoveConfirm,
|
|
9
|
+
} from "@12-apps/ui/data-display/CardKit";
|
|
6
10
|
import { Dialog, DialogContent } from "@12-apps/ui/feedback/Dialog";
|
|
7
11
|
import type { DropdownMenuItem } from "@12-apps/ui/navigation/DropdownMenu";
|
|
8
12
|
|
|
@@ -45,6 +49,34 @@ export interface DiscountActionsMenuProps {
|
|
|
45
49
|
onError: (error: unknown, context: string) => void;
|
|
46
50
|
}
|
|
47
51
|
|
|
52
|
+
/**
|
|
53
|
+
* The row's delete, popup and all.
|
|
54
|
+
*
|
|
55
|
+
* Split out of the menu only to keep that component inside the size gate: the
|
|
56
|
+
* options object is a dozen lines of pure wording, and it reads better named.
|
|
57
|
+
*/
|
|
58
|
+
function useDeleteConfirm(
|
|
59
|
+
row: DiscountListItem,
|
|
60
|
+
api: DiscountsApiClient,
|
|
61
|
+
copy: DiscountsWebCopy,
|
|
62
|
+
onError: (error: unknown, context: string) => void,
|
|
63
|
+
): ReturnType<typeof useRemoveConfirm> {
|
|
64
|
+
return useRemoveConfirm({
|
|
65
|
+
write: async () => {
|
|
66
|
+
const result = await api.remove(row.id);
|
|
67
|
+
if (!result.ok) onError(result, "discounts.delete");
|
|
68
|
+
return result;
|
|
69
|
+
},
|
|
70
|
+
title: copy.actions.deleteTitle,
|
|
71
|
+
entityName: row.name,
|
|
72
|
+
description: copy.actions.deleteDescription,
|
|
73
|
+
confirmText: copy.actions.delete,
|
|
74
|
+
fallbackError: copy.actions.deleteFailed,
|
|
75
|
+
copy: copy.confirmAction,
|
|
76
|
+
dataTestId: "discount-delete-confirm",
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
48
80
|
export function DiscountActionsMenu({
|
|
49
81
|
row,
|
|
50
82
|
api,
|
|
@@ -58,23 +90,15 @@ export function DiscountActionsMenu({
|
|
|
58
90
|
const [editOpen, setEditOpen] = useState(false);
|
|
59
91
|
const canEdit = groups !== undefined;
|
|
60
92
|
|
|
61
|
-
const remove =
|
|
62
|
-
write: async () => {
|
|
63
|
-
const result = await api.remove(row.id);
|
|
64
|
-
if (!result.ok) onError(result, "discounts.delete");
|
|
65
|
-
return result;
|
|
66
|
-
},
|
|
67
|
-
title: copy.actions.deleteTitle,
|
|
68
|
-
entityName: row.name,
|
|
69
|
-
description: copy.actions.deleteDescription,
|
|
70
|
-
confirmText: copy.actions.delete,
|
|
71
|
-
fallbackError: copy.actions.deleteFailed,
|
|
72
|
-
dataTestId: "discount-delete-confirm",
|
|
73
|
-
});
|
|
93
|
+
const remove = useDeleteConfirm(row, api, copy, onError);
|
|
74
94
|
|
|
75
95
|
const items: DropdownMenuItem[] = [];
|
|
76
96
|
if (canEdit) {
|
|
77
|
-
items.push({
|
|
97
|
+
items.push({
|
|
98
|
+
id: "edit",
|
|
99
|
+
label: copy.actions.edit,
|
|
100
|
+
onClick: () => setEditOpen(true),
|
|
101
|
+
});
|
|
78
102
|
}
|
|
79
103
|
items.push({
|
|
80
104
|
id: "delete",
|
|
@@ -8,28 +8,32 @@ import { Box } from "@12-apps/ui/mui/Box";
|
|
|
8
8
|
import { Stack } from "@12-apps/ui/mui/Stack";
|
|
9
9
|
import { Text } from "@12-apps/ui/typography/Text";
|
|
10
10
|
|
|
11
|
-
import {
|
|
11
|
+
import { DISCOUNT_TRIGGERS } from "../engine/kinds";
|
|
12
12
|
|
|
13
13
|
import type { DiscountsWebCopy } from "./copy";
|
|
14
|
+
import { COMBO_REWARDS, isComboKind, SELECTABLE_DISCOUNT_SCOPES, type DiscountKind } from "./form-kind";
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* The form's inputs, split from the form itself for the 400-line file gate —
|
|
17
18
|
* comments count toward it, and the container plus these would not fit.
|
|
18
19
|
*
|
|
19
|
-
* Every option list is built FROM
|
|
20
|
+
* Every option list is built FROM a vocabulary array rather than spelled out
|
|
20
21
|
* again: those arrays back the database's CHECK constraints, so an option an
|
|
21
22
|
* operator can pick is by construction a value the column will store. A
|
|
22
23
|
* hand-written list is how a form offers a fifth type the database refuses.
|
|
24
|
+
*
|
|
25
|
+
* The KIND is what the form asks first, and every other input here is a
|
|
26
|
+
* consequence of it (see `./form-kind` for the mapping and why it exists). That
|
|
27
|
+
* is the difference from the version this replaces, where type and scope were
|
|
28
|
+
* two free choices and most of their sixteen combinations were not offers.
|
|
23
29
|
*/
|
|
24
30
|
|
|
25
31
|
/**
|
|
26
32
|
* A field with a sentence under it.
|
|
27
33
|
*
|
|
28
34
|
* `Fields.TextField` has no helper-text slot — deliberately, it is a thin bind
|
|
29
|
-
* over `Input` — and
|
|
30
|
-
* "
|
|
31
|
-
* operator cannot guess. So the caption is composed here rather than pushed
|
|
32
|
-
* into `@12-apps/ui`, where it would be a prop every other consumer ignores.
|
|
35
|
+
* over `Input` — and the combo cap is meaningless without one: "combos por
|
|
36
|
+
* pedido" is a number whose blank value an operator cannot guess.
|
|
33
37
|
*/
|
|
34
38
|
function HintedField({
|
|
35
39
|
name,
|
|
@@ -57,41 +61,106 @@ function optionsFor<TKey extends string>(
|
|
|
57
61
|
}
|
|
58
62
|
|
|
59
63
|
/**
|
|
60
|
-
*
|
|
64
|
+
* A money input, supplied by the host.
|
|
61
65
|
*
|
|
62
|
-
*
|
|
66
|
+
* Currency entry is a host decision — masking, the symbol's side, whether cents
|
|
67
|
+
* are typed or implied — and `@12-apps/ui` has no opinionated one. So the
|
|
68
|
+
* surface takes the component rather than shipping a guess; a host with nothing
|
|
69
|
+
* special passes a plain text field.
|
|
70
|
+
*/
|
|
71
|
+
export type CurrencyFieldComponent = (props: { name: string; label: string }) => JSX.Element;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* What kind of promotion this is — the form's first and most consequential
|
|
75
|
+
* question, and the only one that decides what the rest of it looks like.
|
|
76
|
+
*
|
|
77
|
+
* The option list comes from the caller rather than from the constant, because
|
|
78
|
+
* it is one longer when a legacy `BUNDLE_PRICE` rule is being edited.
|
|
79
|
+
*/
|
|
80
|
+
export function KindField({
|
|
81
|
+
copy,
|
|
82
|
+
kinds,
|
|
83
|
+
}: {
|
|
84
|
+
copy: DiscountsWebCopy;
|
|
85
|
+
kinds: readonly DiscountKind[];
|
|
86
|
+
}): JSX.Element {
|
|
87
|
+
return (
|
|
88
|
+
<Fields.ToggleField
|
|
89
|
+
name="kind"
|
|
90
|
+
label={copy.form.kind}
|
|
91
|
+
options={optionsFor(kinds, copy.labels.kind)}
|
|
92
|
+
/>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* A combo's reward: which of the two, and how much.
|
|
98
|
+
*
|
|
99
|
+
* Both are here rather than split between the top of the form and the bottom,
|
|
100
|
+
* because to a merchant they are one sentence — "este combo dá 15% de
|
|
101
|
+
* desconto". The two are the ONLY rewards a combo can give: a flat bundle price
|
|
102
|
+
* reprices the group and goes silently wrong the first time one of its items
|
|
103
|
+
* changes price, so it is not offered (see `./form-kind`).
|
|
104
|
+
*/
|
|
105
|
+
function ComboRewardFields({
|
|
106
|
+
copy,
|
|
107
|
+
currencyField,
|
|
108
|
+
}: {
|
|
109
|
+
copy: DiscountsWebCopy;
|
|
110
|
+
currencyField: CurrencyFieldComponent;
|
|
111
|
+
}): JSX.Element {
|
|
112
|
+
const { values } = useFormContext();
|
|
113
|
+
const Currency = currencyField;
|
|
114
|
+
return (
|
|
115
|
+
<Stack spacing={0.5}>
|
|
116
|
+
<Box sx={{ display: "grid", gap: 2, gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" } }}>
|
|
117
|
+
<Fields.ToggleField
|
|
118
|
+
name="comboReward"
|
|
119
|
+
label={copy.form.comboReward}
|
|
120
|
+
options={optionsFor(COMBO_REWARDS, copy.labels.type)}
|
|
121
|
+
/>
|
|
122
|
+
{values.comboReward === "FIXED_AMOUNT" ? (
|
|
123
|
+
<Currency name="amountOff" label={copy.form.amountOff} />
|
|
124
|
+
) : (
|
|
125
|
+
<Fields.TextField
|
|
126
|
+
name="percentOff"
|
|
127
|
+
label={copy.form.percentOff}
|
|
128
|
+
placeholder={copy.form.percentPlaceholder}
|
|
129
|
+
/>
|
|
130
|
+
)}
|
|
131
|
+
</Box>
|
|
132
|
+
<Text variant="caption">{copy.form.comboRewardHint}</Text>
|
|
133
|
+
</Stack>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The reward, in whichever unit the chosen KIND actually stores.
|
|
139
|
+
*
|
|
140
|
+
* One input per kind, and the wrong one must never be mounted: the four value
|
|
63
141
|
* columns are mutually exclusive at the database (`discounts_value_check`), so
|
|
64
142
|
* a leftover value from another branch is a 500 on a form the operator filled
|
|
65
|
-
* in correctly. Mounting rather than hiding is what guarantees it — an
|
|
66
|
-
* field cannot carry a stale number to submit.
|
|
143
|
+
* in correctly. Mounting rather than hiding is what guarantees it — an
|
|
144
|
+
* unmounted field cannot carry a stale number to submit.
|
|
67
145
|
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
* how many of the matched units are given away ("leve 3, pague 2"). Both are
|
|
72
|
-
* money-or-count fields like the two above them; what makes them combos is the
|
|
73
|
-
* scope, and the scope is what the builder below hangs off.
|
|
146
|
+
* `FREE_UNITS` is the one kind with nothing here: its reward is a count of the
|
|
147
|
+
* units it gives away, which only reads as an offer beside the units it takes,
|
|
148
|
+
* so the free-units builder carries both.
|
|
74
149
|
*/
|
|
75
|
-
function
|
|
150
|
+
export function RewardFields({
|
|
76
151
|
copy,
|
|
77
152
|
currencyField,
|
|
78
153
|
}: {
|
|
79
154
|
copy: DiscountsWebCopy;
|
|
80
155
|
currencyField: CurrencyFieldComponent;
|
|
81
|
-
}): JSX.Element {
|
|
156
|
+
}): JSX.Element | null {
|
|
82
157
|
const { values } = useFormContext();
|
|
83
158
|
const Currency = currencyField;
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
if (
|
|
88
|
-
|
|
89
|
-
}
|
|
90
|
-
if (values.type === "FREE_UNITS") {
|
|
91
|
-
return (
|
|
92
|
-
<HintedField name="freeUnits" label={copy.form.freeUnits} hint={copy.form.freeUnitsHint} />
|
|
93
|
-
);
|
|
94
|
-
}
|
|
159
|
+
const kind = values.kind ?? "PERCENTAGE";
|
|
160
|
+
if (kind === "COMBO") return <ComboRewardFields copy={copy} currencyField={currencyField} />;
|
|
161
|
+
if (kind === "FREE_UNITS") return null;
|
|
162
|
+
if (kind === "BUNDLE_PRICE") return <Currency name="bundlePrice" label={copy.form.bundlePrice} />;
|
|
163
|
+
if (kind === "FIXED_AMOUNT") return <Currency name="amountOff" label={copy.form.amountOff} />;
|
|
95
164
|
return (
|
|
96
165
|
<Fields.TextField
|
|
97
166
|
name="percentOff"
|
|
@@ -101,16 +170,6 @@ function ValueField({
|
|
|
101
170
|
);
|
|
102
171
|
}
|
|
103
172
|
|
|
104
|
-
/**
|
|
105
|
-
* A money input, supplied by the host.
|
|
106
|
-
*
|
|
107
|
-
* Currency entry is a host decision — masking, the symbol's side, whether cents
|
|
108
|
-
* are typed or implied — and `@12-apps/ui` has no opinionated one. So the
|
|
109
|
-
* surface takes the component rather than shipping a guess; a host with nothing
|
|
110
|
-
* special passes a plain text field.
|
|
111
|
-
*/
|
|
112
|
-
export type CurrencyFieldComponent = (props: { name: string; label: string }) => JSX.Element;
|
|
113
|
-
|
|
114
173
|
/** The coupon input, shown only for a CODE-triggered rule. */
|
|
115
174
|
function CodeField({ copy }: { copy: DiscountsWebCopy }): JSX.Element | null {
|
|
116
175
|
const { values } = useFormContext();
|
|
@@ -124,22 +183,10 @@ function CodeField({ copy }: { copy: DiscountsWebCopy }): JSX.Element | null {
|
|
|
124
183
|
);
|
|
125
184
|
}
|
|
126
185
|
|
|
127
|
-
/**
|
|
128
|
-
export function
|
|
129
|
-
copy,
|
|
130
|
-
currencyField,
|
|
131
|
-
}: {
|
|
132
|
-
copy: DiscountsWebCopy;
|
|
133
|
-
currencyField: CurrencyFieldComponent;
|
|
134
|
-
}): JSX.Element {
|
|
186
|
+
/** What makes the promotion fire, and the code it fires on. */
|
|
187
|
+
export function TriggerFields({ copy }: { copy: DiscountsWebCopy }): JSX.Element {
|
|
135
188
|
return (
|
|
136
189
|
<Box sx={{ display: "grid", gap: 2, gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" } }}>
|
|
137
|
-
<Fields.ToggleField
|
|
138
|
-
name="type"
|
|
139
|
-
label={copy.form.type}
|
|
140
|
-
options={optionsFor(DISCOUNT_TYPES, copy.labels.type)}
|
|
141
|
-
/>
|
|
142
|
-
<ValueField copy={copy} currencyField={currencyField} />
|
|
143
190
|
<Fields.ToggleField
|
|
144
191
|
name="trigger"
|
|
145
192
|
label={copy.form.trigger}
|
|
@@ -150,13 +197,22 @@ export function RuleFields({
|
|
|
150
197
|
);
|
|
151
198
|
}
|
|
152
199
|
|
|
153
|
-
/**
|
|
154
|
-
|
|
200
|
+
/**
|
|
201
|
+
* What the discount covers — and ONLY for the kinds where that is a question.
|
|
202
|
+
*
|
|
203
|
+
* A combo covers the groups it is made of, so `COMBO` scope is not one of four
|
|
204
|
+
* choices, it is what those two kinds mean. Offering it anyway is what let an
|
|
205
|
+
* operator build "preço de combo, abrangência: pedido" and have the write path
|
|
206
|
+
* refuse it.
|
|
207
|
+
*/
|
|
208
|
+
export function ScopeField({ copy }: { copy: DiscountsWebCopy }): JSX.Element | null {
|
|
209
|
+
const { values } = useFormContext();
|
|
210
|
+
if (isComboKind(values.kind ?? "PERCENTAGE")) return null;
|
|
155
211
|
return (
|
|
156
212
|
<Fields.ToggleField
|
|
157
213
|
name="scope"
|
|
158
214
|
label={copy.form.scope}
|
|
159
|
-
options={optionsFor(
|
|
215
|
+
options={optionsFor(SELECTABLE_DISCOUNT_SCOPES, copy.labels.scope)}
|
|
160
216
|
/>
|
|
161
217
|
);
|
|
162
218
|
}
|
|
@@ -164,18 +220,30 @@ export function ScopeField({ copy }: { copy: DiscountsWebCopy }): JSX.Element {
|
|
|
164
220
|
/**
|
|
165
221
|
* How many times one cart may claim the combo.
|
|
166
222
|
*
|
|
167
|
-
* Only
|
|
223
|
+
* Only for a combo kind, because it is the only place the question means
|
|
168
224
|
* anything: an order-wide or category discount applies once by construction. A
|
|
169
225
|
* blank field is "as often as it fits", which is the merchant's usual answer
|
|
170
226
|
* and therefore the default rather than a number they have to type.
|
|
227
|
+
*
|
|
228
|
+
* It sits with the builder rather than with the redemption limits next to it:
|
|
229
|
+
* "combos por pedido" is a fact about THIS combo, and among "limite de usos"
|
|
230
|
+
* and "limite por cliente" it read as a third redemption cap.
|
|
171
231
|
*/
|
|
172
|
-
function ComboCapField({ copy }: { copy: DiscountsWebCopy }): JSX.Element | null {
|
|
232
|
+
export function ComboCapField({ copy }: { copy: DiscountsWebCopy }): JSX.Element | null {
|
|
173
233
|
const { values } = useFormContext();
|
|
174
|
-
|
|
234
|
+
const kind = values.kind ?? "PERCENTAGE";
|
|
235
|
+
if (!isComboKind(kind)) return null;
|
|
175
236
|
return (
|
|
176
237
|
<HintedField
|
|
177
238
|
name="maxComboApplications"
|
|
178
|
-
|
|
239
|
+
// Same column, same rule, two words for it: nobody calls "leve 3, pague
|
|
240
|
+
// 2" a combo, so "Combos por pedido" reads as a control for a promotion
|
|
241
|
+
// the operator is not creating.
|
|
242
|
+
label={
|
|
243
|
+
kind === "FREE_UNITS"
|
|
244
|
+
? copy.form.maxFreeUnitsApplications
|
|
245
|
+
: copy.form.maxComboApplications
|
|
246
|
+
}
|
|
179
247
|
hint={copy.form.maxComboApplicationsHint}
|
|
180
248
|
/>
|
|
181
249
|
);
|
|
@@ -205,7 +273,6 @@ export function LimitFields({
|
|
|
205
273
|
<Currency name="minSubtotal" label={copy.form.minSubtotal} />
|
|
206
274
|
<Fields.TextField name="usageLimit" label={copy.form.usageLimit} type="number" />
|
|
207
275
|
<Fields.TextField name="perBuyerLimit" label={copy.form.perBuyerLimit} type="number" />
|
|
208
|
-
<ComboCapField copy={copy} />
|
|
209
276
|
</Box>
|
|
210
277
|
);
|
|
211
278
|
}
|