@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 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 of fourteen inputs, four
345
- of which appear only when another says so; the target pickers; two card
346
- layouts; four confirmation popups; and every wire call between them.
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.0",
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.2.0",
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 isPercentage = input.type === "PERCENTAGE";
141
+ const isCombo = input.scope === "COMBO";
134
142
  return {
135
143
  name: input.name.trim(),
136
144
  type: input.type,
137
- percentOffBp: isPercentage ? toBasisPoints(input.percentOff, formatters) : null,
138
- amountOffCents: isPercentage ? null : toCents(input.amountOff, formatters),
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 { CardKebab, useCardActions, useRemoveConfirm } from "@12-apps/ui/data-display/CardKit";
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 = useRemoveConfirm({
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({ id: "edit", label: copy.actions.edit, onClick: () => setEditOpen(true) });
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
- /** Percentage or amount — whichever the chosen type actually stores. */
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
  }