@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.
@@ -0,0 +1,59 @@
1
+ import type { ComboReward } from "./form-kind";
2
+
3
+ /**
4
+ * The combo reward's words (FUT-4417): the three choices, the sentence under
5
+ * each, and "Como fica no carrinho" — what the group's items sum to and what
6
+ * the buyer pays for them under this reward.
7
+ *
8
+ * A group of its own rather than more `form` keys because the preview is a
9
+ * small grammar of its own: which sentence is said depends on the reward, on
10
+ * whether the items' prices are one figure or a range, and on whether the form
11
+ * is wide enough to put the preview in its own column (where it reads in short
12
+ * sentences plus an example line) or under the field (one sentence carrying
13
+ * the range). Each placeholder is a figure the package has already formatted.
14
+ */
15
+ export interface DiscountsComboPreviewCopy {
16
+ /** The box's heading. */
17
+ readonly title: string;
18
+ /** `{units}` ("5 itens"), `{min}`, `{max}`: what one combo's items sum to. */
19
+ readonly itemsRange: string;
20
+ /** The same when every item the groups can take costs the same. `{units}`, `{sum}`. */
21
+ readonly itemsExact: string;
22
+ /** `{min}`, `{max}`: what the buyer pays, as a range. */
23
+ readonly paysRange: string;
24
+ /** `{min}`, `{max}`, `{off}` (the rate, "10%"): the percentage's range, under the field. */
25
+ readonly paysRangeOff: string;
26
+ /** `{pays}`: one figure. */
27
+ readonly paysExact: string;
28
+ /** `{pays}`, `{off}`: one figure, at a rate. */
29
+ readonly paysExactOff: string;
30
+ /** `{off}` — "10%" or "R$ 15,00": the reward itself, in the wide layout. */
31
+ readonly paysOff: string;
32
+ /** Closed price under every items sum: `{price}`, `{min}`, `{max}` (the saving's range). */
33
+ readonly paysPriceSaving: string;
34
+ /** The same with one saving figure: `{price}`, `{saving}`. */
35
+ readonly paysPriceSavingExact: string;
36
+ /**
37
+ * Closed price EQUAL to the cheapest sum: the cheapest combo saves nothing,
38
+ * so the saving reads "up to" rather than "from R$ 0,00 to": `{price}`, `{max}`.
39
+ */
40
+ readonly paysPriceSavingUpTo: string;
41
+ /** Closed price between the cheapest and dearest sums: `{min}`, `{price}`, `{max}` (most saved). */
42
+ readonly paysUpToPrice: string;
43
+ /** Closed price at or above the dearest sum: nothing is saved. */
44
+ readonly noSaving: string;
45
+ /** The wide layout's third line: `{cheapest}` and `{dearest}`, each an `example`. */
46
+ readonly examples: string;
47
+ /** `{quantity}`, `{name}` (as the catalog stores it), `{pays}`. */
48
+ readonly example: string;
49
+ /** The same with what it saves: `{quantity}`, `{name}`, `{pays}`, `{saving}`. */
50
+ readonly exampleSaving: string;
51
+ }
52
+
53
+ export interface DiscountsComboRewardsCopy {
54
+ /** The toggle's three choices, in `COMBO_REWARDS` order. */
55
+ readonly labels: Readonly<Record<ComboReward, string>>;
56
+ /** The sentence under the reward's field, one per choice. */
57
+ readonly hints: Readonly<Record<ComboReward, string>>;
58
+ readonly preview: DiscountsComboPreviewCopy;
59
+ }
@@ -0,0 +1,162 @@
1
+ "use client";
2
+
3
+ import { useLayoutEffect, useRef, useState, type JSX, type RefObject } from "react";
4
+
5
+ import { Fields, useFormContext } from "@12-apps/ui/form/total-form";
6
+ import { Box } from "@12-apps/ui/mui/Box";
7
+ import { Stack } from "@12-apps/ui/mui/Stack";
8
+ import { Text } from "@12-apps/ui/typography/Text";
9
+
10
+ import type { DiscountsWebCopy } from "./copy";
11
+ import { InlineComboCartPreview } from "./combo-cart-preview";
12
+ import type { CurrencyFieldComponent } from "./discount-form-fields";
13
+ import { COMBO_REWARDS, comboRewardOf } from "./form-kind";
14
+ import { useElementWidth } from "./kind-field";
15
+ import { PercentField } from "./number-fields";
16
+
17
+ /**
18
+ * A combo's reward: which of the three, how much, and what it comes to in the
19
+ * cart (FUT-4417).
20
+ *
21
+ * All of it is here rather than split between the top of the form and the
22
+ * bottom, because to a merchant it is one sentence — "este combo dá 15% de
23
+ * desconto", "…sai por R$ 120". "Preço fechado" is the third choice: the
24
+ * matched group costs exactly the price typed, whatever its items cost. That
25
+ * used to be refused as a choice because it goes on charging the same when an
26
+ * item's price changes — which is the point of a closed price, so the hint says
27
+ * it in those words and the preview shows it against the store's real prices.
28
+ *
29
+ * **One line at every width** (ADR 0190 in the host), like the kind question
30
+ * (`./kind-field`): a segmented control of three equal parts where they fit,
31
+ * the same field as a select where they do not. The value's field sits beside
32
+ * the choices where both fit on one row, and under them otherwise.
33
+ */
34
+
35
+ /**
36
+ * What one part needs before a label wraps inside it, when nothing could
37
+ * measure it (a test DOM): the kind toggle's own figure for "Porcentagem".
38
+ */
39
+ const FALLBACK_PART_PX = 136;
40
+
41
+ /** The gap between the choices and the value's field, `gap: 2`. */
42
+ const ROW_GAP_PX = 16;
43
+
44
+ const PART_SELECTOR = '[data-testid^="total-form-field-comboReward-item-"]';
45
+
46
+ /**
47
+ * A part's width with its label on ONE line: a hidden, unwrapped clone of it,
48
+ * measured beside it so it inherits the same type.
49
+ */
50
+ function naturalWidth(part: HTMLElement): number {
51
+ const clone = part.cloneNode(true) as HTMLElement;
52
+ Object.assign(clone.style, {
53
+ position: "absolute",
54
+ visibility: "hidden",
55
+ width: "auto",
56
+ minWidth: "0",
57
+ flex: "none",
58
+ whiteSpace: "nowrap",
59
+ });
60
+ (part.parentElement ?? document.body).appendChild(clone);
61
+ const width = clone.getBoundingClientRect().width;
62
+ clone.remove();
63
+ return width;
64
+ }
65
+
66
+ /**
67
+ * The width the three equal parts need, measured off the toggle the first time
68
+ * it is on screen — the theme decides the type size and the padding, so a
69
+ * figure typed here would be wrong in the next density mode. Parts are equal,
70
+ * so the widest label sizes all three; the group's own padding and gaps are
71
+ * added on top.
72
+ */
73
+ function useTogglePartsWidth(box: RefObject<HTMLElement | null>): number {
74
+ const [measured, setMeasured] = useState<number | null>(null);
75
+ useLayoutEffect(() => {
76
+ if (measured !== null || box.current === null) return;
77
+ const parts = [...box.current.querySelectorAll<HTMLElement>(PART_SELECTOR)];
78
+ const group = parts[0]?.parentElement;
79
+ if (parts.length === 0 || !group) return;
80
+ const widest = Math.max(...parts.map(naturalWidth));
81
+ if (widest <= 0) return;
82
+ const style = getComputedStyle(group);
83
+ const chrome =
84
+ (parseFloat(style.paddingLeft) || 0) +
85
+ (parseFloat(style.paddingRight) || 0) +
86
+ (parseFloat(style.columnGap) || 0) * (parts.length - 1);
87
+ // +1 px a part: sub-pixel widths round the other way often enough to wrap.
88
+ setMeasured(Math.ceil((widest + 1) * parts.length + chrome));
89
+ });
90
+ return measured ?? COMBO_REWARDS.length * FALLBACK_PART_PX;
91
+ }
92
+
93
+ /** The value's input for the chosen reward; the other two are unmounted (`discounts_value_check`). */
94
+ function RewardValueField({
95
+ reward,
96
+ copy,
97
+ currencyField,
98
+ }: {
99
+ reward: string;
100
+ copy: DiscountsWebCopy;
101
+ currencyField: CurrencyFieldComponent;
102
+ }): JSX.Element {
103
+ const Currency = currencyField;
104
+ if (reward === "BUNDLE_PRICE")
105
+ return <Currency name="bundlePrice" label={copy.form.bundlePrice} />;
106
+ if (reward === "FIXED_AMOUNT") return <Currency name="amountOff" label={copy.form.amountOff} />;
107
+ return <PercentField copy={copy} />;
108
+ }
109
+
110
+ export function ComboRewardFields({
111
+ copy,
112
+ currencyField,
113
+ }: {
114
+ copy: DiscountsWebCopy;
115
+ currencyField: CurrencyFieldComponent;
116
+ }): JSX.Element {
117
+ const { values } = useFormContext();
118
+ const box = useRef<HTMLDivElement>(null);
119
+ const width = useElementWidth(box);
120
+ const needed = useTogglePartsWidth(box);
121
+ const reward = comboRewardOf({ type: values.comboReward ?? "" });
122
+ const options = COMBO_REWARDS.map((value) => ({
123
+ value,
124
+ label: copy.comboRewards.labels[value],
125
+ }));
126
+ const sideBySide = width === null || width >= 2 * needed + ROW_GAP_PX;
127
+ const column = width === null ? null : sideBySide ? (width - ROW_GAP_PX) / 2 : width;
128
+ const fits = column === null || column >= needed;
129
+ return (
130
+ <Stack spacing={0.5} ref={box} sx={{ minWidth: 0 }}>
131
+ <Box
132
+ data-testid="combo-reward-row"
133
+ sx={{
134
+ display: "grid",
135
+ gap: 2,
136
+ gridTemplateColumns: sideBySide ? "1fr 1fr" : "1fr",
137
+ }}
138
+ >
139
+ <Box sx={{ minWidth: 0 }}>
140
+ {fits ? (
141
+ <Fields.ToggleField
142
+ name="comboReward"
143
+ label={copy.form.comboReward}
144
+ options={options}
145
+ />
146
+ ) : (
147
+ <Fields.SelectField
148
+ name="comboReward"
149
+ label={copy.form.comboReward}
150
+ options={options}
151
+ />
152
+ )}
153
+ </Box>
154
+ <RewardValueField reward={reward} copy={copy} currencyField={currencyField} />
155
+ </Box>
156
+ <Text variant="caption" data-testid="combo-reward-hint">
157
+ {copy.comboRewards.hints[reward]}
158
+ </Text>
159
+ <InlineComboCartPreview />
160
+ </Stack>
161
+ );
162
+ }
package/src/react/copy.ts CHANGED
@@ -7,6 +7,7 @@ import type {
7
7
  DiscountWindowState,
8
8
  } from "../engine/kinds";
