@12-apps/discounts 1.16.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.
@@ -3,7 +3,6 @@
3
3
  import type { JSX } from "react";
4
4
 
5
5
  import { FormControl, FormLabel, FormMessage } from "@12-apps/ui/form/Form";
6
- import { Input } from "@12-apps/ui/form/Input";
7
6
  import { Box } from "@12-apps/ui/mui/Box";
8
7
  import { Stack } from "@12-apps/ui/mui/Stack";
9
8
  import { Text } from "@12-apps/ui/typography/Text";
@@ -13,6 +12,7 @@ import type { ComboRequirement } from "../engine/types";
13
12
  import type { WireTargetGroup } from "./api";
14
13
  import { blankSlot } from "./combo-slot-builder";
15
14
  import { fill, type DiscountsWebCopy } from "./copy";
15
+ import { CountInput, WholeNumberInput } from "./number-fields";
16
16
  import { TargetPickerField } from "./target-picker-field";
17
17
 
18
18
  /**
@@ -41,6 +41,11 @@ function onlySlot(slots: readonly ComboRequirement[]): ComboRequirement {
41
41
  return slots[0] ?? blankSlot();
42
42
  }
43
43
 
44
+ /** Typed with something besides digits: marked as it is typed, like the other counts. */
45
+ function notDigits(typed: string): boolean {
46
+ return typed.trim() !== "" && !/^\d+$/u.test(typed.trim());
47
+ }
48
+
44
49
  /** How many the customer pays for: what they take, minus what is free. */
