@12-apps/discounts 1.17.0 → 1.18.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.
@@ -18,7 +18,6 @@ import {
18
18
  import type { RowAction } from "@12-apps/ui/data-display/DataViews";
19
19
  import { ErrorState } from "@12-apps/ui/data-display/ErrorState";
20
20
  import { LoadingState } from "@12-apps/ui/data-display/LoadingState";
21
- import { Dialog, DialogContent } from "@12-apps/ui/feedback/Dialog";
22
21
  import { HeaderButton } from "@12-apps/ui/form/HeaderButton";
23
22
  import { Dashboard } from "@12-apps/ui/layout/Dashboard";
24
23
  import { Text } from "@12-apps/ui/typography/Text";
@@ -28,6 +27,7 @@ import type { DiscountsApiClient } from "./api";
28
27
  import { fill, type DiscountsWebCopy } from "./copy";
29
28
  import { DiscountActionsMenu } from "./discount-actions-menu";
30
29
  import { DiscountCard } from "./discount-card";
30
+ import { DiscountDialog } from "./discount-dialog";
31
31
  import { DiscountForm } from "./discount-form";
32
32
  import type { CurrencyFieldComponent } from "./discount-form-fields";
33
33
  import { DiscountListCard, discountCells } from "./discount-list-card";
@@ -214,7 +214,7 @@ function DiscountsGrid({
214
214
  );
215
215
  }
216
216
 
217
- /** The create dialog, mounted only while open so the next open re-seeds empty. */
217
+ /** The create dialog; each opening mounts a fresh form, so it re-seeds empty. */
218
218
  function CreateDialog({
219
219
  open,
220
220
  onClose,
@@ -227,25 +227,19 @@ function CreateDialog({
227
227
  menu: MenuBinding;
228
228
  }): JSX.Element {
229
229
  return (
230
- <Dialog
230
+ <DiscountDialog
231
231
  open={open}
232
232
  onClose={onClose}
233
233
  title={menu.copy.form.createTitle}
234
- size="md"
235
- showCloseButton
236
- dataTestId="discount-dialog"
234
+ copy={menu.copy}
237
235
  >
238
- {open && (
239
- <DialogContent>
240
- <DiscountForm
241
- {...menu}
242
- groups={menu.groups ?? []}
243
- editing={null}
244
- onSaved={onSaved}
245
- />
246
- </DialogContent>
247
- )}
248
- </Dialog>
236
+ <DiscountForm
237
+ {...menu}
238
+ groups={menu.groups ?? []}
239
+ editing={null}
240
+ onSaved={onSaved}
241
+ />
242
+ </DiscountDialog>
249
243
  );
250
244
  }
251
245
 
@@ -139,6 +139,7 @@ export const EN_US_DISCOUNTS_WEB_COPY: DiscountsWebCopy = {
139
139
  form: {
140
140
  createTitle: "New discount",
141
141
  editTitle: "Edit discount",
142
+ dialog: { close: EN_US_UI_LABELS.common.close, back: "Back" },
142
143
  submitCreate: "Create discount",
143
144
  submitEdit: "Save changes",
144
145
  name: "Promotion name",
@@ -201,6 +202,32 @@ export const EN_US_DISCOUNTS_WEB_COPY: DiscountsWebCopy = {
201
202
  wholeNumberOnly: "Whole numbers only, no decimals.",
202
203
  endsBeforeStarts: "The end date must be after the start date.",
203
204
  },
205
+ comboRewards: {
206
+ labels: { PERCENTAGE: "Percentage", FIXED_AMOUNT: "Fixed amount", BUNDLE_PRICE: "Fixed price" },
207
+ hints: {
208
+ PERCENTAGE: "The bundle sells for the price of its items minus this discount.",
209
+ FIXED_AMOUNT: "The bundle sells for the price of its items minus this amount.",
210
+ BUNDLE_PRICE: "The bundle sells for this price, whatever its items cost.",
211
+ },
212
+ preview: {
213
+ title: "In the cart",
214
+ itemsRange: "{units} add up to {min}–{max}",
215
+ itemsExact: "{units} add up to {sum}",
216
+ paysRange: "The customer pays {min}–{max}",
217
+ paysRangeOff: "The customer pays {min}–{max} ({off} off)",
218
+ paysExact: "The customer pays {pays}",
219
+ paysExactOff: "The customer pays {pays} ({off} off)",
220
+ paysOff: "The customer pays {off} less",
221
+ paysPriceSaving: "The customer pays {price} — saves {min}–{max}",
222
+ paysPriceSavingExact: "The customer pays {price} — saves {saving}",
223
+ paysPriceSavingUpTo: "The customer pays {price} — saves up to {max}",
224
+ paysUpToPrice: "The customer pays {min}–{price} — saves up to {max}",
225
+ noSaving: "The customer pays the items' price: the bundle price is not lower than theirs.",
226
+ examples: "Examples: {cheapest} · {dearest}",
227
+ example: "{quantity} × {name} → {pays}",
228
+ exampleSaving: "{quantity} × {name} → {pays} (saves {saving})",
229
+ },
230
+ },
204
231
  combo: {
205
232
  title: "Build the bundle",
206
233
  hint: "Each group is one part of the bundle: how many units it takes, and what can go in it.",
@@ -20,18 +20,20 @@ import { DISCOUNT_SCOPES, type DiscountScope, type DiscountType } from "../engin
20
20
  * |---|---|---|---|
21
21
  * | `PERCENTAGE` | `PERCENTAGE` | ORDER / CATEGORY / ITEM | the rate, and what it covers |
22
22
  * | `FIXED_AMOUNT` | `FIXED_AMOUNT` | ORDER / CATEGORY / ITEM | the amount, and what it covers |
23
- * | `COMBO` | `PERCENTAGE` or `FIXED_AMOUNT` | `COMBO` | the groups, and which of the two rewards |
23
+ * | `COMBO` | `PERCENTAGE`, `FIXED_AMOUNT` or `BUNDLE_PRICE` | `COMBO` | the groups, and which of the three rewards |
24
24
  * | `FREE_UNITS` | `FREE_UNITS` | `COMBO` | the products, how many, how many free |
25
25
  *
26
26
  * Two consequences worth stating, because both are deliberate:
27
27
  *
28
- * **A combo is a DISCOUNT off its items, never a price.** "2 refrigerantes, 2
29
- * hambúrgueres e 2 batatas com 15% de desconto" and "…menos R$ 5,00" are the
30
- * two offers a merchant describes; a flat "por R$ 49,90" reprices the group and
31
- * silently goes wrong the moment one of its items changes price. `BUNDLE_PRICE`
32
- * is still a legal type in the engine and in the database — an older rule that
33
- * uses it keeps working and keeps being editable, see {@link kindOptions} — it
34
- * is simply not offered for a new promotion.
28
+ * **A combo's reward is a discount off its items OR a closed price**
29
+ * (FUT-4417). "2 refrigerantes, 2 hambúrgueres e 2 batatas com 15% de
30
+ * desconto" and "…menos R$ 5,00" are two offers a merchant describes; "5
31
+ * marmitas por R$ 120" is the third, and the one the form used to refuse. A
32
+ * closed price reprices the matched group whatever its items cost — which is
33
+ * exactly what the merchant asked for, and why the form says so under the price
34
+ * field and previews it against the store's real prices ("Como fica no
35
+ * carrinho"). `BUNDLE_PRICE` is therefore a combo REWARD, not a kind: a rule
36
+ * saved with it before FUT-4417 opens as a combo with "Preço fechado" chosen.
35
37
  *
36
38
  * **`COMBO` scope is not a choice any more.** It was never one: a combo is
37
39
  * defined by its groups, so "combo scope, but with an order-wide discount" is
@@ -39,15 +41,14 @@ import { DISCOUNT_SCOPES, type DiscountScope, type DiscountType } from "../engin
39
41
  * genuinely a choice, and the two combo kinds carry `COMBO` implicitly.
40
42
  */
41
43
 
42
- /** The kinds offered for a NEW promotion, in the order the toggle shows them. */
44
+ /** The kinds the form offers, in the order the toggle shows them. */
43
45
  export const DISCOUNT_KINDS = ["PERCENTAGE", "FIXED_AMOUNT", "COMBO", "FREE_UNITS"] as const;
44
46
 
45
47
  /**
46
- * Every kind the form can HOLD, which is one more than it offers: a rule saved
47
- * as `BUNDLE_PRICE` before combos became a discount still has to open, edit and
48
- * save without losing what it is.
48
+ * Every kind the form can hold. `BUNDLE_PRICE` is not one any more (FUT-4417):
49
+ * it is the third reward of a `COMBO`, see {@link COMBO_REWARDS}.
49
50
  */
50
- export type DiscountKind = (typeof DISCOUNT_KINDS)[number] | "BUNDLE_PRICE";
51
+ export type DiscountKind = (typeof DISCOUNT_KINDS)[number];
51
52
 
52
53
  /**
53
54
  * True when this kind's targets are groups — a builder, not a flat picker, and
@@ -58,7 +59,7 @@ export type DiscountKind = (typeof DISCOUNT_KINDS)[number] | "BUNDLE_PRICE";
58
59
  * second `includes` that drifts from this one.
59
60
  */
60
61
  export function isComboKind(kind: string): boolean {
61
- return kind === "COMBO" || kind === "FREE_UNITS" || kind === "BUNDLE_PRICE";
62
+ return kind === "COMBO" || kind === "FREE_UNITS";
62
63
  }
63
64
 
64
65
  /** The scopes the toggle offers: every scope except the one a kind implies. */
@@ -70,39 +71,30 @@ export const SELECTABLE_DISCOUNT_SCOPES: readonly DiscountScope[] = DISCOUNT_SCO
70
71
  * Which kind an existing rule is, read back from the pair it was stored as.
71
72
  *
72
73
  * `FREE_UNITS` is checked before the scope because it is the narrower fact: it
73
- * is only ever legal at `COMBO` scope, so the type alone identifies it.
74
+ * is only ever legal at `COMBO` scope, so the type alone identifies it. A
75
+ * `BUNDLE_PRICE` rule is a combo (FUT-4417) — whatever scope it carries, since
76
+ * the type is only legal at `COMBO`.
74
77
  */
75
78
  export function kindOf(rule: { type: string; scope: string } | null): DiscountKind {
76
79
  if (rule === null) return "PERCENTAGE";
77
- if (rule.type === "BUNDLE_PRICE") return "BUNDLE_PRICE";
78
80
  if (rule.type === "FREE_UNITS") return "FREE_UNITS";
79
- if (rule.scope === "COMBO") return "COMBO";
81
+ if (rule.type === "BUNDLE_PRICE" || rule.scope === "COMBO") return "COMBO";
80
82
  return rule.type === "FIXED_AMOUNT" ? "FIXED_AMOUNT" : "PERCENTAGE";
81
83
  }
82
84
 
83
85
  /**
84
- * The kinds this particular form may show — the four, plus `BUNDLE_PRICE` when
85
- * that is what is being edited.
86
- *
87
- * Appended rather than inserted so the four keep the positions an operator has
88
- * learned, and present ONLY on the legacy rule's own form: an operator editing
89
- * a percentage promotion is not offered a bundle price they cannot create.
90
- */
91
- export function kindOptions(editing: { type: string; scope: string } | null): DiscountKind[] {
92
- const kind = kindOf(editing);
93
- return kind === "BUNDLE_PRICE" ? [...DISCOUNT_KINDS, kind] : [...DISCOUNT_KINDS];
94
- }
95
-
96
- /**
97
- * A combo's reward is one of the two plain types, chosen separately from the
98
- * kind — "15% off the combo" and "R$ 5,00 off the combo" are the same kind of
99
- * promotion with different arithmetic.
86
+ * A combo's reward, chosen separately from the kind — "15% off the combo",
87
+ * "R$ 5,00 off the combo" and "the combo for R$ 120" (FUT-4417) are the same
88
+ * kind of promotion with different arithmetic. In the order the toggle shows
89
+ * them.
100
90
  */
101
- export const COMBO_REWARDS = ["PERCENTAGE", "FIXED_AMOUNT"] as const;
91
+ export const COMBO_REWARDS = ["PERCENTAGE", "FIXED_AMOUNT", "BUNDLE_PRICE"] as const;
92
+ export type ComboReward = (typeof COMBO_REWARDS)[number];
102
93
 
103
- /** Which reward an existing combo was saved with. */
104
- export function comboRewardOf(rule: { type: string } | null): string {
105
- return rule?.type === "FIXED_AMOUNT" ? "FIXED_AMOUNT" : "PERCENTAGE";
94
+ /** Which reward an existing combo was saved with; a new one starts on the percentage. */
95
+ export function comboRewardOf(rule: { type: string } | null): ComboReward {
96
+ if (rule?.type === "FIXED_AMOUNT" || rule?.type === "BUNDLE_PRICE") return rule.type;
97
+ return "PERCENTAGE";
106
98
  }
107
99
 
108
100
  /**
@@ -114,17 +106,19 @@ export function comboRewardOf(rule: { type: string } | null): string {
114
106
  * rather than trusted: the toggle is unmounted at combo kinds, so its value is
115
107
  * whatever it was before the operator changed their mind.
116
108
  */
117
- export function typeAndScopeFor(values: {
118
- kind: string;
119
- comboReward: string;
120
- scope: string;
121
- }): { type: DiscountType; scope: DiscountScope } {
122
- if (values.kind === "COMBO") {
123
- const type = values.comboReward === "FIXED_AMOUNT" ? "FIXED_AMOUNT" : "PERCENTAGE";
124
- return { type, scope: "COMBO" };
125
- }
109
+ export function typeAndScopeFor(values: { kind: string; comboReward: string; scope: string }): {
110
+ type: DiscountType;
111
+ scope: DiscountScope;
112
+ } {
113
+ if (values.kind === "COMBO")
114
+ return {
115
+ type: comboRewardOf({ type: values.comboReward }),
116
+ scope: "COMBO",
117
+ };
126
118
  if (values.kind === "FREE_UNITS") return { type: "FREE_UNITS", scope: "COMBO" };
127
- if (values.kind === "BUNDLE_PRICE") return { type: "BUNDLE_PRICE", scope: "COMBO" };
128
119
  const type = values.kind === "FIXED_AMOUNT" ? "FIXED_AMOUNT" : "PERCENTAGE";
129
- return { type, scope: values.scope === "COMBO" ? "ORDER" : (values.scope as DiscountScope) };
120
+ return {
121
+ type,
122
+ scope: values.scope === "COMBO" ? "ORDER" : (values.scope as DiscountScope),
123
+ };
130
124
  }
@@ -7,14 +7,15 @@ import { Box } from "@12-apps/ui/mui/Box";
7
7
 
8
8
  import type { DiscountsWebCopy } from "./copy";
9
9
  import { optionsFor } from "./discount-form-fields";
10
- import type { DiscountKind } from "./form-kind";
10
+ import { DISCOUNT_KINDS, type DiscountKind } from "./form-kind";
11
11
 
12
12
  /**
13
13
  * What kind of promotion this is — the form's first and most consequential
14
14
  * question, and the only one that decides what the rest of it looks like.
15
15
  *
16
- * The option list comes from the caller rather than from the constant, because
17
- * it is one longer when a legacy `BUNDLE_PRICE` rule is being edited.
16
+ * The option list comes from the caller rather than from the constant. It used
17
+ * to be one longer on a legacy `BUNDLE_PRICE` rule's form; since FUT-4417 that
18
+ * rule is a combo with the "Preço fechado" reward, so it is always the four.
18
19
  *
19
20
  * **One line, at every width** (FUT-4157, ADR 0190 in the host). Four equal
20
21
  * parts need about 530 px before "Porcentagem" and "Itens grátis" wrap inside
@@ -28,8 +29,11 @@ import type { DiscountKind } from "./form-kind";
28
29
  /** The widest pt-BR kind label at the toggle's type size (132 px measured), plus its padding. */
29
30
  const PART_MIN_PX = 136;
30
31
 
32
+ /** How wide the form must be for the four kinds to be a toggle rather than a select. */
33
+ export const KIND_TOGGLE_MIN_PX = DISCOUNT_KINDS.length * PART_MIN_PX;
34
+
31
35
  /** The element's own width as it changes; `null` until measured, or where nothing can measure. */
32
- function useElementWidth(ref: RefObject<HTMLElement | null>): number | null {
36
+ export function useElementWidth(ref: RefObject<HTMLElement | null>): number | null {
33
37
  const [width, setWidth] = useState<number | null>(null);
34
38
  useLayoutEffect(() => {
35
39
  const element = ref.current;
@@ -130,6 +130,7 @@ export const PT_BR_DISCOUNTS_WEB_COPY: DiscountsWebCopy = {
130
130
  form: {
131
131
  createTitle: "Novo desconto",
132
132
  editTitle: "Editar desconto",
133
+ dialog: { close: PT_BR_UI_LABELS.common.close, back: "Voltar" },
133
134
  submitCreate: "Criar desconto",
134
135
  submitEdit: "Salvar alterações",
135
136
  name: "Nome da promoção",
@@ -191,6 +192,32 @@ export const PT_BR_DISCOUNTS_WEB_COPY: DiscountsWebCopy = {
191
192
  wholeNumberOnly: "Use só números inteiros, sem vírgula.",
192
193
  endsBeforeStarts: "A data de término deve ser posterior à data de início.",
193
194
  },
195
+ comboRewards: {
196
+ labels: { PERCENTAGE: "Porcentagem", FIXED_AMOUNT: "Valor fixo", BUNDLE_PRICE: "Preço fechado" },
197
+ hints: {
198
+ PERCENTAGE: "O combo sai pelo preço dos itens menos este desconto.",
199
+ FIXED_AMOUNT: "O combo sai pelo preço dos itens menos este valor.",
200
+ BUNDLE_PRICE: "O combo sai por este preço, qualquer que seja o preço dos itens.",
201
+ },
202
+ preview: {
203
+ title: "Como fica no carrinho",
204
+ itemsRange: "{units} somam de {min} a {max}",
205
+ itemsExact: "{units} somam {sum}",
206
+ paysRange: "Cliente paga de {min} a {max}",
207
+ paysRangeOff: "Cliente paga de {min} a {max} ({off} a menos)",
208
+ paysExact: "Cliente paga {pays}",
209
+ paysExactOff: "Cliente paga {pays} ({off} a menos)",
210
+ paysOff: "Cliente paga {off} a menos",
211
+ paysPriceSaving: "Cliente paga {price} — economiza de {min} a {max}",
212
+ paysPriceSavingExact: "Cliente paga {price} — economiza {saving}",
213
+ paysPriceSavingUpTo: "Cliente paga {price} — economiza até {max}",
214
+ paysUpToPrice: "Cliente paga de {min} a {price} — economiza até {max}",
215
+ noSaving: "Cliente paga o preço dos itens: o preço do combo não é menor que eles.",
216
+ examples: "Exemplos: {cheapest} · {dearest}",
217
+ example: "{quantity} de {name} → {pays}",
218
+ exampleSaving: "{quantity} de {name} → {pays} (economiza {saving})",
219
+ },
220
+ },
194
221
  combo: {
195
222
  title: "Monte o combo",
196
223
  hint: "Cada grupo é uma parte do combo: quantas unidades leva e o que pode entrar nela.",
@@ -54,9 +54,23 @@ function labelled(labels: Readonly<Record<string, string>>, value: string): stri
54
54
  return labels[value] ?? value;
55
55
  }
56
56
 
57
+ /** The Nome column's floor, in design px (the grid converts it through the type scale). */
58
+ const NAME_COLUMN_MIN_PX = 200;
59
+
57
60
  export function discountColumns(copy: DiscountsWebCopy): DataViewColumn<DiscountListItem>[] {
58
61
  return [
59
- { id: "name", header: copy.screen.columns.name, accessor: "name", searchable: true },
62
+ {
63
+ id: "name",
64
+ header: copy.screen.columns.name,
65
+ accessor: "name",
66
+ searchable: true,
67
+ // The column a row is read by: nine columns share the width, and with no
68
+ // floor the grid squeezed it until "Frete grátis na segunda" stood on
69
+ // three lines with "na" alone on one (ADR 0074 U1, FUT-4417). The floor
70
+ // holds a typical promotion name on one or two lines; the table scrolls
71
+ // sideways inside its own wrapper on a phone, as it already did.
72
+ minWidth: NAME_COLUMN_MIN_PX,
73
+ },
60
74
  // Pre-formatted by the page: a cell must not do arithmetic on basis points.
61
75
  { id: "value", header: copy.screen.columns.value, accessor: (row) => row.valueLabel },
62
76
  {
@@ -40,6 +40,26 @@ export interface DiscountTarget {
40
40
  * category path is built from the same edges. Absent or null at the root.
41
41
  */
42
42
  parentId?: string | null;
43
+ /**
44
+ * What a buyer can pay for one unit of this row, cheapest to dearest — its own
45
+ * price and its variations' (FUT-4417). Only an ITEM row carries it, and only
46
+ * so the combo form can say what a group's items sum to ("Como fica no
47
+ * carrinho"); nothing prices a cart from it. Absent: the form shows no
48
+ * preview for a group that can take this row alone.
49
+ */
50
+ priceRangeCents?: DiscountTargetPriceRange | null;
51
+ /**
52
+ * The category an ITEM row is filed under, for the same preview: a group that
53
+ * names a category takes every product filed under it or under its
54
+ * subcategories. Absent or null: uncategorized.
55
+ */
56
+ categoryId?: string | null;
57
+ }
58
+
59
+ /** The cheapest and dearest unit price of one target, in integer cents. */
60
+ export interface DiscountTargetPriceRange {
61
+ minCents: number;
62
+ maxCents: number;
43
63
  }
44
64
 
45
65
  /**
@@ -28,6 +28,7 @@ export {
28
28
  type DiscountableCollection,
29
29
  type DiscountableOps,
30
30
  type DiscountTarget,
31
+ type DiscountTargetPriceRange,
31
32
  type DiscountTargetGroup,
32
33
  } from "./collections";
33
34