9
9
 
10
+ import type { DiscountsComboRewardsCopy } from "./combo-reward-copy";
10
11
  import type { DiscountsScheduleCopy } from "./schedule-copy";
11
12
 
12
13
  /**
@@ -80,8 +81,8 @@ export interface DiscountsVocabularyCopy {
80
81
  /**
81
82
  * The KIND toggle's words — the one question the form actually asks (see
82
83
  * `./form-kind`). It is keyed by `DiscountType` because every kind is named
83
- * after the type it stands for, `COMBO` included: a combo IS a discount, and
84
- * `BUNDLE_PRICE`'s label survives for the legacy rules that still carry it.
84
+ * after the type it stands for. `BUNDLE_PRICE` is no longer a kind on offer
85
+ * (FUT-4417: a combo reward, `comboRewards.labels`); its entry keeps the shape.
85
86
  */
86
87
  readonly kind: LabelsFor<DiscountType | "COMBO">;
87
88
  readonly type: LabelsFor<DiscountType>;
@@ -106,20 +107,21 @@ export interface DiscountsWindowCopy {
106
107
  export interface DiscountsFormCopy {
107
108
  readonly createTitle: string;
108
109
  readonly editTitle: string;
110
+ readonly dialog: { readonly close: string; readonly back: string };
109
111
  readonly submitCreate: string;
110
112
  readonly submitEdit: string;
111
113
  readonly name: string;
112
114
  readonly type: string;
113
115
  /** The kind toggle's own label — the form's first real question. */
114
116
  readonly kind: string;
115
- /** Which of the two rewards a COMBO gives, and the sentence under it. */
117
+ /** The combo reward's label; its choices and hints are `comboRewards` (FUT-4417). */
116
118
  readonly comboReward: string;
117
119
  readonly comboRewardHint: string;
118
120
  readonly percentOff: string;
119
121
  /** The example inside the empty percentage input. */
120
122
  readonly percentPlaceholder: string;
121
123
  readonly amountOff: string;
122
- /** The combo's total price, shown only for a BUNDLE_PRICE rule. */
124
+ /** The combo's closed price, under the "Preço fechado" reward (FUT-4417). */
123
125
  readonly bundlePrice: string;
124
126
  /** How many of the matched units are given away, for a FREE_UNITS rule. */
125
127
  readonly freeUnits: string;
@@ -319,6 +321,7 @@ export interface DiscountsWebCopy {
319
321
  readonly schedule: DiscountsScheduleCopy;
320
322
  readonly form: DiscountsFormCopy;
321
323
  readonly combo: DiscountsComboCopy;
324
+ readonly comboRewards: DiscountsComboRewardsCopy;
322
325
  readonly targets: DiscountsTargetCopy;
323
326
  readonly actions: DiscountsActionsCopy;
324
327
  readonly card: DiscountsCardCopy;
@@ -7,11 +7,11 @@ import {
7
7
  useCardActions,
8
8
  useRemoveConfirm,
9
9
  } from "@12-apps/ui/data-display/CardKit";
10
- import { Dialog, DialogContent } from "@12-apps/ui/feedback/Dialog";
11
10
  import type { DropdownMenuItem } from "@12-apps/ui/navigation/DropdownMenu";
12
11
 
13
12
  import type { DiscountsApiClient, WireTargetGroup } from "./api";
14
13
  import type { DiscountsWebCopy } from "./copy";
14
+ import { DiscountDialog } from "./discount-dialog";
15
15
  import { DiscountForm } from "./discount-form";
16
16
  import type { CurrencyFieldComponent } from "./discount-form-fields";
17
17
  import type { DiscountsFormatters } from "./format";
@@ -125,36 +125,30 @@ export function DiscountActionsMenu({
125
125
  />
126
126
  {remove.dialog}
127
127
  {canEdit && (
128
- <Dialog
128
+ <DiscountDialog
129
129
  open={editOpen}
130
130
  onClose={() => setEditOpen(false)}
131
131
  title={copy.form.editTitle}
132
- size="md"
133
- showCloseButton
134
- dataTestId="discount-dialog"
132
+ copy={copy}
135
133
  >
136
- {/* Mounted only while open, so the next open re-seeds from the row
134
+ {/* Each opening mounts a fresh form, so it re-seeds from the row
137
135
  instead of keeping the last attempt's values. */}
138
- {editOpen && (
139
- <DialogContent>
140
- <DiscountForm
141
- key={row.id}
142
- api={api}
143
- copy={copy}
144
- formatters={formatters}
145
- currencyField={currencyField}
146
- groups={groups}
147
- editing={row.record}
148
- onError={onError}
149
- {...(timezoneLabel ? { timezoneLabel } : {})}
150
- onSaved={() => {
151
- setEditOpen(false);
152
- onRefresh();
153
- }}
154
- />
155
- </DialogContent>
156
- )}
157
- </Dialog>
136
+ <DiscountForm
137
+ key={row.id}
138
+ api={api}
139
+ copy={copy}
140
+ formatters={formatters}
141
+ currencyField={currencyField}
142
+ groups={groups}
143
+ editing={row.record}
144
+ onError={onError}
145
+ {...(timezoneLabel ? { timezoneLabel } : {})}
146
+ onSaved={() => {
147
+ setEditOpen(false);
148
+ onRefresh();
149
+ }}
150
+ />
151
+ </DiscountDialog>
158
152
  )}
159
153
  </>
160
154
  );
@@ -0,0 +1,67 @@
1
+ "use client";
2
+
3
+ import { Fragment, useState, type JSX, type ReactNode } from "react";
4
+
5
+ import { UsableModal } from "@12-apps/ui/feedback/UsableModal";
6
+ import { Box } from "@12-apps/ui/mui/Box";
7
+
8
+ import type { DiscountsWebCopy } from "./copy";
9
+
10
+ /**
11
+ * The surface the create and edit forms open in (FUT-4417): a stacked modal on
12
+ * a computer, a bottom sheet on a phone (`UsableModal`).
13
+ *
14
+ * `size="wide"` — the panel the library keeps for a dense two-column editor —
15
+ * because the combo form lays out "Como fica no carrinho" in its own column
16
+ * beside the fields when the form has the room (`PREVIEW_ASIDE_MIN_PX`), and
17
+ * the 800 px dialog it replaced never had it. The owner's call, 2026-11-05:
18
+ * "Substitui o modal pelo stacked modal no desktop".
19
+ *
20
+ * Every opening mounts a fresh form, so it re-seeds instead of keeping the
21
+ * last attempt's values — even when it reopens inside the modal's 400 ms way
22
+ * out, while the closing body is still mounted (hence the key per opening).
23
+ * The body stays mounted while it leaves, so the panel does not slide out
24
+ * empty. The submit stays in the form: it acts on the fields above it.
25
+ *
26
+ * `copy.form.dialog` names the ✕ and the back arrow, which carry a glyph only;
27
+ * the arrow shows when the modal opens over another stacked panel.
28
+ */
29
+ export function DiscountDialog({
30
+ open,
31
+ onClose,
32
+ title,
33
+ copy,
34
+ children,
35
+ }: {
36
+ open: boolean;
37
+ onClose: () => void;
38
+ title: string;
39
+ copy: DiscountsWebCopy;
40
+ children: ReactNode;
41
+ }): JSX.Element {
42
+ const [opening, setOpening] = useState(open ? 1 : 0);
43
+ const [wasOpen, setWasOpen] = useState(open);
44
+ if (open !== wasOpen) {
45
+ setWasOpen(open);
46
+ if (open) setOpening(opening + 1);
47
+ }
48
+ return (
49
+ <UsableModal
50
+ open={open}
51
+ onClose={onClose}
52
+ title={title}
53
+ size="wide"
54
+ closeLabel={copy.form.dialog.close}
55
+ backLabel={copy.form.dialog.back}
56
+ dataTestId="discount-dialog"
57
+ >
58
+ {/* `clip`, not `hidden`: a checked switch's hit area reaches 24 px past
59
+ its track (the input is three tracks wide), which the sheet's 16 px
60
+ gutter turned into an 8 px sideways scroll of the whole form. Clipping
61
+ keeps that overflow out of the scroller without making a second one. */}
62
+ <Box sx={{ minWidth: 0, overflowX: "clip" }}>
63
+ <Fragment key={opening}>{children}</Fragment>
64
+ </Box>
65
+ </UsableModal>
66
+ );
67
+ }
@@ -13,7 +13,8 @@ import { DISCOUNT_TRIGGERS } from "../engine/kinds";
13
13
  import type { DiscountSchedule } from "../engine/schedule";
14
14
 
15
15
  import type { DiscountsWebCopy } from "./copy";
16
- import { COMBO_REWARDS, isComboKind, SELECTABLE_DISCOUNT_SCOPES } from "./form-kind";
16
+ import { ComboRewardFields } from "./combo-reward-fields";
17
+ import { isComboKind, SELECTABLE_DISCOUNT_SCOPES } from "./form-kind";
17
18
  import { DateField } from "./date-field";
18
19
  import { PercentField, WholeNumberField } from "./number-fields";
19
20
  import { ScheduleBuilder } from "./schedule-builder";
@@ -75,43 +76,6 @@ export function optionsFor<TKey extends string>(
75
76
  */
76
77
  export type CurrencyFieldComponent = (props: { name: string; label: string }) => JSX.Element;
77
78
 
78
- /**
79
- * A combo's reward: which of the two, and how much.
80
- *
81
- * Both are here rather than split between the top of the form and the bottom,
82
- * because to a merchant they are one sentence — "este combo dá 15% de
83
- * desconto". The two are the ONLY rewards a combo can give: a flat bundle price
84
- * reprices the group and goes silently wrong the first time one of its items
85
- * changes price, so it is not offered (see `./form-kind`).
86
- */
87
- function ComboRewardFields({
88
- copy,
89
- currencyField,
90
- }: {
91
- copy: DiscountsWebCopy;
92
- currencyField: CurrencyFieldComponent;
93
- }): JSX.Element {
94
- const { values } = useFormContext();
95
- const Currency = currencyField;
96
- return (
97
- <Stack spacing={0.5}>
98
- <Box sx={{ display: "grid", gap: 2, gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" } }}>
99
- <Fields.ToggleField
100
- name="comboReward"
101
- label={copy.form.comboReward}
102
- options={optionsFor(COMBO_REWARDS, copy.labels.type)}
103
- />
104
- {values.comboReward === "FIXED_AMOUNT" ? (
105
- <Currency name="amountOff" label={copy.form.amountOff} />
106
- ) : (
107
- <PercentField copy={copy} />
108
- )}
109
- </Box>
110
- <Text variant="caption">{copy.form.comboRewardHint}</Text>
111
- </Stack>
112
- );
113
- }
114
-
115
79
  /**
116
80
  * The reward, in whichever unit the chosen KIND actually stores.
117
81
  *
@@ -123,7 +87,8 @@ function ComboRewardFields({
123
87
  *
124
88
  * `FREE_UNITS` is the one kind with nothing here: its reward is a count of the
125
89
  * units it gives away, which only reads as an offer beside the units it takes,
126
- * so the free-units builder carries both.
90
+ * so the free-units builder carries both. A `COMBO`'s reward is one of three —
91
+ * a rate, an amount or a closed price (FUT-4417) — see `./combo-reward-fields`.
127
92
  */
128
93
  export function RewardFields({
129
94
  copy,
@@ -137,7 +102,6 @@ export function RewardFields({
137
102
  const kind = values.kind ?? "PERCENTAGE";
138
103
  if (kind === "COMBO") return <ComboRewardFields copy={copy} currencyField={currencyField} />;
139
104
  if (kind === "FREE_UNITS") return null;
140
- if (kind === "BUNDLE_PRICE") return <Currency name="bundlePrice" label={copy.form.bundlePrice} />;
141
105
  if (kind === "FIXED_AMOUNT") return <Currency name="amountOff" label={copy.form.amountOff} />;
142
106
  return <PercentField copy={copy} />;
143
107
  }
@@ -108,8 +108,9 @@ function checkPercent(
108
108
  /**
109
109
  * The reward, checked against the input the chosen KIND actually mounted.
110
110
  *
111
- * `type` is derived rather than read: a combo's reward is a percentage or an
112
- * amount, and which one it is lives in `comboReward` — so the check runs off
111
+ * `type` is derived rather than read: a combo's reward is a percentage, an
112
+ * amount or a closed price (FUT-4417), and which one it is lives in
113
+ * `comboReward` — so the check runs off
113
114
  * the same mapping the submit does, and the two cannot disagree about which
114
115
  * input holds the number.
115
116
  */
@@ -18,7 +18,7 @@ export interface DiscountFormValues extends Record<string, string> {
18
18
  name: string;
19
19
  /** What kind of promotion this is. Decides `type` and `scope` — see `./form-kind`. */
20
20
  kind: string;
21
- /** Which of the two rewards a COMBO gives. Ignored by every other kind. */
21
+ /** Which of the three rewards a COMBO gives (FUT-4417). Ignored by every other kind. */
22
22
  comboReward: string;
23
23
  percentOff: string;
24
24
  amountOff: string;
@@ -27,12 +27,13 @@ import {
27
27
  WindowFields,
28
28
  type CurrencyFieldComponent,
29
29
  } from "./discount-form-fields";
30
+ import { ComboPreviewLayout } from "./combo-cart-preview";
30
31
  import { DiscountTargetPicker } from "./discount-target-picker";
31
32
  import { KindField } from "./kind-field";
32
33
  import { blankWindow } from "./schedule-builder";
33
34
  import { validate, type FormTargets } from "./discount-form-validate";
34
35
  import { initialValues, type DiscountFormValues } from "./discount-form-values";
35
- import { kindOptions, typeAndScopeFor } from "./form-kind";
36
+ import { DISCOUNT_KINDS, typeAndScopeFor } from "./form-kind";
36
37
  import type { DiscountsFormatters } from "./format";
37
38
 
38
39
  /**
@@ -195,58 +196,66 @@ export function DiscountForm(props: DiscountFormProps): JSX.Element {
195
196
  onSubmit={handleSubmit}
196
197
  dataTestId="discount-form"
197
198
  >
198
- <Stack spacing={2}>
199
- <Fields.TextField name="name" label={copy.form.name} />
200
- <KindField copy={copy} kinds={kindOptions(editing)} />
201
- <RewardFields copy={copy} currencyField={currencyField} />
202
- <ScopeField copy={copy} />
203
- <DiscountTargetPicker
204
- groups={groups}
205
- copy={copy}
206
- selection={{
207
- categoryIds: extra.categoryIds,
208
- menuItemIds: extra.menuItemIds,
209
- comboRequirements: extra.comboRequirements,
210
- onCategoryIdsChange: extra.setCategoryIds,
211
- onMenuItemIdsChange: extra.setMenuItemIds,
212
- onComboRequirementsChange: extra.setComboRequirements,
213
- }}
214
- />
215
- <ComboCapField copy={copy} />
216
- <TriggerFields copy={copy} />
217
- <WindowFields
218
- copy={copy}
219
- schedule={extra.schedule}
220
- scheduleEnabled={extra.scheduleEnabled}
221
- timezoneLabel={props.timezoneLabel ?? null}
222
- onScheduleChange={extra.setSchedule}
223
- onScheduleEnabledChange={extra.setScheduleEnabled}
224
- />
225
- <LimitFields copy={copy} currencyField={currencyField} />
226
- <FirstOrderField
227
- copy={copy}
228
- checked={extra.firstOrderOnly}
229
- onChange={extra.setFirstOrderOnly}
230
- />
231
- <DiscountSwitches
232
- copy={copy}
233
- active={extra.active}
234
- stackable={extra.stackable}
235
- onActive={extra.setActive}
236
- onStackable={extra.setStackable}
237
- />
238
- {error && (
239
- <Alert
240
- variant="danger"
241
- title={copy.form.saveFailed}
242
- description={error}
243
- data-testid="discount-form-error"
199
+ {/* The combo preview's column (FUT-4417) beside a wide form; nothing for other kinds. */}
200
+ <ComboPreviewLayout
201
+ copy={copy}
202
+ formatters={formatters}
203
+ groups={groups}
204
+ slots={extra.comboRequirements}
205
+ >
206
+ <Stack spacing={2}>
207
+ <Fields.TextField name="name" label={copy.form.name} />
208
+ <KindField copy={copy} kinds={DISCOUNT_KINDS} />
209
+ <RewardFields copy={copy} currencyField={currencyField} />
210
+ <ScopeField copy={copy} />
211
+ <DiscountTargetPicker
212
+ groups={groups}
213
+ copy={copy}
214
+ selection={{
215
+ categoryIds: extra.categoryIds,
216
+ menuItemIds: extra.menuItemIds,
217
+ comboRequirements: extra.comboRequirements,
218
+ onCategoryIdsChange: extra.setCategoryIds,
219
+ onMenuItemIdsChange: extra.setMenuItemIds,
220
+ onComboRequirementsChange: extra.setComboRequirements,
221
+ }}
244
222
  />
245
- )}
246
- <SubmitButton dataTestId="discount-form-submit">
247
- {editing ? copy.form.submitEdit : copy.form.submitCreate}
248
- </SubmitButton>
249
- </Stack>
223
+ <ComboCapField copy={copy} />
224
+ <TriggerFields copy={copy} />
225
+ <WindowFields
226
+ copy={copy}
227
+ schedule={extra.schedule}
228
+ scheduleEnabled={extra.scheduleEnabled}
229
+ timezoneLabel={props.timezoneLabel ?? null}
230
+ onScheduleChange={extra.setSchedule}
231
+ onScheduleEnabledChange={extra.setScheduleEnabled}
232
+ />
233
+ <LimitFields copy={copy} currencyField={currencyField} />
234
+ <FirstOrderField
235
+ copy={copy}
236
+ checked={extra.firstOrderOnly}
237
+ onChange={extra.setFirstOrderOnly}
238
+ />
239
+ <DiscountSwitches
240
+ copy={copy}
241
+ active={extra.active}
242
+ stackable={extra.stackable}
243
+ onActive={extra.setActive}
244
+ onStackable={extra.setStackable}
245
+ />
246
+ {error && (
247
+ <Alert
248
+ variant="danger"
249
+ title={copy.form.saveFailed}
250
+ description={error}
251
+ data-testid="discount-form-error"
252
+ />
253
+ )}
254
+ <SubmitButton dataTestId="discount-form-submit">
255
+ {editing ? copy.form.submitEdit : copy.form.submitCreate}
256
+ </SubmitButton>
257
+ </Stack>
258
+ </ComboPreviewLayout>
250
259
  <FormErrorSnackbar message={copy.form.errorSnackbar} />
251
260
  </FormContainer>
252
261
  );
@@ -102,7 +102,7 @@ export function DiscountTargetPicker({
102
102
  );
103
103
  }
104
104
 
105
- if (kind === "COMBO" || kind === "BUNDLE_PRICE") {
105
+ if (kind === "COMBO") {
106
106
  return (
107
107
  <ComboSlotBuilder
108
108
  slots={selection.comboRequirements}