45
50
  function paidUnits(taken: number, free: number): number {
46
51
  return Math.max(taken - free, 0);
@@ -84,20 +89,22 @@ function FreeUnitsCounts({
84
89
  }): JSX.Element {
85
90
  return (
86
91
  <Box sx={{ display: "grid", gap: 2, gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" } }}>
87
- <Input
88
- type="number"
92
+ <CountInput
89
93
  label={copy.combo.buyQuantity}
90
- value={String(quantity)}
91
- onChange={(event) => onQuantity(Number(event.target.value))}
92
- data-testid="free-units-quantity"
94
+ value={quantity}
95
+ copy={copy}
96
+ invalidMessage={copy.form.invalidComboQuantity}
97
+ dataTestId="free-units-quantity"
98
+ onChange={onQuantity}
93
99
  />
94
- <Input
95
- type="number"
100
+ {/* Held as the form's string, so "2,5" stays as typed and the validator
101
+ refuses it with `invalidFreeUnits` below the builder. */}
102
+ <WholeNumberInput
96
103
  label={copy.form.freeUnits}
97
104
  value={freeUnits}
98
- onChange={(event) => onFreeUnits(event.target.value)}
99
- error={error !== undefined}
100
- data-testid="free-units-free"
105
+ error={error !== undefined || notDigits(freeUnits)}
106
+ dataTestId="free-units-free"
107
+ onChange={onFreeUnits}
101
108
  />
102
109
  </Box>
103
110
  );
@@ -160,9 +167,9 @@ export function FreeUnitsBuilder({
160
167
  autocompleteCopy={copy.autocomplete}
161
168
  dataTestId="free-units-items"
162
169
  />
163
- {error !== undefined && (
170
+ {(error !== undefined || notDigits(freeUnits)) && (
164
171
  <FormMessage error dataTestId="free-units-free-message">
165
- {error}
172
+ {error ?? copy.form.wholeNumberOnly}
166
173
  </FormMessage>
167
174
  )}
168
175
  {empty ? (
@@ -0,0 +1,71 @@
1
+ "use client";
2
+
3
+ import { useLayoutEffect, useRef, useState, type JSX, type RefObject } from "react";
4
+
5
+ import { Fields } from "@12-apps/ui/form/total-form";
6
+ import { Box } from "@12-apps/ui/mui/Box";
7
+
8
+ import type { DiscountsWebCopy } from "./copy";
9
+ import { optionsFor } from "./discount-form-fields";
10
+ import { DISCOUNT_KINDS, type DiscountKind } from "./form-kind";
11
+
12
+ /**
13
+ * What kind of promotion this is — the form's first and most consequential
14
+ * question, and the only one that decides what the rest of it looks like.
15
+ *
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.
19
+ *
20
+ * **One line, at every width** (FUT-4157, ADR 0190 in the host). Four equal
21
+ * parts need about 530 px before "Porcentagem" and "Itens grátis" wrap inside
22
+ * their buttons; at 320 and 390 the toggle wrapped every label AND pushed the
23
+ * page to 422 px. Where the FORM is narrower than its parts need, the same
24
+ * question is asked with a select: same field, same options in the same order,
25
+ * same refusal painted under it. What is measured is the form's own width, not
26
+ * the window's — the form also lives in a dialog narrower than the screen.
27
+ */
28
+
29
+ /** The widest pt-BR kind label at the toggle's type size (132 px measured), plus its padding. */
30
+ const PART_MIN_PX = 136;
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
+
35
+ /** The element's own width as it changes; `null` until measured, or where nothing can measure. */
36
+ export function useElementWidth(ref: RefObject<HTMLElement | null>): number | null {
37
+ const [width, setWidth] = useState<number | null>(null);
38
+ useLayoutEffect(() => {
39
+ const element = ref.current;
40
+ if (element === null || typeof ResizeObserver === "undefined") return undefined;
41
+ const observer = new ResizeObserver((entries) => {
42
+ const measured = entries[0]?.contentRect.width;
43
+ if (measured !== undefined && measured > 0) setWidth(measured);
44
+ });
45
+ observer.observe(element);
46
+ return () => observer.disconnect();
47
+ }, [ref]);
48
+ return width;
49
+ }
50
+
51
+ export function KindField({
52
+ copy,
53
+ kinds,
54
+ }: {
55
+ copy: DiscountsWebCopy;
56
+ kinds: readonly DiscountKind[];
57
+ }): JSX.Element {
58
+ const box = useRef<HTMLDivElement>(null);
59
+ const width = useElementWidth(box);
60
+ const options = optionsFor(kinds, copy.labels.kind);
61
+ const fits = width === null || width >= kinds.length * PART_MIN_PX;
62
+ return (
63
+ <Box ref={box} sx={{ minWidth: 0 }}>
64
+ {fits ? (
65
+ <Fields.ToggleField name="kind" label={copy.form.kind} options={options} />
66
+ ) : (
67
+ <Fields.SelectField name="kind" label={copy.form.kind} options={options} />
68
+ )}
69
+ </Box>
70
+ );
71
+ }
@@ -0,0 +1,142 @@
1
+ "use client";
2
+
3
+ import { useState, type JSX } from "react";
4
+
5
+ import { FormMessage } from "@12-apps/ui/form/Form";
6
+ import { Input } from "@12-apps/ui/form/Input";
7
+ import { Fields, type TextFieldInputProps } from "@12-apps/ui/form/total-form";
8
+ import { Stack } from "@12-apps/ui/mui/Stack";
9
+
10
+ import type { DiscountsWebCopy } from "./copy";
11
+
12
+ /**
13
+ * The form's number boxes, and the keypad each one asks the phone for
14
+ * (FUT-4143).
15
+ *
16
+ * None of them is `type="number"`. That control reads "2,5" according to the
17
+ * BROWSER's language — as 2.5, as nothing, or (in the survey's English
18
+ * Chromium) as 25 — and a promotion limited to 25 uses because the operator
19
+ * typed a comma is a silent, expensive mistake. Text boxes keep what was typed;
20
+ * the validator, which knows the pack's language, decides what it means.
21
+ */
22
+
23
+ /** A percentage takes a separator: "12,5". */
24
+ const DECIMAL_KEYPAD: TextFieldInputProps = { inputMode: "decimal" };
25
+
26
+ /** A count of uses or units takes digits and nothing else. */
27
+ const WHOLE_NUMBER_KEYPAD: TextFieldInputProps = { inputMode: "numeric", pattern: "[0-9]*" };
28
+
29
+ /** Digits only, as typed. */
30
+ function isWholeNumber(typed: string): boolean {
31
+ return /^\d+$/u.test(typed.trim());
32
+ }
33
+
34
+ /** "Desconto (%)", wherever the kind mounts it. */
35
+ export function PercentField({ copy }: { copy: DiscountsWebCopy }): JSX.Element {
36
+ return (
37
+ <Fields.TextField
38
+ name="percentOff"
39
+ label={copy.form.percentOff}
40
+ placeholder={copy.form.percentPlaceholder}
41
+ inputProps={DECIMAL_KEYPAD}
42
+ />
43
+ );
44
+ }
45
+
46
+ /** A form-bound count: "Limite de usos", "Limite por cliente", "Combos por pedido". */
47
+ export function WholeNumberField({ name, label }: { name: string; label: string }): JSX.Element {
48
+ return <Fields.TextField name={name} label={label} inputProps={WHOLE_NUMBER_KEYPAD} />;
49
+ }
50
+
51
+ /** The raw-`Input` twin of {@link WholeNumberField}, for a count held as text by its caller. */
52
+ export function WholeNumberInput({
53
+ label,
54
+ value,
55
+ error,
56
+ dataTestId,
57
+ onChange,
58
+ }: {
59
+ label: string;
60
+ value: string;
61
+ error: boolean;
62
+ dataTestId: string;
63
+ onChange: (next: string) => void;
64
+ }): JSX.Element {
65
+ return (
66
+ <Input
67
+ label={label}
68
+ value={value}
69
+ error={error}
70
+ inputProps={WHOLE_NUMBER_KEYPAD}
71
+ onChange={(event) => onChange(event.target.value)}
72
+ data-testid={dataTestId}
73
+ />
74
+ );
75
+ }
76
+
77
+ /**
78
+ * What a count box shows for the number its caller holds.
79
+ *
80
+ * The STATE decides; the draft is only how it is spelled. A draft that reads
81
+ * as the held number is shown as typed. A draft that is not a number at all
82
+ * ("2,5", "") is shown while the held value is "not a number" too. Anything
83
+ * else is stale — a draft left from another row, a value changed from outside
84
+ * — and the state wins: the number, or an empty box for `NaN`.
85
+ */
86
+ function countShown(typed: string, value: number): string {
87
+ const parsed = isWholeNumber(typed) ? Number(typed.trim()) : Number.NaN;
88
+ if (Number.isNaN(value)) return Number.isNaN(parsed) ? typed : "";
89
+ return parsed === value ? typed : String(value);
90
+ }
91
+
92
+ /**
93
+ * A count the caller holds as a NUMBER — a combo group's quantity, the units
94
+ * "leve 3, pague 2" takes.
95
+ *
96
+ * It keeps what was typed so "2,5" stays on screen and is refused there, in
97
+ * the pack's words, instead of becoming `NaN` in the box. What the caller gets
98
+ * is the number, or `NaN` while the text is not one — which its own validator
99
+ * refuses at submit, so nothing typed with a comma is ever sent. The box is
100
+ * red whenever the held value is not a count from 1, so it never looks fine
101
+ * beside a summary that says otherwise.
102
+ */
103
+ export function CountInput({
104
+ label,
105
+ value,
106
+ copy,
107
+ invalidMessage,
108
+ dataTestId,
109
+ onChange,
110
+ }: {
111
+ label: string;
112
+ value: number;
113
+ copy: DiscountsWebCopy;
114
+ /** Under an empty box or a zero — the caller's own refusal. */
115
+ invalidMessage: string;
116
+ dataTestId: string;
117
+ onChange: (next: number) => void;
118
+ }): JSX.Element {
119
+ const [typed, setTyped] = useState<string>(() => (Number.isFinite(value) ? String(value) : ""));
120
+ const shown = countShown(typed, value);
121
+ const notDigits = shown.trim() !== "" && !isWholeNumber(shown);
122
+ const refused = !Number.isInteger(value) || value <= 0;
123
+ return (
124
+ <Stack spacing={0.5}>
125
+ <WholeNumberInput
126
+ label={label}
127
+ value={shown}
128
+ error={refused}
129
+ dataTestId={dataTestId}
130
+ onChange={(next) => {
131
+ setTyped(next);
132
+ onChange(isWholeNumber(next) ? Number(next.trim()) : Number.NaN);
133
+ }}
134
+ />
135
+ {refused && (
136
+ <FormMessage error dataTestId={`${dataTestId}-message`}>
137
+ {notDigits ? copy.form.wholeNumberOnly : invalidMessage}
138
+ </FormMessage>
139
+ )}
140
+ </Stack>
141
+ );
142
+ }
@@ -3,6 +3,7 @@ import {
3
3
  PT_BR_CATEGORY_SELECT_COPY,
4
4
  PT_BR_CONFIRM_ACTION_COPY,
5
5
  PT_BR_DATA_VIEWS_COPY,
6
+ PT_BR_UI_LABELS,
6
7
  } from "@12-apps/ui/pt-BR";
7
8
 
8
9
  import type { DiscountsWebCopy } from "./copy";
@@ -94,7 +95,7 @@ export const PT_BR_DISCOUNTS_WEB_COPY: DiscountsWebCopy = {
94
95
  toLabel: "às",
95
96
  presetEveryDay: "Todos os dias",
96
97
  presetWeekdays: "Seg a Sex",
97
- presetWeekend: "Fim de semana",
98
+ presetWeekend: "Sáb e Dom",
98
99
  dayShort: ["Seg", "Ter", "Qua", "Qui", "Sex", "Sáb", "Dom"],
99
100
  dayLong: ["segunda", "terça", "quarta", "quinta", "sexta", "sábado", "domingo"],
100
101
  dayEvery: [
@@ -122,11 +123,14 @@ export const PT_BR_DISCOUNTS_WEB_COPY: DiscountsWebCopy = {
122
123
  activeNow: "Ativa agora",
123
124
  daysRequired: "Escolha ao menos um dia.",
124
125
  timesRequired: "Informe um horário de início e de fim diferentes.",
126
+ invalidTime: "Horário inexistente. Use de 00:00 a 23:59.",
127
+ timeIncomplete: "Termine o horário: HH:MM.",
125
128
  windowsRequired: "Adicione ao menos um horário.",
126
129
  },
127
130
  form: {
128
131
  createTitle: "Novo desconto",
129
132
  editTitle: "Editar desconto",
133
+ dialog: { close: PT_BR_UI_LABELS.common.close, back: "Voltar" },
130
134
  submitCreate: "Criar desconto",
131
135
  submitEdit: "Salvar alterações",
132
136
  name: "Nome da promoção",
@@ -162,8 +166,13 @@ export const PT_BR_DISCOUNTS_WEB_COPY: DiscountsWebCopy = {
162
166
  firstOrderOnlyHint:
163
167
  "Vale só para quem nunca comprou aqui. O preço do cardápio não muda: o desconto aparece no carrinho de quem pode usar.",
164
168
  reviewFields: "Revise os campos destacados.",
169
+ errorSnackbar: PT_BR_UI_LABELS.formErrorSnackbar.message,
165
170
  saveFailed: "Não foi possível salvar o desconto",
166
171
  nameRequired: "Informe o nome da promoção.",
172
+ nameTooShort: "O nome precisa ter pelo menos 2 letras.",
173
+ datePlaceholder: "DD/MM/AAAA",
174
+ invalidDate: "Data inexistente. Use DD/MM/AAAA, como 25/12/2026.",
175
+ dateIncomplete: "Termine a data: DD/MM/AAAA.",
167
176
  invalidPercent: "Informe uma porcentagem maior que 0 e no máximo 100.",
168
177
  invalidAmount: "Informe um valor de desconto maior que zero.",
169
178
  invalidBundlePrice: "Informe o preço do combo, maior que zero.",
@@ -178,8 +187,37 @@ export const PT_BR_DISCOUNTS_WEB_COPY: DiscountsWebCopy = {
178
187
  comboSlotTargetRequired: "Escolha o que pode entrar em cada grupo do combo.",
179
188
  invalidComboQuantity: "Informe quantas unidades cada grupo leva, a partir de 1.",
180
189
  invalidMaxComboApplications: "Informe quantos combos cabem por pedido, a partir de 1.",
190
+ invalidUsageLimit: "Informe um número inteiro de usos, a partir de 1, ou deixe em branco.",
191
+ invalidPerBuyerLimit: "Informe um número inteiro por cliente, a partir de 1, ou deixe em branco.",
192
+ wholeNumberOnly: "Use só números inteiros, sem vírgula.",
181
193
  endsBeforeStarts: "A data de término deve ser posterior à data de início.",
182
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
+ },
183
221
  combo: {
184
222
  title: "Monte o combo",
185
223
  hint: "Cada grupo é uma parte do combo: quantas unidades leva e o que pode entrar nela.",
@@ -189,7 +227,11 @@ export const PT_BR_DISCOUNTS_WEB_COPY: DiscountsWebCopy = {
189
227
  quantity: "Quantidade",
190
228
  pick: "{collection} deste grupo",
191
229
  empty: "Nenhum grupo ainda. Adicione o primeiro para montar o combo.",
192
- summary: "O combo leva {units} itens em {groups} grupos.",
230
+ summary: "O combo leva {units} em {groups}.",
231
+ unitsOne: "{count} item",
232
+ unitsOther: "{count} itens",
233
+ groupsOne: "{count} grupo",
234
+ groupsOther: "{count} grupos",
193
235
  freeUnitsTitle: "Leve mais, pague menos",
194
236
  freeUnitsHint:
195
237
  "Escolha os produtos, quantos o cliente leva e quantos saem de graça — é o clássico \"leve 3, pague 2\".",