@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
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/**"
|
|
@@ -41,10 +41,10 @@
|
|
|
41
41
|
"typecheck": "tsc --noEmit"
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"@12-apps/app-shell": "^5.
|
|
45
|
-
"@12-apps/forms-core": "^1.
|
|
46
|
-
"@12-apps/shared-helpers": "^2.
|
|
47
|
-
"@12-apps/ui": "^6.
|
|
44
|
+
"@12-apps/app-shell": "^5.14.1",
|
|
45
|
+
"@12-apps/forms-core": "^1.23.0",
|
|
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
|
},
|
|
@@ -80,7 +80,7 @@
|
|
|
80
80
|
"@12-apps/eslint-config": "^1.22.0",
|
|
81
81
|
"@12-apps/i18n": "^1.3.0",
|
|
82
82
|
"@12-apps/typescript-config": "^1.21.0",
|
|
83
|
-
"@12-apps/wiring": "^1.
|
|
83
|
+
"@12-apps/wiring": "^1.17.0",
|
|
84
84
|
"@electric-sql/pglite": "^0.2.17",
|
|
85
85
|
"@emotion/react": "^11.14.0",
|
|
86
86
|
"@emotion/styled": "^11.14.0",
|
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,107 @@
|
|
|
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 { Stack } from "@12-apps/ui/mui/Stack";
|
|
8
|
+
|
|
9
|
+
import { toMinutes } from "../engine/schedule";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A 24-hour `HH:MM` box that is NOT `<input type="time">` (FUT-4143).
|
|
13
|
+
*
|
|
14
|
+
* The native control renders itself in the BROWSER's language, not the page's:
|
|
15
|
+
* on an English machine "Das 16:00" reads "04:00 PM" to a Brazilian merchant on
|
|
16
|
+
* a Portuguese screen, and no attribute overrides it. The host's own hours
|
|
17
|
+
* screens dropped it for the same reason (FUT-872), and this is the same field:
|
|
18
|
+
* text, masked, with the phone's digit keypad.
|
|
19
|
+
*
|
|
20
|
+
* The mask inserts the colon as the operator types, so four digits make
|
|
21
|
+
* `09:00` and "0900123" can never exist — the same rule as the host's
|
|
22
|
+
* `maskTime`, restated because a package cannot import a host. A half-typed
|
|
23
|
+
* `09:` is a fine intermediate state and is not shouted at; a COMPLETE time no
|
|
24
|
+
* day has (25:00, 12:60) is marked on the spot, in the pack's words.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Keep four digits and put the colon after the second.
|
|
29
|
+
*
|
|
30
|
+
* Except while EDITING a time that already has its colon: select "09" in
|
|
31
|
+
* "09:00" and type "1" and the box holds "1:00", whose digits re-masked would
|
|
32
|
+
* read "10:0". So when the previous value had a separator and the new one
|
|
33
|
+
* still does, each side keeps its own digits — "1:00" stays, then "15:00".
|
|
34
|
+
* Any separator counts ("25/10" reads 25:10), and the result is still at most
|
|
35
|
+
* two digits a side.
|
|
36
|
+
*/
|
|
37
|
+
function maskTime(raw: string, previous: string): string {
|
|
38
|
+
const separator = /\D/u.exec(raw);
|
|
39
|
+
if (separator !== null && previous.includes(":")) {
|
|
40
|
+
const hours = raw.slice(0, separator.index).replace(/\D/gu, "").slice(0, 2);
|
|
41
|
+
const minutes = raw.slice(separator.index + 1).replace(/\D/gu, "").slice(0, 2);
|
|
42
|
+
return `${hours}:${minutes}`;
|
|
43
|
+
}
|
|
44
|
+
const digits = raw.replace(/\D/gu, "").slice(0, 4);
|
|
45
|
+
if (digits.length <= 2) return digits;
|
|
46
|
+
return `${digits.slice(0, 2)}:${digits.slice(2)}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A finished `HH:MM` that names no minute of a day. */
|
|
50
|
+
function isImpossibleTime(value: string): boolean {
|
|
51
|
+
return /^\d{2}:\d{2}$/u.test(value) && toMinutes(value) === null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function ClockInput({
|
|
55
|
+
value,
|
|
56
|
+
label,
|
|
57
|
+
invalidMessage,
|
|
58
|
+
incompleteMessage,
|
|
59
|
+
submitted = false,
|
|
60
|
+
dataTestId,
|
|
61
|
+
onChange,
|
|
62
|
+
}: {
|
|
63
|
+
value: string;
|
|
64
|
+
/** The accessible name — "Das" and "às" sit side by side. */
|
|
65
|
+
label: string;
|
|
66
|
+
/** Under a time that does not exist. */
|
|
67
|
+
invalidMessage: string;
|
|
68
|
+
/** Under a half-typed time once the box is left — never while typing. */
|
|
69
|
+
incompleteMessage: string;
|
|
70
|
+
/**
|
|
71
|
+
* The form refused this row's times at Criar: an unfinished clock says so
|
|
72
|
+
* now, left or not, and the row does not repeat it (FUT-4143).
|
|
73
|
+
*/
|
|
74
|
+
submitted?: boolean;
|
|
75
|
+
dataTestId: string;
|
|
76
|
+
onChange: (next: string) => void;
|
|
77
|
+
}): JSX.Element {
|
|
78
|
+
const [left, setLeft] = useState(false);
|
|
79
|
+
const impossible = isImpossibleTime(value);
|
|
80
|
+
const whole = /^\d{2}:\d{2}$/u.test(value);
|
|
81
|
+
const unfinished = !whole && ((left && value !== "") || submitted);
|
|
82
|
+
const message = impossible ? invalidMessage : unfinished ? incompleteMessage : undefined;
|
|
83
|
+
return (
|
|
84
|
+
<Stack spacing={0.5}>
|
|
85
|
+
<Input
|
|
86
|
+
type="text"
|
|
87
|
+
size="sm"
|
|
88
|
+
placeholder="00:00"
|
|
89
|
+
value={value}
|
|
90
|
+
error={message !== undefined}
|
|
91
|
+
aria-label={label}
|
|
92
|
+
inputProps={{ inputMode: "numeric", maxLength: 5, autoComplete: "off" }}
|
|
93
|
+
onBlur={() => setLeft(true)}
|
|
94
|
+
onChange={(event) => {
|
|
95
|
+
setLeft(false);
|
|
96
|
+
onChange(maskTime(event.target.value, value));
|
|
97
|
+
}}
|
|
98
|
+
data-testid={dataTestId}
|
|
99
|
+
/>
|
|
100
|
+
{message !== undefined && (
|
|
101
|
+
<FormMessage error dataTestId={`${dataTestId}-message`}>
|
|
102
|
+
{message}
|
|
103
|
+
</FormMessage>
|
|
104
|
+
)}
|
|
105
|
+
</Stack>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
@@ -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
|
+
}
|