@12-apps/discounts 1.5.0 → 1.7.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.
@@ -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
+ }
@@ -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",
@@ -145,6 +163,7 @@ export const PT_BR_DISCOUNTS_WEB_COPY: DiscountsWebCopy = {
145
163
  deleteManyDescription: "Deixam de valer na vitrine e não podem ser restaurados.",
146
164
  deleteFailed: "Não foi possível excluir o desconto.",
147
165
  actionFailed: "Não foi possível concluir a ação",
166
+ actionFailedDismiss: "Fechar o aviso",
148
167
  },
149
168
  card: {
150
169
  paused: "Pausado",