@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.
- package/README.md +23 -7
- package/package.json +3 -3
- package/src/react/api.ts +6 -2
- 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/copy.ts +7 -4
- package/src/react/discount-actions-menu.tsx +20 -26
- package/src/react/discount-dialog.tsx +67 -0
- package/src/react/discount-form-fields.tsx +4 -40
- package/src/react/discount-form-validate.ts +3 -2
- package/src/react/discount-form-values.ts +1 -1
- package/src/react/discount-form.tsx +61 -52
- package/src/react/discount-target-picker.tsx +1 -1
- package/src/react/discounts-screen.tsx +11 -17
- package/src/react/en-US.ts +27 -0
- package/src/react/form-kind.ts +42 -48
- package/src/react/kind-field.tsx +8 -4
- package/src/react/pt-BR.ts +27 -0
- package/src/react/table-config.ts +15 -1
- package/src/server/collections.ts +20 -0
- package/src/server/index.ts +1 -0
package/README.md
CHANGED
|
@@ -356,7 +356,7 @@ of promotion is this — and derives the pair (`src/react/form-kind.ts`):
|
|
|
356
356
|
|---|---|---|---|
|
|
357
357
|
| Porcentagem | `PERCENTAGE` | ORDER / CATEGORY / ITEM | the rate, and what it covers |
|
|
358
358
|
| Valor fixo | `FIXED_AMOUNT` | ORDER / CATEGORY / ITEM | the amount, and what it covers |
|
|
359
|
-
| Combo | `PERCENTAGE` or `
|
|
359
|
+
| Combo | `PERCENTAGE`, `FIXED_AMOUNT` or `BUNDLE_PRICE` | `COMBO` | the groups, and which of the three rewards |
|
|
360
360
|
| Itens grátis | `FREE_UNITS` | `COMBO` | the products, how many, how many free |
|
|
361
361
|
|
|
362
362
|
So the offers a merchant describes map one to one onto what they fill in:
|
|
@@ -366,6 +366,7 @@ So the offers a merchant describes map one to one onto what they fill in:
|
|
|
366
366
|
| "leve 3, pague 2" | Itens grátis | 3 hambúrgueres, 1 free |
|
|
367
367
|
| "2 refrigerantes, 2 hambúrgueres e 2 batatas com 20% de desconto" | Combo | three groups of 2, and the rate |
|
|
368
368
|
| the same six items at R$ 5,00 off | Combo | three groups of 2, and the amount |
|
|
369
|
+
| "5 marmitas por R$ 120" | Combo | one group of 5 (a category mixes flavours), and the closed price |
|
|
369
370
|
|
|
370
371
|
Two consequences of asking the kind instead of the pair:
|
|
371
372
|
|
|
@@ -373,12 +374,27 @@ Two consequences of asking the kind instead of the pair:
|
|
|
373
374
|
made of, so the scope toggle offers the three that are genuinely a choice.
|
|
374
375
|
Offering the fourth is what let an operator build "preço de combo,
|
|
375
376
|
abrangência: pedido" and have the write path refuse it.
|
|
376
|
-
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
377
|
+
- **A closed price is a combo's third reward, "Preço fechado" (FUT-4417).**
|
|
378
|
+
`BUNDLE_PRICE` reprices the matched group whatever its items cost — which is
|
|
379
|
+
what "5 marmitas por R$ 120" asks for, so the form offers it beside the rate
|
|
380
|
+
and the amount, says so under the price, and previews it. It is a REWARD,
|
|
381
|
+
not a kind: a rule saved as `BUNDLE_PRICE` before FUT-4417 opens as a combo
|
|
382
|
+
with "Preço fechado" chosen, and the kind toggle is always the same four.
|
|
383
|
+
- **"Como fica no carrinho"** previews one combo under the chosen reward: what
|
|
384
|
+
its items sum to (each group filled with its cheapest product, then its
|
|
385
|
+
dearest) and what the buyer pays and saves, with the engine's own
|
|
386
|
+
arithmetic (`src/react/combo-preview.ts`, one application, before the
|
|
387
|
+
cart-level clamps). It needs the host's ITEM targets to carry
|
|
388
|
+
`priceRangeCents` (the product's own price and its variations') and
|
|
389
|
+
`categoryId` (only for products a buyer can add: listed, not paused); a
|
|
390
|
+
host that sends neither simply gets no preview. In a form at least
|
|
391
|
+
`PREVIEW_ASIDE_MIN_PX` wide (931 px: the narrowest form whose left column
|
|
392
|
+
still holds the four-part kind toggle) it sits in its own column beside
|
|
393
|
+
the form; narrower, it sits under the reward's field.
|
|
394
|
+
- **The create and edit forms open in `UsableModal`** (`size="wide"`): a
|
|
395
|
+
stacked modal on a computer, which at 1280 gives the form the room for
|
|
396
|
+
that column, and a bottom sheet on a phone. Every opening mounts a fresh
|
|
397
|
+
form.
|
|
382
398
|
|
|
383
399
|
Picking Combo swaps the single target picker for the **group builder**: each
|
|
384
400
|
group is a quantity plus the rows that can fill it, and a group may name
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12-apps/discounts",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.18.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": [
|
|
6
6
|
"**/e2e/**"
|
|
@@ -43,8 +43,8 @@
|
|
|
43
43
|
"dependencies": {
|
|
44
44
|
"@12-apps/app-shell": "^5.14.1",
|
|
45
45
|
"@12-apps/forms-core": "^1.23.0",
|
|
46
|
-
"@12-apps/shared-helpers": "^2.
|
|
47
|
-
"@12-apps/ui": "^6.
|
|
46
|
+
"@12-apps/shared-helpers": "^2.2.0",
|
|
47
|
+
"@12-apps/ui": "^6.95.1",
|
|
48
48
|
"@mui/icons-material": "^6.5.0",
|
|
49
49
|
"zod": "^4.3.5"
|
|
50
50
|
},
|
package/src/react/api.ts
CHANGED
|
@@ -66,6 +66,10 @@ export interface WireTarget {
|
|
|
66
66
|
id: string;
|
|
67
67
|
name: string;
|
|
68
68
|
parentId?: string | null;
|
|
69
|
+
/** An ITEM row's unit price range (FUT-4417), for the combo preview only. */
|
|
70
|
+
priceRangeCents?: { minCents: number; maxCents: number } | null;
|
|
71
|
+
/** An ITEM row's own category, for the same preview. */
|
|
72
|
+
categoryId?: string | null;
|
|
69
73
|
}
|
|
70
74
|
|
|
71
75
|
/** One registered collection and its rows. */
|
|
@@ -119,7 +123,7 @@ export interface DiscountFormPayload {
|
|
|
119
123
|
}
|
|
120
124
|
|
|
121
125
|
/** A money amount as integer cents, or null when blank. */
|
|
122
|
-
function toCents(typed: string, formatters: DiscountsFormatters): number | null {
|
|
126
|
+
export function toCents(typed: string, formatters: DiscountsFormatters): number | null {
|
|
123
127
|
const amount = formatters.parseDecimal(typed);
|
|
124
128
|
return amount === null ? null : Math.round(amount * 100);
|
|
125
129
|
}
|
|
@@ -138,7 +142,7 @@ function toCount(typed: string, formatters: DiscountsFormatters): number | null
|
|
|
138
142
|
* engine computes without a float. The `Math.round` after the ×100 is what
|
|
139
143
|
* keeps "12,5" exactly 1250 rather than 1249.9999999999998.
|
|
140
144
|
*/
|
|
141
|
-
function toBasisPoints(typed: string, formatters: DiscountsFormatters): number | null {
|
|
145
|
+
export function toBasisPoints(typed: string, formatters: DiscountsFormatters): number | null {
|
|
142
146
|
const percent = formatters.parseDecimal(typed);
|
|
143
147
|
return percent === null ? null : Math.round(percent * 100);
|
|
144
148
|
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext, useRef, type JSX, type ReactNode } from "react";
|
|
4
|
+
|
|
5
|
+
import { Alert } from "@12-apps/ui/data-display/Alert";
|
|
6
|
+
import { useFormContext } from "@12-apps/ui/form/total-form";
|
|
7
|
+
import { Box } from "@12-apps/ui/mui/Box";
|
|
8
|
+
import { Stack } from "@12-apps/ui/mui/Stack";
|
|
9
|
+
import { Text } from "@12-apps/ui/typography/Text";
|
|
10
|
+
|
|
11
|
+
import type { ComboRequirement } from "../engine/types";
|
|
12
|
+
|
|
13
|
+
import { toBasisPoints, toCents, type WireTargetGroup } from "./api";
|
|
14
|
+
import type { DiscountsWebCopy } from "./copy";
|
|
15
|
+
import { comboPreviewOf, type ComboPreview, type ComboRewardValue } from "./combo-preview";
|
|
16
|
+
import { comboPreviewLines, type ComboPreviewLayout } from "./combo-preview-lines";
|
|
17
|
+
import { comboRewardOf } from "./form-kind";
|
|
18
|
+
import type { DiscountsFormatters } from "./format";
|
|
19
|
+
import { KIND_TOGGLE_MIN_PX, useElementWidth } from "./kind-field";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* "Como fica no carrinho" on screen (FUT-4417), and where it sits.
|
|
23
|
+
*
|
|
24
|
+
* Under the reward's field on a phone or a narrow form; in its own column
|
|
25
|
+
* beside the form when the form has the room, as the approved prototype draws
|
|
26
|
+
* it on a computer. What decides is the FORM's own width, measured the way
|
|
27
|
+
* `KindField` measures it, because the form also lives in a modal narrower
|
|
28
|
+
* than the screen. In the wide stacked modal the screens open it in
|
|
29
|
+
* (`DiscountDialog`), a 1280 window gives the form well over the threshold;
|
|
30
|
+
* a tablet's or a phone's does not, and the preview sits under the field.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** The side column's share and gap: `minmax(0, 3fr) minmax(0, 2fr)`, `gap: 3`. */
|
|
34
|
+
const ASIDE_GAP_PX = 24;
|
|
35
|
+
const FORM_SHARE = 3 / 5;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The narrowest form that takes the side column: one whose left column still
|
|
39
|
+
* holds the kind question as a toggle (`KIND_TOGGLE_MIN_PX`). A column that
|
|
40
|
+
* squeezed it into a select would trade a control the operator already knows
|
|
41
|
+
* for a box of text — the preview has room only when nothing else pays for it.
|
|
42
|
+
*/
|
|
43
|
+
const PREVIEW_ASIDE_MIN_PX = Math.ceil(KIND_TOGGLE_MIN_PX / FORM_SHARE + ASIDE_GAP_PX);
|
|
44
|
+
|
|
45
|
+
/** What the preview reads that is not a form value. */
|
|
46
|
+
interface PreviewInputs {
|
|
47
|
+
copy: DiscountsWebCopy;
|
|
48
|
+
formatters: DiscountsFormatters;
|
|
49
|
+
groups: readonly WireTargetGroup[];
|
|
50
|
+
slots: readonly ComboRequirement[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The preview, worked out ONCE per render of the layout and handed down: the
|
|
55
|
+
* inline box under the reward's field reads it from here rather than summing
|
|
56
|
+
* the combo a second time.
|
|
57
|
+
*/
|
|
58
|
+
interface PreviewWorld extends PreviewInputs {
|
|
59
|
+
layout: ComboPreviewLayout;
|
|
60
|
+
reward: ComboRewardValue;
|
|
61
|
+
preview: ComboPreview | null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const PreviewContext = createContext<PreviewWorld | null>(null);
|
|
65
|
+
|
|
66
|
+
/** The reward the form currently describes, as numbers. */
|
|
67
|
+
function rewardFrom(
|
|
68
|
+
values: Record<string, string>,
|
|
69
|
+
formatters: DiscountsFormatters,
|
|
70
|
+
): ComboRewardValue {
|
|
71
|
+
return {
|
|
72
|
+
type: comboRewardOf({ type: values.comboReward ?? "" }),
|
|
73
|
+
percentOffBp: toBasisPoints(values.percentOff ?? "", formatters),
|
|
74
|
+
amountOffCents: toCents(values.amountOff ?? "", formatters),
|
|
75
|
+
bundlePriceCents: toCents(values.bundlePrice ?? "", formatters),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The preview the form currently describes, or null when there is nothing honest to sum. */
|
|
80
|
+
function useComboPreview(world: PreviewInputs): {
|
|
81
|
+
reward: ComboRewardValue;
|
|
82
|
+
preview: ComboPreview | null;
|
|
83
|
+
} {
|
|
84
|
+
const { values } = useFormContext();
|
|
85
|
+
const reward = rewardFrom(values, world.formatters);
|
|
86
|
+
const preview =
|
|
87
|
+
values.kind === "COMBO" ? comboPreviewOf(world.slots, world.groups, reward) : null;
|
|
88
|
+
return { reward, preview };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function PreviewBox({ world }: { world: PreviewWorld }): JSX.Element | null {
|
|
92
|
+
const { reward, preview } = world;
|
|
93
|
+
if (preview === null) return null;
|
|
94
|
+
const lines = comboPreviewLines(preview, reward, world.layout, world.copy, world.formatters);
|
|
95
|
+
const aside = world.layout === "aside";
|
|
96
|
+
return (
|
|
97
|
+
<Alert variant="info" showIcon={false} animate={false} data-testid="combo-cart-preview">
|
|
98
|
+
<Stack spacing={aside ? 1 : 0.5}>
|
|
99
|
+
{/* A small label over the figures, as the prototype draws it — not the
|
|
100
|
+
Alert's own title, which is larger than the lines it heads. */}
|
|
101
|
+
<Text as="p" variant="caption" weight="semibold" data-testid="combo-cart-preview-heading">
|
|
102
|
+
{world.copy.comboRewards.preview.title}
|
|
103
|
+
</Text>
|
|
104
|
+
<Text variant="body" size="sm" data-testid="combo-cart-preview-items">
|
|
105
|
+
{lines.items}
|
|
106
|
+
</Text>
|
|
107
|
+
{lines.pays !== null && (
|
|
108
|
+
<Text
|
|
109
|
+
variant={aside ? "heading" : "body"}
|
|
110
|
+
{...(aside ? {} : { size: "sm" as const })}
|
|
111
|
+
data-testid="combo-cart-preview-pays"
|
|
112
|
+
>
|
|
113
|
+
{lines.pays}
|
|
114
|
+
</Text>
|
|
115
|
+
)}
|
|
116
|
+
{lines.examples !== null && (
|
|
117
|
+
<Text variant="caption" data-testid="combo-cart-preview-examples">
|
|
118
|
+
{lines.examples}
|
|
119
|
+
</Text>
|
|
120
|
+
)}
|
|
121
|
+
</Stack>
|
|
122
|
+
</Alert>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The preview under the reward's field — rendered only when the form is narrow. */
|
|
127
|
+
export function InlineComboCartPreview(): JSX.Element | null {
|
|
128
|
+
const world = useContext(PreviewContext);
|
|
129
|
+
if (world === null || world.layout !== "inline") return null;
|
|
130
|
+
return <PreviewBox world={world} />;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The form's body, with the preview's column beside it when a combo is being
|
|
135
|
+
* built and the form is wide enough; otherwise the body alone, as it always
|
|
136
|
+
* was, and the reward field places the preview under itself.
|
|
137
|
+
*/
|
|
138
|
+
export function ComboPreviewLayout({
|
|
139
|
+
copy,
|
|
140
|
+
formatters,
|
|
141
|
+
groups,
|
|
142
|
+
slots,
|
|
143
|
+
children,
|
|
144
|
+
}: PreviewInputs & { children: ReactNode }): JSX.Element {
|
|
145
|
+
const box = useRef<HTMLDivElement>(null);
|
|
146
|
+
const width = useElementWidth(box);
|
|
147
|
+
// The column exists only while there is a preview to put in it: an empty
|
|
148
|
+
// column beside a combo with no priced group yet is empty space, and the form
|
|
149
|
+
// would be squeezed for nothing.
|
|
150
|
+
const { reward, preview } = useComboPreview({
|
|
151
|
+
copy,
|
|
152
|
+
formatters,
|
|
153
|
+
groups,
|
|
154
|
+
slots,
|
|
155
|
+
});
|
|
156
|
+
const layout: ComboPreviewLayout =
|
|
157
|
+
preview !== null && width !== null && width >= PREVIEW_ASIDE_MIN_PX ? "aside" : "inline";
|
|
158
|
+
const world: PreviewWorld = {
|
|
159
|
+
layout,
|
|
160
|
+
copy,
|
|
161
|
+
formatters,
|
|
162
|
+
groups,
|
|
163
|
+
slots,
|
|
164
|
+
reward,
|
|
165
|
+
preview,
|
|
166
|
+
};
|
|
167
|
+
return (
|
|
168
|
+
<PreviewContext.Provider value={world}>
|
|
169
|
+
<Box
|
|
170
|
+
ref={box}
|
|
171
|
+
data-testid="combo-preview-layout"
|
|
172
|
+
data-layout={layout}
|
|
173
|
+
sx={
|
|
174
|
+
layout === "aside"
|
|
175
|
+
? {
|
|
176
|
+
display: "grid",
|
|
177
|
+
gridTemplateColumns: "minmax(0, 3fr) minmax(0, 2fr)",
|
|
178
|
+
gap: 3,
|
|
179
|
+
alignItems: "start",
|
|
180
|
+
}
|
|
181
|
+
: { minWidth: 0 }
|
|
182
|
+
}
|
|
183
|
+
>
|
|
184
|
+
<Box sx={{ minWidth: 0 }}>{children}</Box>
|
|
185
|
+
{layout === "aside" && <PreviewBox world={world} />}
|
|
186
|
+
</Box>
|
|
187
|
+
</PreviewContext.Provider>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { fill, type DiscountsWebCopy } from "./copy";
|
|
2
|
+
import type { ComboPreview, ComboRewardValue } from "./combo-preview";
|
|
3
|
+
import type { DiscountsFormatters } from "./format";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The preview's sentences (FUT-4417), from the figures `comboPreviewOf` worked
|
|
7
|
+
* out. Pure, so every branch below is a unit test rather than a screenshot.
|
|
8
|
+
*
|
|
9
|
+
* Two layouts say the same thing in two shapes, as the approved prototype
|
|
10
|
+
* draws them:
|
|
11
|
+
*
|
|
12
|
+
* - **inline** — under the reward's field, on a phone or a narrow form: two
|
|
13
|
+
* lines, the second carrying the whole range ("Cliente paga de R$ 121,05 a
|
|
14
|
+
* R$ 143,55 (10% a menos)");
|
|
15
|
+
* - **aside** — its own column beside a wide form: the second line short and
|
|
16
|
+
* emphasised ("Cliente paga 10% a menos"), and a third line of examples at
|
|
17
|
+
* both ends of a one-group combo ("Exemplos: 5 de Frango → R$ 121,05 · …").
|
|
18
|
+
*
|
|
19
|
+
* The first line is ONE wording for every reward ("5 itens somam de … a …").
|
|
20
|
+
* The prototype's phone frame said "Itens: de … a …" under the two discounts
|
|
21
|
+
* and "5 marmitas somam…" under the closed price; the data has no noun like
|
|
22
|
+
* "marmitas" for a group, so the pack's own counted noun stands in for both.
|
|
23
|
+
*/
|
|
24
|
+
export type ComboPreviewLayout = "inline" | "aside";
|
|
25
|
+
|
|
26
|
+
interface ComboPreviewLines {
|
|
27
|
+
items: string;
|
|
28
|
+
/** What the buyer pays; null while the reward's value is not typed. */
|
|
29
|
+
pays: string | null;
|
|
30
|
+
/** The aside's example line; null inline, for several groups, or when both ends cost the same. */
|
|
31
|
+
examples: string | null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface Say {
|
|
35
|
+
copy: DiscountsWebCopy;
|
|
36
|
+
money: (cents: number) => string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function itemsLine(preview: ComboPreview, say: Say): string {
|
|
40
|
+
const { combo, comboRewards } = say.copy;
|
|
41
|
+
const units = fill(preview.units === 1 ? combo.unitsOne : combo.unitsOther, {
|
|
42
|
+
count: preview.units,
|
|
43
|
+
});
|
|
44
|
+
const { cheapest, dearest } = preview;
|
|
45
|
+
return cheapest.groupCents === dearest.groupCents
|
|
46
|
+
? fill(comboRewards.preview.itemsExact, {
|
|
47
|
+
units,
|
|
48
|
+
sum: say.money(cheapest.groupCents),
|
|
49
|
+
})
|
|
50
|
+
: fill(comboRewards.preview.itemsRange, {
|
|
51
|
+
units,
|
|
52
|
+
min: say.money(cheapest.groupCents),
|
|
53
|
+
max: say.money(dearest.groupCents),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** "10%" or "R$ 15,00": what a discount reward takes off, as the operator typed it. */
|
|
58
|
+
function offOf(reward: ComboRewardValue, formatters: DiscountsFormatters): string {
|
|
59
|
+
return reward.type === "PERCENTAGE"
|
|
60
|
+
? formatters.percent(reward.percentOffBp)
|
|
61
|
+
: formatters.money(reward.amountOffCents);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The two discounts: a range of what is paid, or the reward itself in the aside. */
|
|
65
|
+
function discountPays(
|
|
66
|
+
preview: ComboPreview,
|
|
67
|
+
reward: ComboRewardValue,
|
|
68
|
+
layout: ComboPreviewLayout,
|
|
69
|
+
say: Say,
|
|
70
|
+
off: string,
|
|
71
|
+
): string {
|
|
72
|
+
const words = say.copy.comboRewards.preview;
|
|
73
|
+
if (layout === "aside") return fill(words.paysOff, { off });
|
|
74
|
+
const { cheapest, dearest } = preview;
|
|
75
|
+
const rate = reward.type === "PERCENTAGE";
|
|
76
|
+
if (cheapest.paysCents === dearest.paysCents) {
|
|
77
|
+
const pays = say.money(cheapest.paysCents);
|
|
78
|
+
return rate ? fill(words.paysExactOff, { pays, off }) : fill(words.paysExact, { pays });
|
|
79
|
+
}
|
|
80
|
+
const range = {
|
|
81
|
+
min: say.money(cheapest.paysCents),
|
|
82
|
+
max: say.money(dearest.paysCents),
|
|
83
|
+
};
|
|
84
|
+
return rate ? fill(words.paysRangeOff, { ...range, off }) : fill(words.paysRange, range);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The closed price, in the three places it can sit against the items' range. */
|
|
88
|
+
function pricePays(
|
|
89
|
+
preview: ComboPreview,
|
|
90
|
+
price: number,
|
|
91
|
+
layout: ComboPreviewLayout,
|
|
92
|
+
say: Say,
|
|
93
|
+
): string {
|
|
94
|
+
const words = say.copy.comboRewards.preview;
|
|
95
|
+
const { cheapest, dearest } = preview;
|
|
96
|
+
const priced = say.money(price);
|
|
97
|
+
// At or above the dearest sum the engine charges the items (`max(0, …)`).
|
|
98
|
+
if (dearest.savingCents === 0) return words.noSaving;
|
|
99
|
+
if (cheapest.savingCents > 0 || cheapest.groupCents === price) {
|
|
100
|
+
if (layout === "aside") return fill(words.paysExact, { pays: priced });
|
|
101
|
+
if (cheapest.savingCents === 0) {
|
|
102
|
+
return fill(words.paysPriceSavingUpTo, {
|
|
103
|
+
price: priced,
|
|
104
|
+
max: say.money(dearest.savingCents),
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
return cheapest.savingCents === dearest.savingCents
|
|
108
|
+
? fill(words.paysPriceSavingExact, {
|
|
109
|
+
price: priced,
|
|
110
|
+
saving: say.money(dearest.savingCents),
|
|
111
|
+
})
|
|
112
|
+
: fill(words.paysPriceSaving, {
|
|
113
|
+
price: priced,
|
|
114
|
+
min: say.money(cheapest.savingCents),
|
|
115
|
+
max: say.money(dearest.savingCents),
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
// Between the two: the cheapest combo costs its items, the dearest the price.
|
|
119
|
+
const min = say.money(cheapest.paysCents);
|
|
120
|
+
return layout === "aside"
|
|
121
|
+
? fill(words.paysRange, { min, max: priced })
|
|
122
|
+
: fill(words.paysUpToPrice, {
|
|
123
|
+
min,
|
|
124
|
+
price: priced,
|
|
125
|
+
max: say.money(dearest.savingCents),
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function examplesLine(preview: ComboPreview, reward: ComboRewardValue, say: Say): string | null {
|
|
130
|
+
const { single, cheapest, dearest } = preview;
|
|
131
|
+
if (single === null || cheapest.groupCents === dearest.groupCents) return null;
|
|
132
|
+
const words = say.copy.comboRewards.preview;
|
|
133
|
+
const example = (name: string, end: typeof cheapest): string => {
|
|
134
|
+
const values = {
|
|
135
|
+
quantity: single.quantity,
|
|
136
|
+
name,
|
|
137
|
+
pays: say.money(end.paysCents),
|
|
138
|
+
};
|
|
139
|
+
return reward.type === "BUNDLE_PRICE" && end.savingCents > 0
|
|
140
|
+
? fill(words.exampleSaving, {
|
|
141
|
+
...values,
|
|
142
|
+
saving: say.money(end.savingCents),
|
|
143
|
+
})
|
|
144
|
+
: fill(words.example, values);
|
|
145
|
+
};
|
|
146
|
+
return fill(words.examples, {
|
|
147
|
+
cheapest: example(single.cheapestName, cheapest),
|
|
148
|
+
dearest: example(single.dearestName, dearest),
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function comboPreviewLines(
|
|
153
|
+
preview: ComboPreview,
|
|
154
|
+
reward: ComboRewardValue,
|
|
155
|
+
layout: ComboPreviewLayout,
|
|
156
|
+
copy: DiscountsWebCopy,
|
|
157
|
+
formatters: DiscountsFormatters,
|
|
158
|
+
): ComboPreviewLines {
|
|
159
|
+
const say: Say = { copy, money: (cents) => formatters.money(cents) };
|
|
160
|
+
const items = itemsLine(preview, say);
|
|
161
|
+
if (!preview.rewardTyped) return { items, pays: null, examples: null };
|
|
162
|
+
const pays =
|
|
163
|
+
reward.type === "BUNDLE_PRICE"
|
|
164
|
+
? pricePays(preview, reward.bundlePriceCents ?? 0, layout, say)
|
|
165
|
+
: discountPays(preview, reward, layout, say, offOf(reward, formatters));
|
|
166
|
+
return {
|
|
167
|
+
items,
|
|
168
|
+
pays,
|
|
169
|
+
examples: layout === "aside" ? examplesLine(preview, reward, say) : null,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { comboSlotAcceptsLine, freshComboPool, matchCombo } from "../engine/combo-match";
|
|
2
|
+
import type { ComboRequirement, DiscountCartLine, DiscountRule } from "../engine/types";
|
|
3
|
+
import { buildTargetPath } from "../server/collections";
|
|
4
|
+
|
|
5
|
+
import type { WireTarget, WireTargetGroup } from "./api";
|
|
6
|
+
import type { ComboReward } from "./form-kind";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* "Como fica no carrinho" — what one combo's items sum to and what the buyer
|
|
10
|
+
* pays for them, worked out from the store's own prices while the operator is
|
|
11
|
+
* still typing (FUT-4417).
|
|
12
|
+
*
|
|
13
|
+
* The figures are the ENGINE's, not a second derivation of them: each end of
|
|
14
|
+
* the range is a small synthetic cart — every group filled with its cheapest
|
|
15
|
+
* product, then with its dearest — handed to `matchCombo`, so the rounding of a
|
|
16
|
+
* percentage, the cap of a fixed amount at the group's total and the closed
|
|
17
|
+
* price's "never more than the items" (`applicationReward`,
|
|
18
|
+
* `engine/combo-match.ts`) are the ones the cart will apply.
|
|
19
|
+
*
|
|
20
|
+
* It is ONE application, before the cart-level clamps (R4's remainders and
|
|
21
|
+
* R9's payable floor, `ComboMatch.rewardCents`): a preview of the offer, not
|
|
22
|
+
* of a particular cart.
|
|
23
|
+
*
|
|
24
|
+
* Prices come from the targets endpoint's ITEM rows (`priceRangeCents`, its own
|
|
25
|
+
* price and its variations'); a group that can take no priced product makes
|
|
26
|
+
* the whole preview `null`, because a sum with a missing term is not a figure
|
|
27
|
+
* worth showing.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** The reward as numbers; a value left blank or invalid is `null`. */
|
|
31
|
+
export interface ComboRewardValue {
|
|
32
|
+
type: ComboReward;
|
|
33
|
+
percentOffBp: number | null;
|
|
34
|
+
amountOffCents: number | null;
|
|
35
|
+
bundlePriceCents: number | null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** One end of the range: the combo filled with the cheapest (or dearest) products. */
|
|
39
|
+
export interface ComboPreviewEnd {
|
|
40
|
+
/** What the matched items cost before the reward. */
|
|
41
|
+
groupCents: number;
|
|
42
|
+
/** What the reward takes off them. 0 when the reward's value is not typed yet. */
|
|
43
|
+
savingCents: number;
|
|
44
|
+
/** What the buyer pays for this combo. */
|
|
45
|
+
paysCents: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface ComboPreview {
|
|
49
|
+
/** How many units one combo takes. */
|
|
50
|
+
units: number;
|
|
51
|
+
cheapest: ComboPreviewEnd;
|
|
52
|
+
dearest: ComboPreviewEnd;
|
|
53
|
+
/** Whether the reward has a usable value; without one only the items sum means anything. */
|
|
54
|
+
rewardTyped: boolean;
|
|
55
|
+
/**
|
|
56
|
+
* For a ONE-group combo: the quantity and the product names at each end
|
|
57
|
+
* (as the catalog stores them), for the example line. `null` for several groups.
|
|
58
|
+
*/
|
|
59
|
+
single: {
|
|
60
|
+
quantity: number;
|
|
61
|
+
cheapestName: string;
|
|
62
|
+
dearestName: string;
|
|
63
|
+
} | null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface PricedItem {
|
|
67
|
+
target: WireTarget;
|
|
68
|
+
line: DiscountCartLine;
|
|
69
|
+
minCents: number;
|
|
70
|
+
maxCents: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Every priced ITEM row as the cart line the matcher would see for it. */
|
|
74
|
+
function pricedItems(groups: readonly WireTargetGroup[]): PricedItem[] {
|
|
75
|
+
const parents = new Map<string, string | null>(
|
|
76
|
+
groups
|
|
77
|
+
.filter((group) => group.targetType === "CATEGORY")
|
|
78
|
+
.flatMap((group) => group.targets)
|
|
79
|
+
.map((target) => [target.id, target.parentId ?? null] as const),
|
|
80
|
+
);
|
|
81
|
+
return groups
|
|
82
|
+
.filter((group) => group.targetType === "ITEM")
|
|
83
|
+
.flatMap((group) => group.targets)
|
|
84
|
+
.flatMap((target) => {
|
|
85
|
+
const range = target.priceRangeCents;
|
|
86
|
+
if (!range || range.minCents < 0 || range.maxCents < range.minCents) return [];
|
|
87
|
+
const line: DiscountCartLine = {
|
|
88
|
+
lineId: target.id,
|
|
89
|
+
menuItemId: target.id,
|
|
90
|
+
variationMenuItemId: null,
|
|
91
|
+
categoryPath: buildTargetPath(target.categoryId ?? null, parents),
|
|
92
|
+
quantity: 1,
|
|
93
|
+
unitPriceCents: range.minCents,
|
|
94
|
+
};
|
|
95
|
+
return [{ target, line, minCents: range.minCents, maxCents: range.maxCents }];
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The rule the synthetic carts are matched against: this combo, applied once. */
|
|
100
|
+
function previewRule(slots: readonly ComboRequirement[], reward: ComboRewardValue): DiscountRule {
|
|
101
|
+
const typed = rewardIsTyped(reward);
|
|
102
|
+
return {
|
|
103
|
+
id: "preview",
|
|
104
|
+
name: "preview",
|
|
105
|
+
// An untyped reward is matched as a zero percentage, which takes nothing
|
|
106
|
+
// off and still sums the group.
|
|
107
|
+
type: typed ? reward.type : "PERCENTAGE",
|
|
108
|
+
percentOffBp: typed ? reward.percentOffBp : 0,
|
|
109
|
+
amountOffCents: reward.amountOffCents,
|
|
110
|
+
bundlePriceCents: reward.bundlePriceCents,
|
|
111
|
+
scope: "COMBO",
|
|
112
|
+
targetCategoryIds: [],
|
|
113
|
+
targetMenuItemIds: [],
|
|
114
|
+
comboRequirements: slots,
|
|
115
|
+
maxComboApplications: 1,
|
|
116
|
+
trigger: "AUTOMATIC",
|
|
117
|
+
code: null,
|
|
118
|
+
active: true,
|
|
119
|
+
startsAt: null,
|
|
120
|
+
endsAt: null,
|
|
121
|
+
minSubtotalCents: null,
|
|
122
|
+
usageLimit: null,
|
|
123
|
+
usageCount: 0,
|
|
124
|
+
perBuyerLimit: null,
|
|
125
|
+
buyerUsageCount: 0,
|
|
126
|
+
stackable: true,
|
|
127
|
+
createdAt: new Date(0),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function rewardIsTyped(reward: ComboRewardValue): boolean {
|
|
132
|
+
if (reward.type === "PERCENTAGE") return reward.percentOffBp !== null && reward.percentOffBp > 0;
|
|
133
|
+
if (reward.type === "FIXED_AMOUNT")
|
|
134
|
+
return reward.amountOffCents !== null && reward.amountOffCents > 0;
|
|
135
|
+
return reward.bundlePriceCents !== null && reward.bundlePriceCents > 0;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** One end: each group filled by `pick`'s product, priced at `price`, then matched. */
|
|
139
|
+
function endOf(
|
|
140
|
+
slots: readonly ComboRequirement[],
|
|
141
|
+
picks: readonly PricedItem[],
|
|
142
|
+
price: (item: PricedItem) => number,
|
|
143
|
+
rule: DiscountRule,
|
|
144
|
+
): ComboPreviewEnd | null {
|
|
145
|
+
const lines = picks.map((item, index) => ({
|
|
146
|
+
...item.line,
|
|
147
|
+
lineId: `${index}:${item.target.id}`,
|
|
148
|
+
quantity: slots[index]?.quantity ?? 0,
|
|
149
|
+
unitPriceCents: price(item),
|
|
150
|
+
}));
|
|
151
|
+
const match = matchCombo(rule, freshComboPool(lines));
|
|
152
|
+
if (match === null) return null;
|
|
153
|
+
return {
|
|
154
|
+
groupCents: match.groupCents,
|
|
155
|
+
savingCents: match.rewardCents,
|
|
156
|
+
paysCents: match.groupCents - match.rewardCents,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The cheapest and the dearest product one group can take, or null when it can take none. */
|
|
161
|
+
function extremesFor(
|
|
162
|
+
slot: ComboRequirement,
|
|
163
|
+
items: readonly PricedItem[],
|
|
164
|
+
): { cheapest: PricedItem; dearest: PricedItem } | null {
|
|
165
|
+
// The matcher's own predicate, asked one group at a time.
|
|
166
|
+
const fits = items.filter((item) => comboSlotAcceptsLine(slot, item.line));
|
|
167
|
+
if (fits.length === 0) return null;
|
|
168
|
+
const cheapest = fits.reduce((best, item) => (item.minCents < best.minCents ? item : best));
|
|
169
|
+
const dearest = fits.reduce((best, item) => (item.maxCents > best.maxCents ? item : best));
|
|
170
|
+
return { cheapest, dearest };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function comboPreviewOf(
|
|
174
|
+
slots: readonly ComboRequirement[],
|
|
175
|
+
groups: readonly WireTargetGroup[],
|
|
176
|
+
reward: ComboRewardValue,
|
|
177
|
+
): ComboPreview | null {
|
|
178
|
+
if (
|
|
179
|
+
slots.length === 0 ||
|
|
180
|
+
slots.some((slot) => !Number.isInteger(slot.quantity) || slot.quantity <= 0)
|
|
181
|
+
) {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
const items = pricedItems(groups);
|
|
185
|
+
const extremes = slots.map((slot) => extremesFor(slot, items));
|
|
186
|
+
if (extremes.some((pair) => pair === null)) return null;
|
|
187
|
+
const pairs = extremes as { cheapest: PricedItem; dearest: PricedItem }[];
|
|
188
|
+
const rule = previewRule(slots, reward);
|
|
189
|
+
const cheapest = endOf(
|
|
190
|
+
slots,
|
|
191
|
+
pairs.map((pair) => pair.cheapest),
|
|
192
|
+
(item) => item.minCents,
|
|
193
|
+
rule,
|
|
194
|
+
);
|
|
195
|
+
const dearest = endOf(
|
|
196
|
+
slots,
|
|
197
|
+
pairs.map((pair) => pair.dearest),
|
|
198
|
+
(item) => item.maxCents,
|
|
199
|
+
rule,
|
|
200
|
+
);
|
|
201
|
+
if (cheapest === null || dearest === null) return null;
|
|
202
|
+
const [only] = pairs;
|
|
203
|
+
return {
|
|
204
|
+
units: slots.reduce((total, slot) => total + slot.quantity, 0),
|
|
205
|
+
cheapest,
|
|
206
|
+
dearest,
|
|
207
|
+
rewardTyped: rewardIsTyped(reward),
|
|
208
|
+
single:
|
|
209
|
+
slots.length === 1 && only
|
|
210
|
+
? {
|
|
211
|
+
quantity: slots[0]?.quantity ?? 0,
|
|
212
|
+
cheapestName: only.cheapest.target.name,
|
|
213
|
+
dearestName: only.dearest.target.name,
|
|
214
|
+
}
|
|
215
|
+
: null,
|
|
216
|
+
};
|
|
217
|
+
}
|