@12-apps/discounts 1.5.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/copy.ts +30 -0
- 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 +31 -14
- package/src/react/form-kind.ts +130 -0
- package/src/react/free-units-builder.tsx +180 -0
- package/src/react/pt-BR.ts +18 -0
|
@@ -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
|
@@ -43,6 +43,13 @@ export const PT_BR_DISCOUNTS_WEB_COPY: DiscountsWebCopy = {
|
|
|
43
43
|
no: "Não",
|
|
44
44
|
},
|
|
45
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
|
+
},
|
|
46
53
|
type: {
|
|
47
54
|
PERCENTAGE: "Porcentagem",
|
|
48
55
|
FIXED_AMOUNT: "Valor fixo",
|
|
@@ -78,6 +85,9 @@ export const PT_BR_DISCOUNTS_WEB_COPY: DiscountsWebCopy = {
|
|
|
78
85
|
submitEdit: "Salvar alterações",
|
|
79
86
|
name: "Nome da promoção",
|
|
80
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.",
|
|
81
91
|
percentOff: "Desconto (%)",
|
|
82
92
|
percentPlaceholder: "10",
|
|
83
93
|
amountOff: "Valor do desconto",
|
|
@@ -96,6 +106,7 @@ export const PT_BR_DISCOUNTS_WEB_COPY: DiscountsWebCopy = {
|
|
|
96
106
|
perBuyerLimit: "Limite por cliente",
|
|
97
107
|
maxComboApplications: "Combos por pedido",
|
|
98
108
|
maxComboApplicationsHint: "Deixe em branco para valer quantas vezes couber no carrinho.",
|
|
109
|
+
maxFreeUnitsApplications: "Vezes por pedido",
|
|
99
110
|
active: "Ativo",
|
|
100
111
|
activeHint: "Desligue para pausar a promoção sem excluí-la.",
|
|
101
112
|
stackable: "Acumulável",
|
|
@@ -110,6 +121,7 @@ export const PT_BR_DISCOUNTS_WEB_COPY: DiscountsWebCopy = {
|
|
|
110
121
|
invalidFreeUnits: "Informe quantos itens saem de graça, a partir de 1.",
|
|
111
122
|
freeUnitsExceedCombo:
|
|
112
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.",
|
|
113
125
|
codeRequired: "Informe o código do cupom que o cliente vai digitar.",
|
|
114
126
|
categoryTargetRequired: "Selecione ao menos uma categoria para este desconto.",
|
|
115
127
|
itemTargetRequired: "Selecione ao menos um produto para este desconto.",
|
|
@@ -129,6 +141,12 @@ export const PT_BR_DISCOUNTS_WEB_COPY: DiscountsWebCopy = {
|
|
|
129
141
|
pick: "{collection} deste grupo",
|
|
130
142
|
empty: "Nenhum grupo ainda. Adicione o primeiro para montar o combo.",
|
|
131
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}.",
|
|
132
150
|
},
|
|
133
151
|
targets: {
|
|
134
152
|
pick: "{collection} com desconto",
|