@12-apps/discounts 1.3.0 → 1.4.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 +1 -1
- package/src/react/api.ts +27 -3
- package/src/react/combo-slot-builder.tsx +213 -0
- package/src/react/copy.ts +57 -0
- package/src/react/discount-form-fields.tsx +72 -1
- package/src/react/discount-form.tsx +134 -13
- package/src/react/discount-target-picker.tsx +36 -81
- package/src/react/pt-BR.ts +25 -0
- package/src/react/target-picker-field.tsx +138 -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.4.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": {
|
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,213 @@
|
|
|
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
|
+
dataTestId={`combo-slot-${index}-${group.slug}`}
|
|
140
|
+
/>
|
|
141
|
+
))}
|
|
142
|
+
{empty && (
|
|
143
|
+
<FormMessage error dataTestId={`combo-slot-${index}-message`}>
|
|
144
|
+
{copy.form.comboSlotTargetRequired}
|
|
145
|
+
</FormMessage>
|
|
146
|
+
)}
|
|
147
|
+
</Stack>
|
|
148
|
+
</Box>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The whole builder: the heading, the groups, the add button and the read-back.
|
|
154
|
+
*
|
|
155
|
+
* The read-back line is not decoration. A combo is the one promotion whose
|
|
156
|
+
* meaning is spread across several controls, and "the combo takes 6 items in 3
|
|
157
|
+
* groups" is the sentence that catches a mistyped quantity before a save — the
|
|
158
|
+
* same number `freeUnits` is validated against, computed once and shown.
|
|
159
|
+
*/
|
|
160
|
+
export function ComboSlotBuilder({
|
|
161
|
+
slots,
|
|
162
|
+
groups,
|
|
163
|
+
copy,
|
|
164
|
+
onChange,
|
|
165
|
+
}: {
|
|
166
|
+
slots: readonly ComboRequirement[];
|
|
167
|
+
groups: readonly WireTargetGroup[];
|
|
168
|
+
copy: DiscountsWebCopy;
|
|
169
|
+
onChange: (next: ComboRequirement[]) => void;
|
|
170
|
+
}): JSX.Element {
|
|
171
|
+
return (
|
|
172
|
+
<FormControl fullWidth>
|
|
173
|
+
<FormLabel>{copy.combo.title}</FormLabel>
|
|
174
|
+
<Text variant="caption">{copy.combo.hint}</Text>
|
|
175
|
+
<Stack spacing={2} sx={{ mt: 1 }} data-testid="combo-slots">
|
|
176
|
+
{slots.map((slot, index) => (
|
|
177
|
+
<ComboSlotRow
|
|
178
|
+
// The index IS the identity here: `position` is the array index,
|
|
179
|
+
// and a group carries no id of its own until the database gives it
|
|
180
|
+
// one. Removing a group renumbers the rest, which is exactly what
|
|
181
|
+
// the operator sees happen.
|
|
182
|
+
key={index}
|
|
183
|
+
slot={slot}
|
|
184
|
+
index={index}
|
|
185
|
+
groups={groups}
|
|
186
|
+
copy={copy}
|
|
187
|
+
onChange={(next) => onChange(replaceAt(slots, index, next))}
|
|
188
|
+
onRemove={() => onChange(slots.filter((_, position) => position !== index))}
|
|
189
|
+
/>
|
|
190
|
+
))}
|
|
191
|
+
{slots.length === 0 && (
|
|
192
|
+
<Text variant="caption" data-testid="combo-slots-empty">
|
|
193
|
+
{copy.combo.empty}
|
|
194
|
+
</Text>
|
|
195
|
+
)}
|
|
196
|
+
<Stack direction="row" alignItems="center" justifyContent="space-between" spacing={2}>
|
|
197
|
+
<Button
|
|
198
|
+
variant="outline"
|
|
199
|
+
onClick={() => onChange([...slots, blankSlot()])}
|
|
200
|
+
data-testid="combo-add-slot"
|
|
201
|
+
>
|
|
202
|
+
{copy.combo.addSlot}
|
|
203
|
+
</Button>
|
|
204
|
+
{slots.length > 0 && (
|
|
205
|
+
<Text variant="caption" data-testid="combo-summary">
|
|
206
|
+
{fill(copy.combo.summary, { units: comboUnits(slots), groups: slots.length })}
|
|
207
|
+
</Text>
|
|
208
|
+
)}
|
|
209
|
+
</Stack>
|
|
210
|
+
</Stack>
|
|
211
|
+
</FormControl>
|
|
212
|
+
);
|
|
213
|
+
}
|
package/src/react/copy.ts
CHANGED
|
@@ -101,6 +101,12 @@ export interface DiscountsFormCopy {
|
|
|
101
101
|
/** The example inside the empty percentage input. */
|
|
102
102
|
readonly percentPlaceholder: string;
|
|
103
103
|
readonly amountOff: string;
|
|
104
|
+
/** The combo's total price, shown only for a BUNDLE_PRICE rule. */
|
|
105
|
+
readonly bundlePrice: string;
|
|
106
|
+
/** How many of the matched units are given away, for a FREE_UNITS rule. */
|
|
107
|
+
readonly freeUnits: string;
|
|
108
|
+
/** Under it: what "leve 3 pague 2" means in this rule's own numbers. */
|
|
109
|
+
readonly freeUnitsHint: string;
|
|
104
110
|
readonly trigger: string;
|
|
105
111
|
readonly code: string;
|
|
106
112
|
/** The example inside the empty coupon input. */
|
|
@@ -111,6 +117,9 @@ export interface DiscountsFormCopy {
|
|
|
111
117
|
readonly minSubtotal: string;
|
|
112
118
|
readonly usageLimit: string;
|
|
113
119
|
readonly perBuyerLimit: string;
|
|
120
|
+
/** How many times ONE cart may claim the combo. Blank = as often as it fits. */
|
|
121
|
+
readonly maxComboApplications: string;
|
|
122
|
+
readonly maxComboApplicationsHint: string;
|
|
114
123
|
readonly active: string;
|
|
115
124
|
readonly activeHint: string;
|
|
116
125
|
readonly stackable: string;
|
|
@@ -123,12 +132,59 @@ export interface DiscountsFormCopy {
|
|
|
123
132
|
readonly nameRequired: string;
|
|
124
133
|
readonly invalidPercent: string;
|
|
125
134
|
readonly invalidAmount: string;
|
|
135
|
+
readonly invalidBundlePrice: string;
|
|
136
|
+
readonly invalidFreeUnits: string;
|
|
137
|
+
/**
|
|
138
|
+
* "Take 3, three free" is a giveaway, not a promotion — the free count has to
|
|
139
|
+
* be smaller than what one application takes out of the cart. `{units}` is
|
|
140
|
+
* that number, so the operator is told the ceiling rather than just refused.
|
|
141
|
+
*/
|
|
142
|
+
readonly freeUnitsExceedCombo: string;
|
|
126
143
|
readonly codeRequired: string;
|
|
127
144
|
readonly categoryTargetRequired: string;
|
|
128
145
|
readonly itemTargetRequired: string;
|
|
146
|
+
/** COMBO scope with no groups: there is nothing for the reward to apply to. */
|
|
147
|
+
readonly comboSlotsRequired: string;
|
|
148
|
+
/** A group naming nothing can never be filled, so the combo never fires. */
|
|
149
|
+
readonly comboSlotTargetRequired: string;
|
|
150
|
+
readonly invalidComboQuantity: string;
|
|
151
|
+
readonly invalidMaxComboApplications: string;
|
|
129
152
|
readonly endsBeforeStarts: string;
|
|
130
153
|
}
|
|
131
154
|
|
|
155
|
+
/**
|
|
156
|
+
* The combo builder — the "2 refrigerantes + 2 hambúrgueres + 2 batatas" half
|
|
157
|
+
* of a promotion (FUT-268).
|
|
158
|
+
*
|
|
159
|
+
* A combo is a list of GROUPS, each a quantity and the rows that can fill it,
|
|
160
|
+
* and the reward is whichever value the type asks for. So this group covers the
|
|
161
|
+
* builder's chrome; the reward's own input is a `form` key beside the other
|
|
162
|
+
* value fields, because to the operator it is one.
|
|
163
|
+
*/
|
|
164
|
+
export interface DiscountsComboCopy {
|
|
165
|
+
/** The section heading over the group list. */
|
|
166
|
+
readonly title: string;
|
|
167
|
+
/** One sentence saying what a group is, in the merchant's terms. */
|
|
168
|
+
readonly hint: string;
|
|
169
|
+
/** The button that appends a group. */
|
|
170
|
+
readonly addSlot: string;
|
|
171
|
+
/** The accessible name of one group's remove control. `{position}`. */
|
|
172
|
+
readonly removeSlot: string;
|
|
173
|
+
/** One group's heading. `{position}` is 1-based — an operator counts from 1. */
|
|
174
|
+
readonly slot: string;
|
|
175
|
+
/** The units this group takes. */
|
|
176
|
+
readonly quantity: string;
|
|
177
|
+
/** The group's picker label, `{collection}` — the registration's own label. */
|
|
178
|
+
readonly pick: string;
|
|
179
|
+
/** Shown when COMBO is chosen and no group exists yet. */
|
|
180
|
+
readonly empty: string;
|
|
181
|
+
/**
|
|
182
|
+
* The offer read back in the operator's own numbers, so a combo can be
|
|
183
|
+
* checked without saving it. `{units}` and `{groups}`.
|
|
184
|
+
*/
|
|
185
|
+
readonly summary: string;
|
|
186
|
+
}
|
|
187
|
+
|
|
132
188
|
/** The target pickers, and what an empty selection says. */
|
|
133
189
|
export interface DiscountsTargetCopy {
|
|
134
190
|
/** The picker's label, `{collection}` — the registration's own label. */
|
|
@@ -185,6 +241,7 @@ export interface DiscountsWebCopy {
|
|
|
185
241
|
readonly labels: DiscountsVocabularyCopy;
|
|
186
242
|
readonly window: DiscountsWindowCopy;
|
|
187
243
|
readonly form: DiscountsFormCopy;
|
|
244
|
+
readonly combo: DiscountsComboCopy;
|
|
188
245
|
readonly targets: DiscountsTargetCopy;
|
|
189
246
|
readonly actions: DiscountsActionsCopy;
|
|
190
247
|
readonly card: DiscountsCardCopy;
|
|
@@ -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
|
}
|
|
@@ -12,8 +12,10 @@ import {
|
|
|
12
12
|
} from "@12-apps/ui/form/total-form";
|
|
13
13
|
import { Stack } from "@12-apps/ui/mui/Stack";
|
|
14
14
|
|
|
15
|
+
import type { ComboRequirement } from "../engine/types";
|
|
16
|
+
|
|
15
17
|
import type { DiscountFormPayload, DiscountsApiClient, DiscountWireRecord, WireTargetGroup } from "./api";
|
|
16
|
-
import type
|
|
18
|
+
import { fill, type DiscountsWebCopy } from "./copy";
|
|
17
19
|
import {
|
|
18
20
|
DiscountSwitches,
|
|
19
21
|
LimitFields,
|
|
@@ -22,6 +24,7 @@ import {
|
|
|
22
24
|
WindowFields,
|
|
23
25
|
type CurrencyFieldComponent,
|
|
24
26
|
} from "./discount-form-fields";
|
|
27
|
+
import { comboUnits } from "./combo-slot-builder";
|
|
25
28
|
import { DiscountTargetPicker } from "./discount-target-picker";
|
|
26
29
|
import type { DiscountsFormatters } from "./format";
|
|
27
30
|
|
|
@@ -46,6 +49,9 @@ interface DiscountFormValues extends Record<string, string> {
|
|
|
46
49
|
type: string;
|
|
47
50
|
percentOff: string;
|
|
48
51
|
amountOff: string;
|
|
52
|
+
bundlePrice: string;
|
|
53
|
+
freeUnits: string;
|
|
54
|
+
maxComboApplications: string;
|
|
49
55
|
scope: string;
|
|
50
56
|
trigger: string;
|
|
51
57
|
code: string;
|
|
@@ -67,6 +73,9 @@ const BLANK: DiscountFormValues = {
|
|
|
67
73
|
type: "PERCENTAGE",
|
|
68
74
|
percentOff: "",
|
|
69
75
|
amountOff: "",
|
|
76
|
+
bundlePrice: "",
|
|
77
|
+
freeUnits: "",
|
|
78
|
+
maxComboApplications: "",
|
|
70
79
|
scope: "ORDER",
|
|
71
80
|
trigger: "AUTOMATIC",
|
|
72
81
|
code: "",
|
|
@@ -89,6 +98,10 @@ function initialValues(
|
|
|
89
98
|
type: editing.type,
|
|
90
99
|
percentOff: editing.percentOffBp === null ? "" : formatters.toInput(editing.percentOffBp / 100),
|
|
91
100
|
amountOff: cents(editing.amountOffCents),
|
|
101
|
+
bundlePrice: cents(editing.bundlePriceCents ?? null),
|
|
102
|
+
freeUnits: editing.freeUnits == null ? "" : String(editing.freeUnits),
|
|
103
|
+
maxComboApplications:
|
|
104
|
+
editing.maxComboApplications == null ? "" : String(editing.maxComboApplications),
|
|
92
105
|
scope: editing.scope,
|
|
93
106
|
trigger: editing.trigger,
|
|
94
107
|
code: editing.code ?? "",
|
|
@@ -100,31 +113,125 @@ function initialValues(
|
|
|
100
113
|
};
|
|
101
114
|
}
|
|
102
115
|
|
|
103
|
-
/**
|
|
116
|
+
/** A whole number above zero, as typed. `null` when it is neither. */
|
|
117
|
+
function positiveCount(typed: string): number | null {
|
|
118
|
+
const trimmed = typed.trim();
|
|
119
|
+
if (trimmed === "") return null;
|
|
120
|
+
const value = Number(trimmed);
|
|
121
|
+
return Number.isInteger(value) && value > 0 ? value : null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** A money field that has to hold something above zero. */
|
|
125
|
+
function checkMoney(
|
|
126
|
+
typed: string,
|
|
127
|
+
field: string,
|
|
128
|
+
message: string,
|
|
129
|
+
formatters: DiscountsFormatters,
|
|
130
|
+
): Record<string, string> {
|
|
131
|
+
const amount = formatters.parseDecimal(typed);
|
|
132
|
+
return amount === null || amount <= 0 ? { [field]: message } : {};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The free count, which is the one reward bounded by another field.
|
|
137
|
+
*
|
|
138
|
+
* "Take 3, three free" is a giveaway rather than a promotion, so it is compared
|
|
139
|
+
* against what ONE application of the combo takes out of the cart. The ceiling
|
|
140
|
+
* is named in the message: an operator told only "too many" has to guess the
|
|
141
|
+
* number.
|
|
142
|
+
*/
|
|
143
|
+
function checkFreeUnits(
|
|
144
|
+
typed: string,
|
|
145
|
+
slots: readonly ComboRequirement[],
|
|
146
|
+
copy: DiscountsWebCopy,
|
|
147
|
+
): Record<string, string> {
|
|
148
|
+
const free = positiveCount(typed);
|
|
149
|
+
if (free === null) return { freeUnits: copy.form.invalidFreeUnits };
|
|
150
|
+
const units = comboUnits(slots);
|
|
151
|
+
if (free >= units) {
|
|
152
|
+
return {
|
|
153
|
+
freeUnits: fill(copy.form.freeUnitsExceedCombo, { units, max: Math.max(units - 1, 0) }),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
return {};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The rate, in the 0–100 an operator thinks in rather than basis points. */
|
|
160
|
+
function checkPercent(
|
|
161
|
+
typed: string,
|
|
162
|
+
formatters: DiscountsFormatters,
|
|
163
|
+
copy: DiscountsWebCopy,
|
|
164
|
+
): Record<string, string> {
|
|
165
|
+
const percent = formatters.parseDecimal(typed);
|
|
166
|
+
return percent === null || percent <= 0 || percent > 100
|
|
167
|
+
? { percentOff: copy.form.invalidPercent }
|
|
168
|
+
: {};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The reward, checked against the input the chosen type actually mounted. */
|
|
104
172
|
function checkValue(
|
|
105
173
|
values: DiscountFormValues,
|
|
174
|
+
slots: readonly ComboRequirement[],
|
|
106
175
|
formatters: DiscountsFormatters,
|
|
107
176
|
copy: DiscountsWebCopy,
|
|
108
177
|
): Record<string, string> {
|
|
109
|
-
if (values.type === "
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
return {};
|
|
178
|
+
if (values.type === "FIXED_AMOUNT") {
|
|
179
|
+
return checkMoney(values.amountOff, "amountOff", copy.form.invalidAmount, formatters);
|
|
180
|
+
}
|
|
181
|
+
if (values.type === "BUNDLE_PRICE") {
|
|
182
|
+
return checkMoney(values.bundlePrice, "bundlePrice", copy.form.invalidBundlePrice, formatters);
|
|
115
183
|
}
|
|
116
|
-
|
|
117
|
-
return
|
|
184
|
+
if (values.type === "FREE_UNITS") return checkFreeUnits(values.freeUnits, slots, copy);
|
|
185
|
+
return checkPercent(values.percentOff, formatters, copy);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The combo half: the groups themselves, and the per-cart cap.
|
|
190
|
+
*
|
|
191
|
+
* Every rule here is also the server's (`validate-combo.ts`), and deliberately
|
|
192
|
+
* so — this half exists to attach the refusal to the input the operator is
|
|
193
|
+
* looking at, never to be the authority.
|
|
194
|
+
*/
|
|
195
|
+
function checkCombo(
|
|
196
|
+
values: DiscountFormValues,
|
|
197
|
+
slots: readonly ComboRequirement[],
|
|
198
|
+
copy: DiscountsWebCopy,
|
|
199
|
+
): Record<string, string> {
|
|
200
|
+
if (values.scope !== "COMBO") return {};
|
|
201
|
+
if (slots.length === 0) return { scope: copy.form.comboSlotsRequired };
|
|
202
|
+
const errors: Record<string, string> = {};
|
|
203
|
+
if (slots.some((slot) => !Number.isInteger(slot.quantity) || slot.quantity <= 0)) {
|
|
204
|
+
errors.scope = copy.form.invalidComboQuantity;
|
|
205
|
+
} else if (
|
|
206
|
+
slots.some((slot) => slot.categoryIds.length === 0 && slot.menuItemIds.length === 0)
|
|
207
|
+
) {
|
|
208
|
+
errors.scope = copy.form.comboSlotTargetRequired;
|
|
209
|
+
}
|
|
210
|
+
if (values.maxComboApplications.trim() !== "" && positiveCount(values.maxComboApplications) === null) {
|
|
211
|
+
errors.maxComboApplications = copy.form.invalidMaxComboApplications;
|
|
212
|
+
}
|
|
213
|
+
return errors;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** What the pickers and the builder currently hold, in one argument. */
|
|
217
|
+
interface FormTargets {
|
|
218
|
+
categoryIds: string[];
|
|
219
|
+
menuItemIds: string[];
|
|
220
|
+
comboRequirements: ComboRequirement[];
|
|
118
221
|
}
|
|
119
222
|
|
|
120
223
|
/** The cross-field rules a per-input schema cannot express. */
|
|
121
224
|
function validate(
|
|
122
225
|
values: DiscountFormValues,
|
|
123
|
-
targets:
|
|
226
|
+
targets: FormTargets,
|
|
124
227
|
formatters: DiscountsFormatters,
|
|
125
228
|
copy: DiscountsWebCopy,
|
|
126
229
|
): Record<string, string> {
|
|
127
|
-
const
|
|
230
|
+
const slots = targets.comboRequirements;
|
|
231
|
+
const errors: Record<string, string> = {
|
|
232
|
+
...checkValue(values, slots, formatters, copy),
|
|
233
|
+
...checkCombo(values, slots, copy),
|
|
234
|
+
};
|
|
128
235
|
if (values.trigger === "CODE" && values.code.trim() === "") {
|
|
129
236
|
errors.code = copy.form.codeRequired;
|
|
130
237
|
}
|
|
@@ -148,15 +255,23 @@ function useNonStringState(editing: DiscountWireRecord | null) {
|
|
|
148
255
|
const [stackable, setStackable] = useState<boolean>(editing?.stackable ?? true);
|
|
149
256
|
const [categoryIds, setCategoryIds] = useState<string[]>(editing?.categoryIds ?? []);
|
|
150
257
|
const [menuItemIds, setMenuItemIds] = useState<string[]>(editing?.menuItemIds ?? []);
|
|
258
|
+
// The combo's groups are the third array, and the one whose ORDER matters:
|
|
259
|
+
// the list the operator built is the list a card reads back, and the server
|
|
260
|
+
// stamps each group's index as its stored `position`.
|
|
261
|
+
const [comboRequirements, setComboRequirements] = useState<ComboRequirement[]>(() =>
|
|
262
|
+
editing?.comboRequirements ? [...editing.comboRequirements] : [],
|
|
263
|
+
);
|
|
151
264
|
return {
|
|
152
265
|
active,
|
|
153
266
|
stackable,
|
|
154
267
|
categoryIds,
|
|
155
268
|
menuItemIds,
|
|
269
|
+
comboRequirements,
|
|
156
270
|
setActive,
|
|
157
271
|
setStackable,
|
|
158
272
|
setCategoryIds,
|
|
159
273
|
setMenuItemIds,
|
|
274
|
+
setComboRequirements,
|
|
160
275
|
};
|
|
161
276
|
}
|
|
162
277
|
|
|
@@ -190,7 +305,11 @@ function makeSubmit(
|
|
|
190
305
|
const { api, copy, formatters, editing, onSaved, onError } = props;
|
|
191
306
|
return async function handleSubmit(values, { setFieldErrors }): Promise<void> {
|
|
192
307
|
setError(null);
|
|
193
|
-
const targets = {
|
|
308
|
+
const targets: FormTargets = {
|
|
309
|
+
categoryIds: extra.categoryIds,
|
|
310
|
+
menuItemIds: extra.menuItemIds,
|
|
311
|
+
comboRequirements: extra.comboRequirements,
|
|
312
|
+
};
|
|
194
313
|
const invalid = validate(values, targets, formatters, copy);
|
|
195
314
|
if (Object.keys(invalid).length > 0) {
|
|
196
315
|
setFieldErrors(invalid);
|
|
@@ -241,8 +360,10 @@ export function DiscountForm(props: DiscountFormProps): JSX.Element {
|
|
|
241
360
|
selection={{
|
|
242
361
|
categoryIds: extra.categoryIds,
|
|
243
362
|
menuItemIds: extra.menuItemIds,
|
|
363
|
+
comboRequirements: extra.comboRequirements,
|
|
244
364
|
onCategoryIdsChange: extra.setCategoryIds,
|
|
245
365
|
onMenuItemIdsChange: extra.setMenuItemIds,
|
|
366
|
+
onComboRequirementsChange: extra.setComboRequirements,
|
|
246
367
|
}}
|
|
247
368
|
/>
|
|
248
369
|
<WindowFields copy={copy} />
|
|
@@ -1,23 +1,24 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import type { JSX } from "react";
|
|
4
4
|
|
|
5
|
-
import { Autocomplete } from "@12-apps/ui/form/Autocomplete";
|
|
6
|
-
import { CategorySelect } from "@12-apps/ui/form/CategorySelect";
|
|
7
|
-
import { FormControl, FormLabel, FormMessage } from "@12-apps/ui/form/Form";
|
|
8
5
|
import { useFormContext } from "@12-apps/ui/form/total-form";
|
|
9
6
|
|
|
7
|
+
import type { ComboRequirement } from "../engine/types";
|
|
8
|
+
|
|
9
|
+
import type { WireTargetGroup } from "./api";
|
|
10
|
+
import { ComboSlotBuilder } from "./combo-slot-builder";
|
|
10
11
|
import { fill, type DiscountsWebCopy } from "./copy";
|
|
11
|
-
import
|
|
12
|
+
import { TargetPickerField } from "./target-picker-field";
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* What a scoped discount points at, picked from the host's own collections.
|
|
15
16
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* `
|
|
20
|
-
*
|
|
17
|
+
* This file is the ROUTER: it reads the scope and decides which shape the
|
|
18
|
+
* targets take. The controls themselves live next door — one collection's
|
|
19
|
+
* picker in `target-picker-field`, the quantified group list in
|
|
20
|
+
* `combo-slot-builder` — because the combo builder needs the former per group
|
|
21
|
+
* and a file cannot import its own importer.
|
|
21
22
|
*
|
|
22
23
|
* Which collections exist is not decided here. The surface reads them from
|
|
23
24
|
* `GET /discounts/targets`, which answers whatever the host registered — so
|
|
@@ -32,62 +33,15 @@ import type { WireTarget, WireTargetGroup } from "./api";
|
|
|
32
33
|
* leaving a stale one on screen would suggest otherwise.
|
|
33
34
|
*/
|
|
34
35
|
|
|
35
|
-
/**
|
|
36
|
-
function TargetField({
|
|
37
|
-
label,
|
|
38
|
-
placeholder,
|
|
39
|
-
requiredMessage,
|
|
40
|
-
options,
|
|
41
|
-
ids,
|
|
42
|
-
onChange,
|
|
43
|
-
dataTestId,
|
|
44
|
-
}: {
|
|
45
|
-
label: string;
|
|
46
|
-
placeholder: string;
|
|
47
|
-
requiredMessage: string;
|
|
48
|
-
options: WireTarget[];
|
|
49
|
-
ids: readonly string[];
|
|
50
|
-
onChange: (next: string[]) => void;
|
|
51
|
-
dataTestId: string;
|
|
52
|
-
}): JSX.Element {
|
|
53
|
-
const [query, setQuery] = useState("");
|
|
54
|
-
const byId = new Map(options.map((option) => [option.id, option]));
|
|
55
|
-
// An id whose option is missing — a target archived since the rule was saved
|
|
56
|
-
// — still renders as a chip, so saving cannot silently drop it.
|
|
57
|
-
const selected = ids.map((id) => byId.get(id) ?? { id, name: id });
|
|
58
|
-
|
|
59
|
-
return (
|
|
60
|
-
<FormControl fullWidth>
|
|
61
|
-
<FormLabel error={ids.length === 0}>{label}</FormLabel>
|
|
62
|
-
<div data-testid={dataTestId}>
|
|
63
|
-
<Autocomplete<WireTarget>
|
|
64
|
-
multiple
|
|
65
|
-
value={query}
|
|
66
|
-
onChange={setQuery}
|
|
67
|
-
suggestions={options}
|
|
68
|
-
selectedItems={selected}
|
|
69
|
-
onSelectedItemsChange={(items) => onChange(items.map((item) => item.id))}
|
|
70
|
-
getKey={(option) => option.id}
|
|
71
|
-
getLabel={(option) => option.name}
|
|
72
|
-
placeholder={placeholder}
|
|
73
|
-
inputAriaLabel={label}
|
|
74
|
-
/>
|
|
75
|
-
</div>
|
|
76
|
-
{ids.length === 0 && (
|
|
77
|
-
<FormMessage error dataTestId={`${dataTestId}-message`}>
|
|
78
|
-
{requiredMessage}
|
|
79
|
-
</FormMessage>
|
|
80
|
-
)}
|
|
81
|
-
</FormControl>
|
|
82
|
-
);
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/** The ids currently chosen for one dimension. */
|
|
36
|
+
/** What the form currently has chosen, in every shape a scope can need. */
|
|
86
37
|
interface DiscountTargetSelection {
|
|
87
38
|
categoryIds: string[];
|
|
88
39
|
menuItemIds: string[];
|
|
40
|
+
/** The combo's groups, in the operator's own order. Empty at every other scope. */
|
|
41
|
+
comboRequirements: readonly ComboRequirement[];
|
|
89
42
|
onCategoryIdsChange: (next: string[]) => void;
|
|
90
43
|
onMenuItemIdsChange: (next: string[]) => void;
|
|
44
|
+
onComboRequirementsChange: (next: ComboRequirement[]) => void;
|
|
91
45
|
}
|
|
92
46
|
|
|
93
47
|
/** Which selection a dimension reads and writes. */
|
|
@@ -112,6 +66,12 @@ function scopeFor(targetType: string): string {
|
|
|
112
66
|
* Reads the LIVE `scope` from the form context, so flipping the toggle swaps
|
|
113
67
|
* the picker immediately without the parent form having to mirror the value
|
|
114
68
|
* into its own state.
|
|
69
|
+
*
|
|
70
|
+
* COMBO is the third branch (FUT-268) and it is a different SHAPE, not another
|
|
71
|
+
* picker: a combo is a list of quantified groups, so it gets the builder and
|
|
72
|
+
* every collection at once, while CATEGORY and ITEM each get exactly one
|
|
73
|
+
* collection's picker. ORDER still renders nothing — an order-wide discount
|
|
74
|
+
* covers everything, so a target list there is not empty, it is meaningless.
|
|
115
75
|
*/
|
|
116
76
|
export function DiscountTargetPicker({
|
|
117
77
|
groups,
|
|
@@ -124,36 +84,31 @@ export function DiscountTargetPicker({
|
|
|
124
84
|
}): JSX.Element | null {
|
|
125
85
|
const { values } = useFormContext();
|
|
126
86
|
const scope = values.scope ?? "ORDER";
|
|
127
|
-
const group = groups.find((entry) => scopeFor(entry.targetType) === scope);
|
|
128
|
-
if (!group) return null;
|
|
129
|
-
|
|
130
|
-
const { ids, onChange } = bind(group.targetType, selection);
|
|
131
|
-
const label = fill(copy.targets.pick, { collection: group.label });
|
|
132
|
-
const dataTestId = `discount-${group.slug}-targets`;
|
|
133
87
|
|
|
134
|
-
if (
|
|
88
|
+
if (scope === "COMBO") {
|
|
135
89
|
return (
|
|
136
|
-
<
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
value={ids}
|
|
142
|
-
onChange={onChange}
|
|
143
|
-
error={ids.length === 0 ? copy.targets.required : undefined}
|
|
144
|
-
dataTestId={dataTestId}
|
|
90
|
+
<ComboSlotBuilder
|
|
91
|
+
slots={selection.comboRequirements}
|
|
92
|
+
groups={groups}
|
|
93
|
+
copy={copy}
|
|
94
|
+
onChange={selection.onComboRequirementsChange}
|
|
145
95
|
/>
|
|
146
96
|
);
|
|
147
97
|
}
|
|
98
|
+
|
|
99
|
+
const group = groups.find((entry) => scopeFor(entry.targetType) === scope);
|
|
100
|
+
if (!group) return null;
|
|
101
|
+
|
|
102
|
+
const { ids, onChange } = bind(group.targetType, selection);
|
|
148
103
|
return (
|
|
149
|
-
<
|
|
150
|
-
|
|
104
|
+
<TargetPickerField
|
|
105
|
+
group={group}
|
|
106
|
+
label={fill(copy.targets.pick, { collection: group.label })}
|
|
151
107
|
placeholder={fill(copy.targets.search, { collection: group.label })}
|
|
152
108
|
requiredMessage={copy.targets.required}
|
|
153
|
-
options={group.targets}
|
|
154
109
|
ids={ids}
|
|
155
110
|
onChange={onChange}
|
|
156
|
-
dataTestId={
|
|
111
|
+
dataTestId={`discount-${group.slug}-targets`}
|
|
157
112
|
/>
|
|
158
113
|
);
|
|
159
114
|
}
|
package/src/react/pt-BR.ts
CHANGED
|
@@ -75,6 +75,10 @@ export const PT_BR_DISCOUNTS_WEB_COPY: DiscountsWebCopy = {
|
|
|
75
75
|
percentOff: "Desconto (%)",
|
|
76
76
|
percentPlaceholder: "10",
|
|
77
77
|
amountOff: "Valor do desconto",
|
|
78
|
+
bundlePrice: "Preço do combo",
|
|
79
|
+
freeUnits: "Itens grátis",
|
|
80
|
+
freeUnitsHint:
|
|
81
|
+
"Quantas unidades do combo saem de graça. Em um grupo de 3, deixar 1 grátis é o clássico \"leve 3, pague 2\".",
|
|
78
82
|
trigger: "Ativação",
|
|
79
83
|
code: "Código do cupom",
|
|
80
84
|
codePlaceholder: "BEMVINDO10",
|
|
@@ -84,6 +88,8 @@ export const PT_BR_DISCOUNTS_WEB_COPY: DiscountsWebCopy = {
|
|
|
84
88
|
minSubtotal: "Pedido mínimo",
|
|
85
89
|
usageLimit: "Limite de usos",
|
|
86
90
|
perBuyerLimit: "Limite por cliente",
|
|
91
|
+
maxComboApplications: "Combos por pedido",
|
|
92
|
+
maxComboApplicationsHint: "Deixe em branco para valer quantas vezes couber no carrinho.",
|
|
87
93
|
active: "Ativo",
|
|
88
94
|
activeHint: "Desligue para pausar a promoção sem excluí-la.",
|
|
89
95
|
stackable: "Acumulável",
|
|
@@ -94,11 +100,30 @@ export const PT_BR_DISCOUNTS_WEB_COPY: DiscountsWebCopy = {
|
|
|
94
100
|
nameRequired: "Informe o nome da promoção.",
|
|
95
101
|
invalidPercent: "Informe uma porcentagem maior que 0 e no máximo 100.",
|
|
96
102
|
invalidAmount: "Informe um valor de desconto maior que zero.",
|
|
103
|
+
invalidBundlePrice: "Informe o preço do combo, maior que zero.",
|
|
104
|
+
invalidFreeUnits: "Informe quantos itens saem de graça, a partir de 1.",
|
|
105
|
+
freeUnitsExceedCombo:
|
|
106
|
+
"O combo tem {units} itens; deixe grátis no máximo {max} para ainda ser uma promoção.",
|
|
97
107
|
codeRequired: "Informe o código do cupom que o cliente vai digitar.",
|
|
98
108
|
categoryTargetRequired: "Selecione ao menos uma categoria para este desconto.",
|
|
99
109
|
itemTargetRequired: "Selecione ao menos um produto para este desconto.",
|
|
110
|
+
comboSlotsRequired: "Monte o combo: adicione ao menos um grupo de itens.",
|
|
111
|
+
comboSlotTargetRequired: "Escolha o que pode entrar em cada grupo do combo.",
|
|
112
|
+
invalidComboQuantity: "Informe quantas unidades cada grupo leva, a partir de 1.",
|
|
113
|
+
invalidMaxComboApplications: "Informe quantos combos cabem por pedido, a partir de 1.",
|
|
100
114
|
endsBeforeStarts: "A data de término deve ser posterior à data de início.",
|
|
101
115
|
},
|
|
116
|
+
combo: {
|
|
117
|
+
title: "Monte o combo",
|
|
118
|
+
hint: "Cada grupo é uma parte do combo: quantas unidades leva e o que pode entrar nela.",
|
|
119
|
+
addSlot: "Adicionar grupo",
|
|
120
|
+
removeSlot: "Remover o grupo {position}",
|
|
121
|
+
slot: "Grupo {position}",
|
|
122
|
+
quantity: "Quantidade",
|
|
123
|
+
pick: "{collection} deste grupo",
|
|
124
|
+
empty: "Nenhum grupo ainda. Adicione o primeiro para montar o combo.",
|
|
125
|
+
summary: "O combo leva {units} itens em {groups} grupos.",
|
|
126
|
+
},
|
|
102
127
|
targets: {
|
|
103
128
|
pick: "{collection} com desconto",
|
|
104
129
|
search: "Buscar em {collection}…",
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, type JSX } from "react";
|
|
4
|
+
|
|
5
|
+
import { Autocomplete } from "@12-apps/ui/form/Autocomplete";
|
|
6
|
+
import { CategorySelect } from "@12-apps/ui/form/CategorySelect";
|
|
7
|
+
import { FormControl, FormLabel, FormMessage } from "@12-apps/ui/form/Form";
|
|
8
|
+
|
|
9
|
+
import type { WireTarget, WireTargetGroup } from "./api";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* One collection's picker, in the two shapes a collection can take.
|
|
13
|
+
*
|
|
14
|
+
* Its own module because BOTH callers need it and they are not each other's
|
|
15
|
+
* parent: the scope-driven block renders one per discount, and the combo
|
|
16
|
+
* builder renders one per collection per GROUP. Importing it from either would
|
|
17
|
+
* make the two files a cycle, and copying it is how a nesting collection ends
|
|
18
|
+
* up a flat combobox in one of the two places — the exact bug this seam exists
|
|
19
|
+
* to prevent.
|
|
20
|
+
*
|
|
21
|
+
* There is no shared multi-select in `@12-apps/ui`, so the flat case composes
|
|
22
|
+
* `Autocomplete` in multiple mode — chips plus type-to-filter, the same control
|
|
23
|
+
* `AddressAutocomplete` is built on. A collection that NESTS gets
|
|
24
|
+
* `CategorySelect` instead: a flat combobox would put a subcategory beside its
|
|
25
|
+
* parent with nothing to tell them apart.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** One labelled multi-select of targets, with its own input-text state. */
|
|
29
|
+
function TargetField({
|
|
30
|
+
label,
|
|
31
|
+
placeholder,
|
|
32
|
+
requiredMessage,
|
|
33
|
+
options,
|
|
34
|
+
ids,
|
|
35
|
+
onChange,
|
|
36
|
+
dataTestId,
|
|
37
|
+
}: {
|
|
38
|
+
label: string;
|
|
39
|
+
placeholder: string;
|
|
40
|
+
requiredMessage: string | undefined;
|
|
41
|
+
options: readonly WireTarget[];
|
|
42
|
+
ids: readonly string[];
|
|
43
|
+
onChange: (next: string[]) => void;
|
|
44
|
+
dataTestId: string;
|
|
45
|
+
}): JSX.Element {
|
|
46
|
+
const [query, setQuery] = useState("");
|
|
47
|
+
const byId = new Map(options.map((option) => [option.id, option]));
|
|
48
|
+
// An id whose option is missing — a target archived since the rule was saved
|
|
49
|
+
// — still renders as a chip, so saving cannot silently drop it.
|
|
50
|
+
const selected = ids.map((id) => byId.get(id) ?? { id, name: id });
|
|
51
|
+
|
|
52
|
+
const missing = requiredMessage !== undefined && ids.length === 0;
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<FormControl fullWidth>
|
|
56
|
+
<FormLabel error={missing}>{label}</FormLabel>
|
|
57
|
+
<div data-testid={dataTestId}>
|
|
58
|
+
<Autocomplete<WireTarget>
|
|
59
|
+
multiple
|
|
60
|
+
value={query}
|
|
61
|
+
onChange={setQuery}
|
|
62
|
+
suggestions={[...options]}
|
|
63
|
+
selectedItems={selected}
|
|
64
|
+
onSelectedItemsChange={(items) => onChange(items.map((item) => item.id))}
|
|
65
|
+
getKey={(option) => option.id}
|
|
66
|
+
getLabel={(option) => option.name}
|
|
67
|
+
placeholder={placeholder}
|
|
68
|
+
inputAriaLabel={label}
|
|
69
|
+
/>
|
|
70
|
+
</div>
|
|
71
|
+
{missing && (
|
|
72
|
+
<FormMessage error dataTestId={`${dataTestId}-message`}>
|
|
73
|
+
{requiredMessage}
|
|
74
|
+
</FormMessage>
|
|
75
|
+
)}
|
|
76
|
+
</FormControl>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* One collection's picker, for whoever is asking.
|
|
82
|
+
*
|
|
83
|
+
* Extracted because the COMBO builder needs the same control per GROUP, and a
|
|
84
|
+
* second copy is how a nesting collection ends up rendered as a flat combobox
|
|
85
|
+
* in one of the two places — the bug this seam exists to prevent. The caller
|
|
86
|
+
* supplies the label and the test id, because "Categorias com desconto" and
|
|
87
|
+
* "Categorias deste grupo" are different sentences about the same control.
|
|
88
|
+
*
|
|
89
|
+
* `requiredMessage` is OPTIONAL, and that is the difference between the two
|
|
90
|
+
* callers rather than an oversight. At CATEGORY/ITEM scope an empty picker IS
|
|
91
|
+
* the error, so the message hangs on it. Inside a combo group it is not: a
|
|
92
|
+
* group naming two categories and no items is complete, so the group as a whole
|
|
93
|
+
* carries the refusal and neither picker does.
|
|
94
|
+
*/
|
|
95
|
+
export function TargetPickerField({
|
|
96
|
+
group,
|
|
97
|
+
label,
|
|
98
|
+
placeholder,
|
|
99
|
+
requiredMessage,
|
|
100
|
+
ids,
|
|
101
|
+
onChange,
|
|
102
|
+
dataTestId,
|
|
103
|
+
}: {
|
|
104
|
+
group: WireTargetGroup;
|
|
105
|
+
label: string;
|
|
106
|
+
placeholder: string;
|
|
107
|
+
requiredMessage: string | undefined;
|
|
108
|
+
ids: readonly string[];
|
|
109
|
+
onChange: (next: string[]) => void;
|
|
110
|
+
dataTestId: string;
|
|
111
|
+
}): JSX.Element {
|
|
112
|
+
if (group.nests) {
|
|
113
|
+
return (
|
|
114
|
+
<CategorySelect
|
|
115
|
+
label={label}
|
|
116
|
+
fullWidth
|
|
117
|
+
allowParentSelection
|
|
118
|
+
options={[...group.targets]}
|
|
119
|
+
value={[...ids]}
|
|
120
|
+
onChange={onChange}
|
|
121
|
+
error={requiredMessage !== undefined && ids.length === 0 ? requiredMessage : undefined}
|
|
122
|
+
dataTestId={dataTestId}
|
|
123
|
+
/>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
return (
|
|
127
|
+
<TargetField
|
|
128
|
+
label={label}
|
|
129
|
+
placeholder={placeholder}
|
|
130
|
+
requiredMessage={requiredMessage}
|
|
131
|
+
options={group.targets}
|
|
132
|
+
ids={ids}
|
|
133
|
+
onChange={onChange}
|
|
134
|
+
dataTestId={dataTestId}
|
|
135
|
+
/>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|