@12-apps/discounts 1.3.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -3
- package/package.json +2 -2
- package/src/react/api.ts +27 -3
- package/src/react/combo-slot-builder.tsx +214 -0
- package/src/react/copy.ts +76 -0
- package/src/react/discount-actions-menu.tsx +39 -15
- package/src/react/discount-form-fields.tsx +72 -1
- package/src/react/discount-form.tsx +134 -13
- package/src/react/discount-target-picker.tsx +37 -81
- package/src/react/discounts-screen.tsx +113 -39
- package/src/react/pt-BR.ts +34 -0
- package/src/react/target-picker-field.tsx +143 -0
package/README.md
CHANGED
|
@@ -341,9 +341,44 @@ export const discounts = createWebDiscounts({
|
|
|
341
341
|
```
|
|
342
342
|
|
|
343
343
|
What that one call replaces: a server-driven grid with eight columns, five
|
|
344
|
-
filter pills and a CSV/JSON export; a create/edit form
|
|
345
|
-
|
|
346
|
-
layouts; four confirmation popups; and every wire call
|
|
344
|
+
filter pills and a CSV/JSON export; a create/edit form whose value input and
|
|
345
|
+
target block both follow the toggles above them; the target pickers and the
|
|
346
|
+
combo builder; two card layouts; four confirmation popups; and every wire call
|
|
347
|
+
between them.
|
|
348
|
+
|
|
349
|
+
### Authoring a combo
|
|
350
|
+
|
|
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:
|
|
353
|
+
|
|
354
|
+
| the offer | scope | type | what the operator fills in |
|
|
355
|
+
|---|---|---|---|
|
|
356
|
+
| "leve 3, pague 2" | `COMBO` | `FREE_UNITS` | one group of 3, and 1 free |
|
|
357
|
+
| "2 refrigerantes, 2 hambúrgueres e 2 batatas por R$ 25" | `COMBO` | `BUNDLE_PRICE` | three groups of 2, and the price |
|
|
358
|
+
| the same six items at 20% off | `COMBO` | `PERCENTAGE` | three groups of 2, and the rate |
|
|
359
|
+
|
|
360
|
+
Picking `COMBO` swaps the single target picker for the **group builder**: each
|
|
361
|
+
group is a quantity plus the rows that can fill it, and a group may name
|
|
362
|
+
categories AND products at once, because a real menu mixes them — "any drink,
|
|
363
|
+
or specifically the large fries" is one group. Every registered
|
|
364
|
+
`DiscountableCollection` gets a picker inside every group, so a host that
|
|
365
|
+
registers a third dimension gets it here with no change to this package.
|
|
366
|
+
|
|
367
|
+
Three consequences worth stating, because each is a bug this shape prevents:
|
|
368
|
+
|
|
369
|
+
- **The reward input is MOUNTED, not hidden.** The four value columns are
|
|
370
|
+
mutually exclusive at the database, so a leftover number from a branch the
|
|
371
|
+
operator flipped away from is a 500 on a form they filled in correctly.
|
|
372
|
+
- **`freeUnits` is bounded by the groups.** "Take 3, three free" is a giveaway,
|
|
373
|
+
so the form compares it against what one application takes out of the cart
|
|
374
|
+
and names the ceiling when it refuses.
|
|
375
|
+
- **Position is the array index.** The list an operator builds is the list a
|
|
376
|
+
card reads back; two groups of the same size are otherwise indistinguishable
|
|
377
|
+
once stored, which is why `toTargetRows` stamps the index as `position`.
|
|
378
|
+
|
|
379
|
+
`maxComboApplications` appears at `COMBO` scope and nowhere else — it is the
|
|
380
|
+
only scope where "how many times may one cart claim this" means anything — and
|
|
381
|
+
blank means as often as it fits.
|
|
347
382
|
|
|
348
383
|
**Build it once, at module scope.** The members are component TYPES, so
|
|
349
384
|
rebuilding per render gives React new types every time and remounts the whole
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12-apps/discounts",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.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": {
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"@12-apps/app-shell": "^5.1.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.0",
|
|
35
35
|
"@mui/icons-material": "^6.5.0",
|
|
36
36
|
"zod": "^4.3.5"
|
|
37
37
|
},
|
package/src/react/api.ts
CHANGED
|
@@ -80,6 +80,12 @@ export interface DiscountFormPayload {
|
|
|
80
80
|
percentOff: string;
|
|
81
81
|
/** A money amount, as the currency field stores it. */
|
|
82
82
|
amountOff: string;
|
|
83
|
+
/** What the matched combo group costs, as the currency field stores it. */
|
|
84
|
+
bundlePrice: string;
|
|
85
|
+
/** How many of the matched units are free, as typed. */
|
|
86
|
+
freeUnits: string;
|
|
87
|
+
/** How many combos one cart may claim. Blank = as often as it fits. */
|
|
88
|
+
maxComboApplications: string;
|
|
83
89
|
scope: string;
|
|
84
90
|
trigger: string;
|
|
85
91
|
code: string;
|
|
@@ -92,6 +98,8 @@ export interface DiscountFormPayload {
|
|
|
92
98
|
active: boolean;
|
|
93
99
|
categoryIds: string[];
|
|
94
100
|
menuItemIds: string[];
|
|
101
|
+
/** The combo's groups, in the operator's own order. Empty at every other scope. */
|
|
102
|
+
comboRequirements: readonly ComboRequirement[];
|
|
95
103
|
}
|
|
96
104
|
|
|
97
105
|
/** A money amount as integer cents, or null when blank. */
|
|
@@ -130,12 +138,28 @@ export function toWriteBody(
|
|
|
130
138
|
input: DiscountFormPayload,
|
|
131
139
|
formatters: DiscountsFormatters,
|
|
132
140
|
): Record<string, unknown> {
|
|
133
|
-
const
|
|
141
|
+
const isCombo = input.scope === "COMBO";
|
|
134
142
|
return {
|
|
135
143
|
name: input.name.trim(),
|
|
136
144
|
type: input.type,
|
|
137
|
-
|
|
138
|
-
|
|
145
|
+
// Exactly one value column, chosen by the TYPE, with the other three
|
|
146
|
+
// forced to null. The four are mutually exclusive at the database
|
|
147
|
+
// (`discounts_value_check`), so carrying a leftover from a branch the
|
|
148
|
+
// operator flipped away from is a 500 on a form they filled in correctly —
|
|
149
|
+
// the same reason the server's own folder nulls them rather than passing
|
|
150
|
+
// them through.
|
|
151
|
+
percentOffBp:
|
|
152
|
+
input.type === "PERCENTAGE" ? toBasisPoints(input.percentOff, formatters) : null,
|
|
153
|
+
amountOffCents: input.type === "FIXED_AMOUNT" ? toCents(input.amountOff, formatters) : null,
|
|
154
|
+
bundlePriceCents:
|
|
155
|
+
input.type === "BUNDLE_PRICE" ? toCents(input.bundlePrice, formatters) : null,
|
|
156
|
+
freeUnits: input.type === "FREE_UNITS" ? toCount(input.freeUnits, formatters) : null,
|
|
157
|
+
// The combo SPEC, narrowed to the scope for the reason the id lists below
|
|
158
|
+
// are: a rule whose scope moved off COMBO keeps neither its groups nor its
|
|
159
|
+
// cap, or it would go on being matched as a combo by a screen that no
|
|
160
|
+
// longer shows one.
|
|
161
|
+
comboRequirements: isCombo ? [...input.comboRequirements] : [],
|
|
162
|
+
maxComboApplications: isCombo ? toCount(input.maxComboApplications, formatters) : null,
|
|
139
163
|
scope: input.scope,
|
|
140
164
|
trigger: input.trigger,
|
|
141
165
|
code: input.trigger === "CODE" ? toNullable(input.code) : null,
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { JSX } from "react";
|
|
4
|
+
|
|
5
|
+
import { Button } from "@12-apps/ui/form/Button";
|
|
6
|
+
import { FormControl, FormLabel, FormMessage } from "@12-apps/ui/form/Form";
|
|
7
|
+
import { Input } from "@12-apps/ui/form/Input";
|
|
8
|
+
import { Box } from "@12-apps/ui/mui/Box";
|
|
9
|
+
import { Stack } from "@12-apps/ui/mui/Stack";
|
|
10
|
+
import { Text } from "@12-apps/ui/typography/Text";
|
|
11
|
+
|
|
12
|
+
import type { ComboRequirement } from "../engine/types";
|
|
13
|
+
|
|
14
|
+
import type { WireTargetGroup } from "./api";
|
|
15
|
+
import { fill, type DiscountsWebCopy } from "./copy";
|
|
16
|
+
import { TargetPickerField } from "./target-picker-field";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The combo builder — how an operator says "2 refrigerantes, 2 hambúrgueres e
|
|
20
|
+
* 2 batatas" (FUT-268).
|
|
21
|
+
*
|
|
22
|
+
* A combo is a list of GROUPS. Each group is a quantity and the rows that can
|
|
23
|
+
* fill it, and a cart matches the combo when every group is satisfiable at
|
|
24
|
+
* once. That shape is already the engine's (`ComboRequirement`), the
|
|
25
|
+
* validator's and the schema's — `discount_combo_slots` with its own targets —
|
|
26
|
+
* so this file adds no concept. It adds the only thing that was missing: a way
|
|
27
|
+
* to author one.
|
|
28
|
+
*
|
|
29
|
+
* Two decisions worth stating, because both are visible in the markup:
|
|
30
|
+
*
|
|
31
|
+
* **A group can name categories AND items at once.** "2 refrigerantes" is a
|
|
32
|
+
* category; "2 batatas médias" is an item; and a real menu mixes them inside
|
|
33
|
+
* one group ("any drink, or specifically the large fries"). So each group gets
|
|
34
|
+
* every registered collection's picker rather than a scope toggle — the union
|
|
35
|
+
* is what `ComboRequirement` already stores, and forcing a choice would make
|
|
36
|
+
* the common offer unexpressible.
|
|
37
|
+
*
|
|
38
|
+
* **Position is the array index, and it is meaningful.** The list an operator
|
|
39
|
+
* builds is the list a card reads back; two groups of the same size are
|
|
40
|
+
* otherwise indistinguishable once stored. Removing a group therefore
|
|
41
|
+
* renumbers the ones after it, which is what an operator expects from a list
|
|
42
|
+
* they are editing — and `toTargetRows` stamps the index as `position` on the
|
|
43
|
+
* way to the database.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/** How many units one application of the combo takes out of the cart. */
|
|
47
|
+
export function comboUnits(slots: readonly ComboRequirement[]): number {
|
|
48
|
+
return slots.reduce((total, slot) => total + (Number.isFinite(slot.quantity) ? slot.quantity : 0), 0);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** An empty group: one unit, nothing chosen yet. */
|
|
52
|
+
export function blankSlot(): ComboRequirement {
|
|
53
|
+
return { quantity: 1, categoryIds: [], menuItemIds: [] };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Replace one group, leaving the rest (and their order) alone. */
|
|
57
|
+
function replaceAt(
|
|
58
|
+
slots: readonly ComboRequirement[],
|
|
59
|
+
index: number,
|
|
60
|
+
next: ComboRequirement,
|
|
61
|
+
): ComboRequirement[] {
|
|
62
|
+
return slots.map((slot, position) => (position === index ? next : slot));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Which id list of a group a collection reads and writes. */
|
|
66
|
+
function idsFor(slot: ComboRequirement, targetType: string): readonly string[] {
|
|
67
|
+
return targetType === "CATEGORY" ? slot.categoryIds : slot.menuItemIds;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function withIds(slot: ComboRequirement, targetType: string, ids: string[]): ComboRequirement {
|
|
71
|
+
return targetType === "CATEGORY" ? { ...slot, categoryIds: ids } : { ...slot, menuItemIds: ids };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* One group: its quantity, one picker per registered collection, and the
|
|
76
|
+
* control that removes it.
|
|
77
|
+
*
|
|
78
|
+
* The quantity is a `number` input rather than a masked one — it counts units,
|
|
79
|
+
* not money, so there is no locale in it. It is held as a NUMBER in state
|
|
80
|
+
* rather than as the form's string, because the summary line above adds them up
|
|
81
|
+
* and the validator compares them against `freeUnits`; parsing on every read
|
|
82
|
+
* would put the same `Number()` in three places.
|
|
83
|
+
*/
|
|
84
|
+
function ComboSlotRow({
|
|
85
|
+
slot,
|
|
86
|
+
index,
|
|
87
|
+
groups,
|
|
88
|
+
copy,
|
|
89
|
+
onChange,
|
|
90
|
+
onRemove,
|
|
91
|
+
}: {
|
|
92
|
+
slot: ComboRequirement;
|
|
93
|
+
index: number;
|
|
94
|
+
groups: readonly WireTargetGroup[];
|
|
95
|
+
copy: DiscountsWebCopy;
|
|
96
|
+
onChange: (next: ComboRequirement) => void;
|
|
97
|
+
onRemove: () => void;
|
|
98
|
+
}): JSX.Element {
|
|
99
|
+
const position = index + 1;
|
|
100
|
+
const empty = slot.categoryIds.length === 0 && slot.menuItemIds.length === 0;
|
|
101
|
+
return (
|
|
102
|
+
<Box
|
|
103
|
+
data-testid={`combo-slot-${index}`}
|
|
104
|
+
sx={{ border: 1, borderColor: "divider", borderRadius: 1, p: 2 }}
|
|
105
|
+
>
|
|
106
|
+
<Stack direction="row" alignItems="center" justifyContent="space-between" spacing={1}>
|
|
107
|
+
<Text weight="medium">{fill(copy.combo.slot, { position })}</Text>
|
|
108
|
+
<Button
|
|
109
|
+
variant="text"
|
|
110
|
+
onClick={onRemove}
|
|
111
|
+
// The label carries the POSITION, so a screen reader announces which
|
|
112
|
+
// group this removes — "remove" alone is three identical buttons.
|
|
113
|
+
aria-label={fill(copy.combo.removeSlot, { position })}
|
|
114
|
+
data-testid={`combo-slot-${index}-remove`}
|
|
115
|
+
>
|
|
116
|
+
✕
|
|
117
|
+
</Button>
|
|
118
|
+
</Stack>
|
|
119
|
+
<Stack spacing={2} sx={{ mt: 1 }}>
|
|
120
|
+
<Input
|
|
121
|
+
type="number"
|
|
122
|
+
label={copy.combo.quantity}
|
|
123
|
+
value={String(slot.quantity)}
|
|
124
|
+
onChange={(event) => onChange({ ...slot, quantity: Number(event.target.value) })}
|
|
125
|
+
data-testid={`combo-slot-${index}-quantity`}
|
|
126
|
+
/>
|
|
127
|
+
{groups.map((group) => (
|
|
128
|
+
<TargetPickerField
|
|
129
|
+
key={group.targetType}
|
|
130
|
+
group={group}
|
|
131
|
+
label={fill(copy.combo.pick, { collection: group.label })}
|
|
132
|
+
placeholder={fill(copy.targets.search, { collection: group.label })}
|
|
133
|
+
// The refusal belongs to the GROUP, not to either picker: a group
|
|
134
|
+
// naming a category is complete even with no items in it. So the
|
|
135
|
+
// message hangs below both, once, and only when both are empty.
|
|
136
|
+
requiredMessage={undefined}
|
|
137
|
+
ids={idsFor(slot, group.targetType)}
|
|
138
|
+
onChange={(ids) => onChange(withIds(slot, group.targetType, ids))}
|
|
139
|
+
copy={copy.categorySelect}
|
|
140
|
+
dataTestId={`combo-slot-${index}-${group.slug}`}
|
|
141
|
+
/>
|
|
142
|
+
))}
|
|
143
|
+
{empty && (
|
|
144
|
+
<FormMessage error dataTestId={`combo-slot-${index}-message`}>
|
|
145
|
+
{copy.form.comboSlotTargetRequired}
|
|
146
|
+
</FormMessage>
|
|
147
|
+
)}
|
|
148
|
+
</Stack>
|
|
149
|
+
</Box>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The whole builder: the heading, the groups, the add button and the read-back.
|
|
155
|
+
*
|
|
156
|
+
* The read-back line is not decoration. A combo is the one promotion whose
|
|
157
|
+
* meaning is spread across several controls, and "the combo takes 6 items in 3
|
|
158
|
+
* groups" is the sentence that catches a mistyped quantity before a save — the
|
|
159
|
+
* same number `freeUnits` is validated against, computed once and shown.
|
|
160
|
+
*/
|
|
161
|
+
export function ComboSlotBuilder({
|
|
162
|
+
slots,
|
|
163
|
+
groups,
|
|
164
|
+
copy,
|
|
165
|
+
onChange,
|
|
166
|
+
}: {
|
|
167
|
+
slots: readonly ComboRequirement[];
|
|
168
|
+
groups: readonly WireTargetGroup[];
|
|
169
|
+
copy: DiscountsWebCopy;
|
|
170
|
+
onChange: (next: ComboRequirement[]) => void;
|
|
171
|
+
}): JSX.Element {
|
|
172
|
+
return (
|
|
173
|
+
<FormControl fullWidth>
|
|
174
|
+
<FormLabel>{copy.combo.title}</FormLabel>
|
|
175
|
+
<Text variant="caption">{copy.combo.hint}</Text>
|
|
176
|
+
<Stack spacing={2} sx={{ mt: 1 }} data-testid="combo-slots">
|
|
177
|
+
{slots.map((slot, index) => (
|
|
178
|
+
<ComboSlotRow
|
|
179
|
+
// The index IS the identity here: `position` is the array index,
|
|
180
|
+
// and a group carries no id of its own until the database gives it
|
|
181
|
+
// one. Removing a group renumbers the rest, which is exactly what
|
|
182
|
+
// the operator sees happen.
|
|
183
|
+
key={index}
|
|
184
|
+
slot={slot}
|
|
185
|
+
index={index}
|
|
186
|
+
groups={groups}
|
|
187
|
+
copy={copy}
|
|
188
|
+
onChange={(next) => onChange(replaceAt(slots, index, next))}
|
|
189
|
+
onRemove={() => onChange(slots.filter((_, position) => position !== index))}
|
|
190
|
+
/>
|
|
191
|
+
))}
|
|
192
|
+
{slots.length === 0 && (
|
|
193
|
+
<Text variant="caption" data-testid="combo-slots-empty">
|
|
194
|
+
{copy.combo.empty}
|
|
195
|
+
</Text>
|
|
196
|
+
)}
|
|
197
|
+
<Stack direction="row" alignItems="center" justifyContent="space-between" spacing={2}>
|
|
198
|
+
<Button
|
|
199
|
+
variant="outline"
|
|
200
|
+
onClick={() => onChange([...slots, blankSlot()])}
|
|
201
|
+
data-testid="combo-add-slot"
|
|
202
|
+
>
|
|
203
|
+
{copy.combo.addSlot}
|
|
204
|
+
</Button>
|
|
205
|
+
{slots.length > 0 && (
|
|
206
|
+
<Text variant="caption" data-testid="combo-summary">
|
|
207
|
+
{fill(copy.combo.summary, { units: comboUnits(slots), groups: slots.length })}
|
|
208
|
+
</Text>
|
|
209
|
+
)}
|
|
210
|
+
</Stack>
|
|
211
|
+
</Stack>
|
|
212
|
+
</FormControl>
|
|
213
|
+
);
|
|
214
|
+
}
|
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,
|
|
@@ -101,6 +103,12 @@ export interface DiscountsFormCopy {
|
|
|
101
103
|
/** The example inside the empty percentage input. */
|
|
102
104
|
readonly percentPlaceholder: string;
|
|
103
105
|
readonly amountOff: string;
|
|
106
|
+
/** The combo's total price, shown only for a BUNDLE_PRICE rule. */
|
|
107
|
+
readonly bundlePrice: string;
|
|
108
|
+
/** How many of the matched units are given away, for a FREE_UNITS rule. */
|
|
109
|
+
readonly freeUnits: string;
|
|
110
|
+
/** Under it: what "leve 3 pague 2" means in this rule's own numbers. */
|
|
111
|
+
readonly freeUnitsHint: string;
|
|
104
112
|
readonly trigger: string;
|
|
105
113
|
readonly code: string;
|
|
106
114
|
/** The example inside the empty coupon input. */
|
|
@@ -111,6 +119,9 @@ export interface DiscountsFormCopy {
|
|
|
111
119
|
readonly minSubtotal: string;
|
|
112
120
|
readonly usageLimit: string;
|
|
113
121
|
readonly perBuyerLimit: string;
|
|
122
|
+
/** How many times ONE cart may claim the combo. Blank = as often as it fits. */
|
|
123
|
+
readonly maxComboApplications: string;
|
|
124
|
+
readonly maxComboApplicationsHint: string;
|
|
114
125
|
readonly active: string;
|
|
115
126
|
readonly activeHint: string;
|
|
116
127
|
readonly stackable: string;
|
|
@@ -123,12 +134,59 @@ export interface DiscountsFormCopy {
|
|
|
123
134
|
readonly nameRequired: string;
|
|
124
135
|
readonly invalidPercent: string;
|
|
125
136
|
readonly invalidAmount: string;
|
|
137
|
+
readonly invalidBundlePrice: string;
|
|
138
|
+
readonly invalidFreeUnits: string;
|
|
139
|
+
/**
|
|
140
|
+
* "Take 3, three free" is a giveaway, not a promotion — the free count has to
|
|
141
|
+
* be smaller than what one application takes out of the cart. `{units}` is
|
|
142
|
+
* that number, so the operator is told the ceiling rather than just refused.
|
|
143
|
+
*/
|
|
144
|
+
readonly freeUnitsExceedCombo: string;
|
|
126
145
|
readonly codeRequired: string;
|
|
127
146
|
readonly categoryTargetRequired: string;
|
|
128
147
|
readonly itemTargetRequired: string;
|
|
148
|
+
/** COMBO scope with no groups: there is nothing for the reward to apply to. */
|
|
149
|
+
readonly comboSlotsRequired: string;
|
|
150
|
+
/** A group naming nothing can never be filled, so the combo never fires. */
|
|
151
|
+
readonly comboSlotTargetRequired: string;
|
|
152
|
+
readonly invalidComboQuantity: string;
|
|
153
|
+
readonly invalidMaxComboApplications: string;
|
|
129
154
|
readonly endsBeforeStarts: string;
|
|
130
155
|
}
|
|
131
156
|
|
|
157
|
+
/**
|
|
158
|
+
* The combo builder — the "2 refrigerantes + 2 hambúrgueres + 2 batatas" half
|
|
159
|
+
* of a promotion (FUT-268).
|
|
160
|
+
*
|
|
161
|
+
* A combo is a list of GROUPS, each a quantity and the rows that can fill it,
|
|
162
|
+
* and the reward is whichever value the type asks for. So this group covers the
|
|
163
|
+
* builder's chrome; the reward's own input is a `form` key beside the other
|
|
164
|
+
* value fields, because to the operator it is one.
|
|
165
|
+
*/
|
|
166
|
+
export interface DiscountsComboCopy {
|
|
167
|
+
/** The section heading over the group list. */
|
|
168
|
+
readonly title: string;
|
|
169
|
+
/** One sentence saying what a group is, in the merchant's terms. */
|
|
170
|
+
readonly hint: string;
|
|
171
|
+
/** The button that appends a group. */
|
|
172
|
+
readonly addSlot: string;
|
|
173
|
+
/** The accessible name of one group's remove control. `{position}`. */
|
|
174
|
+
readonly removeSlot: string;
|
|
175
|
+
/** One group's heading. `{position}` is 1-based — an operator counts from 1. */
|
|
176
|
+
readonly slot: string;
|
|
177
|
+
/** The units this group takes. */
|
|
178
|
+
readonly quantity: string;
|
|
179
|
+
/** The group's picker label, `{collection}` — the registration's own label. */
|
|
180
|
+
readonly pick: string;
|
|
181
|
+
/** Shown when COMBO is chosen and no group exists yet. */
|
|
182
|
+
readonly empty: string;
|
|
183
|
+
/**
|
|
184
|
+
* The offer read back in the operator's own numbers, so a combo can be
|
|
185
|
+
* checked without saving it. `{units}` and `{groups}`.
|
|
186
|
+
*/
|
|
187
|
+
readonly summary: string;
|
|
188
|
+
}
|
|
189
|
+
|
|
132
190
|
/** The target pickers, and what an empty selection says. */
|
|
133
191
|
export interface DiscountsTargetCopy {
|
|
134
192
|
/** The picker's label, `{collection}` — the registration's own label. */
|
|
@@ -185,9 +243,21 @@ export interface DiscountsWebCopy {
|
|
|
185
243
|
readonly labels: DiscountsVocabularyCopy;
|
|
186
244
|
readonly window: DiscountsWindowCopy;
|
|
187
245
|
readonly form: DiscountsFormCopy;
|
|
246
|
+
readonly combo: DiscountsComboCopy;
|
|
188
247
|
readonly targets: DiscountsTargetCopy;
|
|
189
248
|
readonly actions: DiscountsActionsCopy;
|
|
190
249
|
readonly card: DiscountsCardCopy;
|
|
250
|
+
/**
|
|
251
|
+
* The words of the `@12-apps/ui` components these screens MOUNT but do not
|
|
252
|
+
* own — the confirmation popup and the category picker.
|
|
253
|
+
*
|
|
254
|
+
* They arrive here because that package stopped shipping defaults for them
|
|
255
|
+
* (FUT-760): a design system's Portuguese reached every adopter silently,
|
|
256
|
+
* so its copy is required config now and this surface is one of its hosts.
|
|
257
|
+
*/
|
|
258
|
+
readonly confirmAction: ConfirmActionCopy;
|
|
259
|
+
readonly categorySelect: CategorySelectCopy;
|
|
260
|
+
readonly dataViews: DataViewsCopy;
|
|
191
261
|
}
|
|
192
262
|
|
|
193
263
|
/**
|
|
@@ -234,6 +304,12 @@ export function missingWebCopy(
|
|
|
234
304
|
): string[] {
|
|
235
305
|
return leafPaths(reference).filter((path) => {
|
|
236
306
|
const value = read(copy, path);
|
|
307
|
+
// A leaf is satisfied by a non-blank string OR a function. The `@12-apps/ui`
|
|
308
|
+
// packs folded in below carry entries that take the interpolated value as
|
|
309
|
+
// an argument — `noResults.title(query)`, `footer.selectedCount(count)` —
|
|
310
|
+
// because Portuguese decides word order and plural agreement per sentence.
|
|
311
|
+
// Demanding a string here would report every one of them as missing.
|
|
312
|
+
if (typeof value === "function") return false;
|
|
237
313
|
return typeof value !== "string" || value.trim() === "";
|
|
238
314
|
});
|
|
239
315
|
}
|
|
@@ -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",
|
|
@@ -6,6 +6,7 @@ import { Switch } from "@12-apps/ui/form/Switch";
|
|
|
6
6
|
import { Fields, useFormContext } from "@12-apps/ui/form/total-form";
|
|
7
7
|
import { Box } from "@12-apps/ui/mui/Box";
|
|
8
8
|
import { Stack } from "@12-apps/ui/mui/Stack";
|
|
9
|
+
import { Text } from "@12-apps/ui/typography/Text";
|
|
9
10
|
|
|
10
11
|
import { DISCOUNT_SCOPES, DISCOUNT_TRIGGERS, DISCOUNT_TYPES } from "../engine/kinds";
|
|
11
12
|
|
|
@@ -21,6 +22,32 @@ import type { DiscountsWebCopy } from "./copy";
|
|
|
21
22
|
* hand-written list is how a form offers a fifth type the database refuses.
|
|
22
23
|
*/
|
|
23
24
|
|
|
25
|
+
/**
|
|
26
|
+
* A field with a sentence under it.
|
|
27
|
+
*
|
|
28
|
+
* `Fields.TextField` has no helper-text slot — deliberately, it is a thin bind
|
|
29
|
+
* over `Input` — and two of the combo inputs are meaningless without one:
|
|
30
|
+
* "itens grátis" and "combos por pedido" are both numbers whose UNIT an
|
|
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.
|
|
33
|
+
*/
|
|
34
|
+
function HintedField({
|
|
35
|
+
name,
|
|
36
|
+
label,
|
|
37
|
+
hint,
|
|
38
|
+
}: {
|
|
39
|
+
name: string;
|
|
40
|
+
label: string;
|
|
41
|
+
hint: string;
|
|
42
|
+
}): JSX.Element {
|
|
43
|
+
return (
|
|
44
|
+
<Stack spacing={0.5}>
|
|
45
|
+
<Fields.TextField name={name} label={label} type="number" />
|
|
46
|
+
<Text variant="caption">{hint}</Text>
|
|
47
|
+
</Stack>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
24
51
|
/** One closed set as a toggle's options, labelled by the pack. */
|
|
25
52
|
function optionsFor<TKey extends string>(
|
|
26
53
|
values: readonly TKey[],
|
|
@@ -29,7 +56,22 @@ function optionsFor<TKey extends string>(
|
|
|
29
56
|
return values.map((value) => ({ value, label: labels[value] }));
|
|
30
57
|
}
|
|
31
58
|
|
|
32
|
-
/**
|
|
59
|
+
/**
|
|
60
|
+
* The reward, in whichever unit the chosen type actually stores.
|
|
61
|
+
*
|
|
62
|
+
* One input per type, and the wrong one must never be mounted: the four value
|
|
63
|
+
* columns are mutually exclusive at the database (`discounts_value_check`), so
|
|
64
|
+
* 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 unmounted
|
|
66
|
+
* field cannot carry a stale number to submit.
|
|
67
|
+
*
|
|
68
|
+
* The two combo rewards read differently from the two plain ones, and that is
|
|
69
|
+
* the whole point of FUT-268: BUNDLE_PRICE is what the matched group COSTS
|
|
70
|
+
* ("2 refrigerantes, 2 hambúrgueres e 2 batatas por R$ 49,90"), FREE_UNITS is
|
|
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.
|
|
74
|
+
*/
|
|
33
75
|
function ValueField({
|
|
34
76
|
copy,
|
|
35
77
|
currencyField,
|
|
@@ -42,6 +84,14 @@ function ValueField({
|
|
|
42
84
|
if (values.type === "FIXED_AMOUNT") {
|
|
43
85
|
return <Currency name="amountOff" label={copy.form.amountOff} />;
|
|
44
86
|
}
|
|
87
|
+
if (values.type === "BUNDLE_PRICE") {
|
|
88
|
+
return <Currency name="bundlePrice" label={copy.form.bundlePrice} />;
|
|
89
|
+
}
|
|
90
|
+
if (values.type === "FREE_UNITS") {
|
|
91
|
+
return (
|
|
92
|
+
<HintedField name="freeUnits" label={copy.form.freeUnits} hint={copy.form.freeUnitsHint} />
|
|
93
|
+
);
|
|
94
|
+
}
|
|
45
95
|
return (
|
|
46
96
|
<Fields.TextField
|
|
47
97
|
name="percentOff"
|
|
@@ -111,6 +161,26 @@ export function ScopeField({ copy }: { copy: DiscountsWebCopy }): JSX.Element {
|
|
|
111
161
|
);
|
|
112
162
|
}
|
|
113
163
|
|
|
164
|
+
/**
|
|
165
|
+
* How many times one cart may claim the combo.
|
|
166
|
+
*
|
|
167
|
+
* Only at COMBO scope, because it is the only scope where the question means
|
|
168
|
+
* anything: an order-wide or category discount applies once by construction. A
|
|
169
|
+
* blank field is "as often as it fits", which is the merchant's usual answer
|
|
170
|
+
* and therefore the default rather than a number they have to type.
|
|
171
|
+
*/
|
|
172
|
+
function ComboCapField({ copy }: { copy: DiscountsWebCopy }): JSX.Element | null {
|
|
173
|
+
const { values } = useFormContext();
|
|
174
|
+
if (values.scope !== "COMBO") return null;
|
|
175
|
+
return (
|
|
176
|
+
<HintedField
|
|
177
|
+
name="maxComboApplications"
|
|
178
|
+
label={copy.form.maxComboApplications}
|
|
179
|
+
hint={copy.form.maxComboApplicationsHint}
|
|
180
|
+
/>
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
114
184
|
/** The active window. Both sides optional; the end date is EXCLUSIVE. */
|
|
115
185
|
export function WindowFields({ copy }: { copy: DiscountsWebCopy }): JSX.Element {
|
|
116
186
|
return (
|
|
@@ -135,6 +205,7 @@ export function LimitFields({
|
|
|
135
205
|
<Currency name="minSubtotal" label={copy.form.minSubtotal} />
|
|
136
206
|
<Fields.TextField name="usageLimit" label={copy.form.usageLimit} type="number" />
|
|
137
207
|
<Fields.TextField name="perBuyerLimit" label={copy.form.perBuyerLimit} type="number" />
|
|
208
|
+
<ComboCapField copy={copy} />
|
|
138
209
|
</Box>
|
|
139
210
|
);
|
|
140
211
|
}
|