@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.
- package/README.md +23 -7
- package/package.json +6 -6
- package/src/react/api.ts +6 -2
- package/src/react/clock-input.tsx +107 -0
- package/src/react/combo-cart-preview.tsx +189 -0
- package/src/react/combo-preview-lines.ts +171 -0
- package/src/react/combo-preview.ts +217 -0
- package/src/react/combo-reward-copy.ts +59 -0
- package/src/react/combo-reward-fields.tsx +162 -0
- package/src/react/combo-slot-builder.tsx +73 -27
- package/src/react/copy.ts +44 -6
- package/src/react/date-field.tsx +141 -0
- package/src/react/discount-actions-menu.tsx +20 -26
- package/src/react/discount-dialog.tsx +67 -0
- package/src/react/discount-form-fields.tsx +47 -84
- package/src/react/discount-form-validate.ts +78 -17
- package/src/react/discount-form-values.ts +1 -1
- package/src/react/discount-form.tsx +68 -57
- package/src/react/discount-target-picker.tsx +1 -1
- package/src/react/discounts-screen.tsx +11 -17
- package/src/react/en-US.ts +43 -1
- package/src/react/form-kind.ts +42 -48
- package/src/react/free-units-builder.tsx +20 -13
- package/src/react/kind-field.tsx +71 -0
- package/src/react/number-fields.tsx +142 -0
- package/src/react/pt-BR.ts +44 -2
- package/src/react/schedule-builder.tsx +181 -37
- package/src/react/schedule-copy.ts +4 -0
- package/src/react/table-config.ts +15 -1
- package/src/server/collections.ts +20 -0
- package/src/server/index.ts +1 -0
|
@@ -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
|
-
<
|
|
88
|
-
type="number"
|
|
92
|
+
<CountInput
|
|
89
93
|
label={copy.combo.buyQuantity}
|
|
90
|
-
value={
|
|
91
|
-
|
|
92
|
-
|
|
94
|
+
value={quantity}
|
|
95
|
+
copy={copy}
|
|
96
|
+
invalidMessage={copy.form.invalidComboQuantity}
|
|
97
|
+
dataTestId="free-units-quantity"
|
|
98
|
+
onChange={onQuantity}
|
|
93
99
|
/>
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
+
}
|
package/src/react/pt-BR.ts
CHANGED
|
@@ -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: "
|
|
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}
|
|
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\".",
|