@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.
@@ -1,8 +1,11 @@
1
+ import { minLength, required } from "@12-apps/forms-core";
2
+
1
3
  import { toMinutes, type DiscountSchedule } from "../engine/schedule";
2
4
  import type { ComboRequirement } from "../engine/types";
3
5
 
4
6
  import { comboUnits } from "./combo-slot-builder";
5
7
  import { fill, type DiscountsWebCopy } from "./copy";
8
+ import { isCompleteMaskedDay, isIsoDay } from "./date-field";
6
9
  import type { DiscountFormValues } from "./discount-form-values";
7
10
  import { isComboKind, typeAndScopeFor } from "./form-kind";
8
11
  import type { DiscountsFormatters } from "./format";
@@ -19,12 +22,40 @@ import type { DiscountsFormatters } from "./format";
19
22
  * inputs red.
20
23
  */
21
24
 
22
- /** A whole number above zero, as typed. `null` when it is neither. */
25
+ /**
26
+ * A whole number above zero, as typed. `null` when it is neither.
27
+ *
28
+ * Digits only (FUT-4143): `Number` reads "2.0" and "1e1" as whole numbers, and
29
+ * a count an operator typed with a separator in it was meant as something else.
30
+ */
23
31
  function positiveCount(typed: string): number | null {
24
32
  const trimmed = typed.trim();
25
- if (trimmed === "") return null;
33
+ if (!/^\d+$/u.test(trimmed)) return null;
26
34
  const value = Number(trimmed);
27
- return Number.isInteger(value) && value > 0 ? value : null;
35
+ return value > 0 ? value : null;
36
+ }
37
+
38
+ /** The name: present, and at least two characters, in the pack's words. */
39
+ function checkName(typed: string, copy: DiscountsWebCopy): Record<string, string> {
40
+ const refused =
41
+ required(copy.form.nameRequired)(typed) ?? minLength(2, copy.form.nameTooShort)(typed.trim());
42
+ return refused === undefined ? {} : { name: refused };
43
+ }
44
+
45
+ /**
46
+ * The two redemption caps. Blank is "no cap"; anything else is a whole number
47
+ * from 1, so "2,5" is refused here instead of reaching the wire as 2 — or, in a
48
+ * browser that read the comma away, as 25.
49
+ */
50
+ function checkLimits(values: DiscountFormValues, copy: DiscountsWebCopy): Record<string, string> {
51
+ const errors: Record<string, string> = {};
52
+ if (values.usageLimit.trim() !== "" && positiveCount(values.usageLimit) === null) {
53
+ errors.usageLimit = copy.form.invalidUsageLimit;
54
+ }
55
+ if (values.perBuyerLimit.trim() !== "" && positiveCount(values.perBuyerLimit) === null) {
56
+ errors.perBuyerLimit = copy.form.invalidPerBuyerLimit;
57
+ }
58
+ return errors;
28
59
  }
29
60
 
30
61
  /** A money field that has to hold something above zero. */
@@ -77,8 +108,9 @@ function checkPercent(
77
108
  /**
78
109
  * The reward, checked against the input the chosen KIND actually mounted.
79
110
  *
80
- * `type` is derived rather than read: a combo's reward is a percentage or an
81
- * 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
82
114
  * the same mapping the submit does, and the two cannot disagree about which
83
115
  * input holds the number.
84
116
  */
@@ -134,6 +166,28 @@ function checkCombo(
134
166
  return errors;
135
167
  }
136
168
 
169
+ /**
170
+ * Início and Término. Each is blank or a whole real ISO day — the date box
171
+ * holds its typed text until it is one (FUT-4156) — and only two real days are
172
+ * compared. The window is half-open [start, end): equal dates are an EMPTY
173
+ * window, so the rule is "after", not "not before".
174
+ */
175
+ function checkPeriod(values: DiscountFormValues, copy: DiscountsWebCopy): Record<string, string> {
176
+ const errors: Record<string, string> = {};
177
+ for (const name of ["startsAt", "endsAt"] as const) {
178
+ if (values[name] === "" || isIsoDay(values[name])) continue;
179
+ // A whole DD/MM/AAAA that is no day does not exist; anything shorter is
180
+ // simply not finished, and is told so.
181
+ errors[name] = isCompleteMaskedDay(values[name])
182
+ ? copy.form.invalidDate
183
+ : copy.form.dateIncomplete;
184
+ }
185
+ if (isIsoDay(values.startsAt) && isIsoDay(values.endsAt) && values.endsAt <= values.startsAt) {
186
+ errors.endsAt = copy.form.endsBeforeStarts;
187
+ }
188
+ return errors;
189
+ }
190
+
137
191
  /** What the pickers and the builder currently hold, in one argument. */
138
192
  export interface FormTargets {
139
193
  categoryIds: string[];
@@ -164,13 +218,19 @@ function checkSchedule(
164
218
  if (windows.some((window) => window.days.length === 0)) {
165
219
  return { schedule: copy.schedule.daysRequired };
166
220
  }
167
- const badTime = windows.some(
168
- (window) =>
169
- toMinutes(window.from) === null ||
170
- toMinutes(window.to) === null ||
171
- window.from === window.to,
172
- );
173
- return badTime ? { schedule: copy.schedule.timesRequired } : {};
221
+ const ends = windows.flatMap((window) => [window.from, window.to]);
222
+ // Unfinished first: "1:00" mid-edit is not a pair of equal ends, it is a
223
+ // time nobody has finished typing.
224
+ if (ends.some((end) => !/^\d{2}:\d{2}$/u.test(end))) {
225
+ return { schedule: copy.schedule.timeIncomplete };
226
+ }
227
+ // Then a whole HH:MM that no day has (25:00), in the clock's own words —
228
+ // "start and end must differ" would be untrue of it.
229
+ if (ends.some((end) => toMinutes(end) === null)) {
230
+ return { schedule: copy.schedule.invalidTime };
231
+ }
232
+ const sameEnds = windows.some((window) => window.from === window.to);
233
+ return sameEnds ? { schedule: copy.schedule.timesRequired } : {};
174
234
  }
175
235
 
176
236
  /** The schedule editor's state: its switch and its rows, kept separate. */
@@ -187,8 +247,13 @@ export function validate(
187
247
  scheduleState: ScheduleState,
188
248
  ): Record<string, string> {
189
249
  const slots = targets.comboRequirements;
250
+ // Every rule in ONE pass, the name's included (FUT-4143): the name used to be
251
+ // the container's schema, which stops before this function runs, so a 150%
252
+ // sat unmarked beside a red name until the name was fixed.
190
253
  const errors: Record<string, string> = {
254
+ ...checkName(values.name, copy),
191
255
  ...checkValue(values, slots, formatters, copy),
256
+ ...checkLimits(values, copy),
192
257
  ...checkCombo(values, slots, copy),
193
258
  ...checkSchedule(scheduleState, copy),
194
259
  };
@@ -202,11 +267,7 @@ export function validate(
202
267
  if (scope === "ITEM" && targets.menuItemIds.length === 0) {
203
268
  errors.scope = copy.form.itemTargetRequired;
204
269
  }
205
- // The window is half-open [start, end): equal dates are an EMPTY window, so
206
- // the rule is "after", not "not before".
207
- if (values.startsAt !== "" && values.endsAt !== "" && values.endsAt <= values.startsAt) {
208
- errors.endsAt = copy.form.endsBeforeStarts;
209
- }
270
+ Object.assign(errors, checkPeriod(values, copy));
210
271
  return errors;
211
272
  }
212
273
 
@@ -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;
@@ -2,7 +2,6 @@
2
2
 
3
3
  import { useState, type JSX } from "react";
4
4
 
5
- import { minLength, required } from "@12-apps/forms-core";
6
5
  import { Alert } from "@12-apps/ui/data-display/Alert";
7
6
  import {
8
7
  Fields,
@@ -21,7 +20,6 @@ import type { DiscountsWebCopy } from "./copy";
21
20
  import {
22
21
  ComboCapField,
23
22
  DiscountSwitches,
24
- KindField,
25
23
  LimitFields,
26
24
  RewardFields,
27
25
  ScopeField,
@@ -29,11 +27,13 @@ import {
29
27
  WindowFields,
30
28
  type CurrencyFieldComponent,
31
29
  } from "./discount-form-fields";
30
+ import { ComboPreviewLayout } from "./combo-cart-preview";
32
31
  import { DiscountTargetPicker } from "./discount-target-picker";
32
+ import { KindField } from "./kind-field";
33
33
  import { blankWindow } from "./schedule-builder";
34
34
  import { validate, type FormTargets } from "./discount-form-validate";
35
35
  import { initialValues, type DiscountFormValues } from "./discount-form-values";
36
- import { kindOptions, typeAndScopeFor } from "./form-kind";
36
+ import { DISCOUNT_KINDS, typeAndScopeFor } from "./form-kind";
37
37
  import type { DiscountsFormatters } from "./format";
38
38
 
39
39
  /**
@@ -147,8 +147,9 @@ function makeSubmit(
147
147
  schedule: extra.schedule,
148
148
  });
149
149
  if (Object.keys(invalid).length > 0) {
150
+ // ONE message for a client refusal: the red fields and the toast
151
+ // (FUT-4143). The `saveFailed` alert is the SERVER's, below.
150
152
  setFieldErrors(invalid);
151
- setError(copy.form.reviewFields);
152
153
  return;
153
154
  }
154
155
  const payload: DiscountFormPayload = {
@@ -188,64 +189,74 @@ export function DiscountForm(props: DiscountFormProps): JSX.Element {
188
189
 
189
190
  return (
190
191
  <FormContainer<DiscountFormValues>
192
+ // No `schema`: the container runs one before `onSubmit` and stops when
193
+ // it fails, which judged the name alone. Every rule, the name's too,
194
+ // lives in `validate` so one Criar marks every field (FUT-4143).
191
195
  initialValues={initialValues(editing, formatters)}
192
- schema={{ name: [required(copy.form.nameRequired), minLength(2)] }}
193
196
  onSubmit={handleSubmit}
194
197
  dataTestId="discount-form"
195
198
  >
196
- <Stack spacing={2}>
197
- <Fields.TextField name="name" label={copy.form.name} />
198
- <KindField copy={copy} kinds={kindOptions(editing)} />
199
- <RewardFields copy={copy} currencyField={currencyField} />
200
- <ScopeField copy={copy} />
201
- <DiscountTargetPicker
202
- groups={groups}
203
- copy={copy}
204
- selection={{
205
- categoryIds: extra.categoryIds,
206
- menuItemIds: extra.menuItemIds,
207
- comboRequirements: extra.comboRequirements,
208
- onCategoryIdsChange: extra.setCategoryIds,
209
- onMenuItemIdsChange: extra.setMenuItemIds,
210
- onComboRequirementsChange: extra.setComboRequirements,
211
- }}
212
- />
213
- <ComboCapField copy={copy} />
214
- <TriggerFields copy={copy} />
215
- <WindowFields
216
- copy={copy}
217
- schedule={extra.schedule}
218
- scheduleEnabled={extra.scheduleEnabled}
219
- timezoneLabel={props.timezoneLabel ?? null}
220
- onScheduleChange={extra.setSchedule}
221
- onScheduleEnabledChange={extra.setScheduleEnabled}
222
- />
223
- <LimitFields copy={copy} currencyField={currencyField} />
224
- <FirstOrderField
225
- copy={copy}
226
- checked={extra.firstOrderOnly}
227
- onChange={extra.setFirstOrderOnly}
228
- />
229
- <DiscountSwitches
230
- copy={copy}
231
- active={extra.active}
232
- stackable={extra.stackable}
233
- onActive={extra.setActive}
234
- onStackable={extra.setStackable}
235
- />
236
- {error && (
237
- <Alert
238
- variant="danger"
239
- title={copy.form.saveFailed}
240
- description={error}
241
- 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
+ }}
242
222
  />
243
- )}
244
- <SubmitButton dataTestId="discount-form-submit">
245
- {editing ? copy.form.submitEdit : copy.form.submitCreate}
246
- </SubmitButton>
247
- </Stack>
248
- <FormErrorSnackbar />
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>
259
+ <FormErrorSnackbar message={copy.form.errorSnackbar} />
249
260
  </FormContainer>
250
261
  );
251
262
  }
@@ -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}
@@ -18,7 +18,6 @@ import {
18
18
  import type { RowAction } from "@12-apps/ui/data-display/DataViews";
19
19
  import { ErrorState } from "@12-apps/ui/data-display/ErrorState";
20
20
  import { LoadingState } from "@12-apps/ui/data-display/LoadingState";
21
- import { Dialog, DialogContent } from "@12-apps/ui/feedback/Dialog";
22
21
  import { HeaderButton } from "@12-apps/ui/form/HeaderButton";
23
22
  import { Dashboard } from "@12-apps/ui/layout/Dashboard";
24
23
  import { Text } from "@12-apps/ui/typography/Text";
@@ -28,6 +27,7 @@ import type { DiscountsApiClient } from "./api";
28
27
  import { fill, type DiscountsWebCopy } from "./copy";
29
28
  import { DiscountActionsMenu } from "./discount-actions-menu";
30
29
  import { DiscountCard } from "./discount-card";
30
+ import { DiscountDialog } from "./discount-dialog";
31
31
  import { DiscountForm } from "./discount-form";
32
32
  import type { CurrencyFieldComponent } from "./discount-form-fields";
33
33
  import { DiscountListCard, discountCells } from "./discount-list-card";
@@ -214,7 +214,7 @@ function DiscountsGrid({
214
214
  );
215
215
  }
216
216
 
217
- /** The create dialog, mounted only while open so the next open re-seeds empty. */
217
+ /** The create dialog; each opening mounts a fresh form, so it re-seeds empty. */
218
218
  function CreateDialog({
219
219
  open,
220
220
  onClose,
@@ -227,25 +227,19 @@ function CreateDialog({
227
227
  menu: MenuBinding;
228
228
  }): JSX.Element {
229
229
  return (
230
- <Dialog
230
+ <DiscountDialog
231
231
  open={open}
232
232
  onClose={onClose}
233
233
  title={menu.copy.form.createTitle}
234
- size="md"
235
- showCloseButton
236
- dataTestId="discount-dialog"
234
+ copy={menu.copy}
237
235
  >
238
- {open && (
239
- <DialogContent>
240
- <DiscountForm
241
- {...menu}
242
- groups={menu.groups ?? []}
243
- editing={null}
244
- onSaved={onSaved}
245
- />
246
- </DialogContent>
247
- )}
248
- </Dialog>
236
+ <DiscountForm
237
+ {...menu}
238
+ groups={menu.groups ?? []}
239
+ editing={null}
240
+ onSaved={onSaved}
241
+ />
242
+ </DiscountDialog>
249
243
  );
250
244
  }
251
245
 
@@ -3,6 +3,7 @@ import {
3
3
  EN_US_CATEGORY_SELECT_COPY,
4
4
  EN_US_CONFIRM_ACTION_COPY,
5
5
  EN_US_DATA_VIEWS_COPY,
6
+ EN_US_UI_LABELS,
6
7
  } from "@12-apps/ui/en-US";
7
8
 
8
9
  import type { DiscountsWebCopy } from "./copy";
@@ -131,11 +132,14 @@ export const EN_US_DISCOUNTS_WEB_COPY: DiscountsWebCopy = {
131
132
  activeNow: "Running now",
132
133
  daysRequired: "Pick at least one day.",
133
134
  timesRequired: "Enter a start and end time that differ.",
135
+ invalidTime: "That time does not exist. Use 00:00 to 23:59.",
136
+ timeIncomplete: "Finish the time: HH:MM.",
134
137
  windowsRequired: "Add at least one time.",
135
138
  },
136
139
  form: {
137
140
  createTitle: "New discount",
138
141
  editTitle: "Edit discount",
142
+ dialog: { close: EN_US_UI_LABELS.common.close, back: "Back" },
139
143
  submitCreate: "Create discount",
140
144
  submitEdit: "Save changes",
141
145
  name: "Promotion name",
@@ -172,8 +176,13 @@ export const EN_US_DISCOUNTS_WEB_COPY: DiscountsWebCopy = {
172
176
  firstOrderOnlyHint:
173
177
  "Only for customers who have never bought here. Menu prices do not change: the discount shows in the cart of whoever can use it.",
174
178
  reviewFields: "Check the highlighted fields.",
179
+ errorSnackbar: EN_US_UI_LABELS.formErrorSnackbar.message,
175
180
  saveFailed: "Could not save the discount",
176
181
  nameRequired: "Give the promotion a name.",
182
+ nameTooShort: "The name needs at least 2 characters.",
183
+ datePlaceholder: "DD/MM/YYYY",
184
+ invalidDate: "That date does not exist. Use DD/MM/YYYY, such as 25/12/2026.",
185
+ dateIncomplete: "Finish the date: DD/MM/YYYY.",
177
186
  invalidPercent: "Enter a percentage greater than 0 and at most 100.",
178
187
  invalidAmount: "Enter a discount amount greater than zero.",
179
188
  invalidBundlePrice: "Enter the bundle price, greater than zero.",
@@ -188,8 +197,37 @@ export const EN_US_DISCOUNTS_WEB_COPY: DiscountsWebCopy = {
188
197
  comboSlotTargetRequired: "Choose what can go in each group of the bundle.",
189
198
  invalidComboQuantity: "Enter how many units each group takes, from 1 upwards.",
190
199
  invalidMaxComboApplications: "Enter how many bundles fit in one order, from 1 upwards.",
200
+ invalidUsageLimit: "Enter a whole number of uses from 1 upwards, or leave it blank.",
201
+ invalidPerBuyerLimit: "Enter a whole number per customer from 1 upwards, or leave it blank.",
202
+ wholeNumberOnly: "Whole numbers only, no decimals.",
191
203
  endsBeforeStarts: "The end date must be after the start date.",
192
204
  },
205
+ comboRewards: {
206
+ labels: { PERCENTAGE: "Percentage", FIXED_AMOUNT: "Fixed amount", BUNDLE_PRICE: "Fixed price" },
207
+ hints: {
208
+ PERCENTAGE: "The bundle sells for the price of its items minus this discount.",
209
+ FIXED_AMOUNT: "The bundle sells for the price of its items minus this amount.",
210
+ BUNDLE_PRICE: "The bundle sells for this price, whatever its items cost.",
211
+ },
212
+ preview: {
213
+ title: "In the cart",
214
+ itemsRange: "{units} add up to {min}–{max}",
215
+ itemsExact: "{units} add up to {sum}",
216
+ paysRange: "The customer pays {min}–{max}",
217
+ paysRangeOff: "The customer pays {min}–{max} ({off} off)",
218
+ paysExact: "The customer pays {pays}",
219
+ paysExactOff: "The customer pays {pays} ({off} off)",
220
+ paysOff: "The customer pays {off} less",
221
+ paysPriceSaving: "The customer pays {price} — saves {min}–{max}",
222
+ paysPriceSavingExact: "The customer pays {price} — saves {saving}",
223
+ paysPriceSavingUpTo: "The customer pays {price} — saves up to {max}",
224
+ paysUpToPrice: "The customer pays {min}–{price} — saves up to {max}",
225
+ noSaving: "The customer pays the items' price: the bundle price is not lower than theirs.",
226
+ examples: "Examples: {cheapest} · {dearest}",
227
+ example: "{quantity} × {name} → {pays}",
228
+ exampleSaving: "{quantity} × {name} → {pays} (saves {saving})",
229
+ },
230
+ },
193
231
  combo: {
194
232
  title: "Build the bundle",
195
233
  hint: "Each group is one part of the bundle: how many units it takes, and what can go in it.",
@@ -201,7 +239,11 @@ export const EN_US_DISCOUNTS_WEB_COPY: DiscountsWebCopy = {
201
239
  // sentence is built around it rather than naming a noun of its own.
202
240
  pick: "{collection} in this group",
203
241
  empty: "No groups yet. Add the first one to build the bundle.",
204
- summary: "The bundle takes {units} items across {groups} groups.",
242
+ summary: "The bundle takes {units} across {groups}.",
243
+ unitsOne: "{count} item",
244
+ unitsOther: "{count} items",
245
+ groupsOne: "{count} group",
246
+ groupsOther: "{count} groups",
205
247
  freeUnitsTitle: "Buy more, pay less",
206
248
  freeUnitsHint:
207
249
  'Choose the products, how many the customer takes and how many are free — the classic "buy 3, pay for 2".',
@@ -20,18 +20,20 @@ import { DISCOUNT_SCOPES, type DiscountScope, type DiscountType } from "../engin
20
20
  * |---|---|---|---|
21
21
  * | `PERCENTAGE` | `PERCENTAGE` | ORDER / CATEGORY / ITEM | the rate, and what it covers |
22
22
  * | `FIXED_AMOUNT` | `FIXED_AMOUNT` | ORDER / CATEGORY / ITEM | the amount, and what it covers |
23
- * | `COMBO` | `PERCENTAGE` or `FIXED_AMOUNT` | `COMBO` | the groups, and which of the two rewards |
23
+ * | `COMBO` | `PERCENTAGE`, `FIXED_AMOUNT` or `BUNDLE_PRICE` | `COMBO` | the groups, and which of the three rewards |
24
24
  * | `FREE_UNITS` | `FREE_UNITS` | `COMBO` | the products, how many, how many free |
25
25
  *
26
26
  * Two consequences worth stating, because both are deliberate:
27
27
  *
28
- * **A combo is a DISCOUNT off its items, never a price.** "2 refrigerantes, 2
29
- * hambúrgueres e 2 batatas com 15% de desconto" and "…menos R$ 5,00" are the
30
- * two offers a merchant describes; a flat "por R$ 49,90" reprices the group and
31
- * silently goes wrong the moment one of its items changes price. `BUNDLE_PRICE`
32
- * is still a legal type in the engine and in the database — an older rule that
33
- * uses it keeps working and keeps being editable, see {@link kindOptions} — it
34
- * is simply not offered for a new promotion.
28
+ * **A combo's reward is a discount off its items OR a closed price**
29
+ * (FUT-4417). "2 refrigerantes, 2 hambúrgueres e 2 batatas com 15% de
30
+ * desconto" and "…menos R$ 5,00" are two offers a merchant describes; "5
31
+ * marmitas por R$ 120" is the third, and the one the form used to refuse. A
32
+ * closed price reprices the matched group whatever its items cost — which is
33
+ * exactly what the merchant asked for, and why the form says so under the price
34
+ * field and previews it against the store's real prices ("Como fica no
35
+ * carrinho"). `BUNDLE_PRICE` is therefore a combo REWARD, not a kind: a rule
36
+ * saved with it before FUT-4417 opens as a combo with "Preço fechado" chosen.
35
37
  *
36
38
  * **`COMBO` scope is not a choice any more.** It was never one: a combo is
37
39
  * defined by its groups, so "combo scope, but with an order-wide discount" is
@@ -39,15 +41,14 @@ import { DISCOUNT_SCOPES, type DiscountScope, type DiscountType } from "../engin
39
41
  * genuinely a choice, and the two combo kinds carry `COMBO` implicitly.
40
42
  */
41
43
 
42
- /** The kinds offered for a NEW promotion, in the order the toggle shows them. */
44
+ /** The kinds the form offers, in the order the toggle shows them. */
43
45
  export const DISCOUNT_KINDS = ["PERCENTAGE", "FIXED_AMOUNT", "COMBO", "FREE_UNITS"] as const;
44
46
 
45
47
  /**
46
- * Every kind the form can HOLD, which is one more than it offers: a rule saved
47
- * as `BUNDLE_PRICE` before combos became a discount still has to open, edit and
48
- * save without losing what it is.
48
+ * Every kind the form can hold. `BUNDLE_PRICE` is not one any more (FUT-4417):
49
+ * it is the third reward of a `COMBO`, see {@link COMBO_REWARDS}.
49
50
  */
50
- export type DiscountKind = (typeof DISCOUNT_KINDS)[number] | "BUNDLE_PRICE";
51
+ export type DiscountKind = (typeof DISCOUNT_KINDS)[number];
51
52
 
52
53
  /**
53
54
  * True when this kind's targets are groups — a builder, not a flat picker, and
@@ -58,7 +59,7 @@ export type DiscountKind = (typeof DISCOUNT_KINDS)[number] | "BUNDLE_PRICE";
58
59
  * second `includes` that drifts from this one.
59
60
  */
60
61
  export function isComboKind(kind: string): boolean {
61
- return kind === "COMBO" || kind === "FREE_UNITS" || kind === "BUNDLE_PRICE";
62
+ return kind === "COMBO" || kind === "FREE_UNITS";
62
63
  }
63
64
 
64
65
  /** The scopes the toggle offers: every scope except the one a kind implies. */
@@ -70,39 +71,30 @@ export const SELECTABLE_DISCOUNT_SCOPES: readonly DiscountScope[] = DISCOUNT_SCO
70
71
  * Which kind an existing rule is, read back from the pair it was stored as.
71
72
  *
72
73
  * `FREE_UNITS` is checked before the scope because it is the narrower fact: it
73
- * is only ever legal at `COMBO` scope, so the type alone identifies it.
74
+ * is only ever legal at `COMBO` scope, so the type alone identifies it. A
75
+ * `BUNDLE_PRICE` rule is a combo (FUT-4417) — whatever scope it carries, since
76
+ * the type is only legal at `COMBO`.
74
77
  */
75
78
  export function kindOf(rule: { type: string; scope: string } | null): DiscountKind {
76
79
  if (rule === null) return "PERCENTAGE";
77
- if (rule.type === "BUNDLE_PRICE") return "BUNDLE_PRICE";
78
80
  if (rule.type === "FREE_UNITS") return "FREE_UNITS";
79
- if (rule.scope === "COMBO") return "COMBO";
81
+ if (rule.type === "BUNDLE_PRICE" || rule.scope === "COMBO") return "COMBO";
80
82
  return rule.type === "FIXED_AMOUNT" ? "FIXED_AMOUNT" : "PERCENTAGE";
81
83
  }
82
84
 
83
85
  /**
84
- * The kinds this particular form may show — the four, plus `BUNDLE_PRICE` when
85
- * that is what is being edited.
86
- *
87
- * Appended rather than inserted so the four keep the positions an operator has
88
- * learned, and present ONLY on the legacy rule's own form: an operator editing
89
- * a percentage promotion is not offered a bundle price they cannot create.
90
- */
91
- export function kindOptions(editing: { type: string; scope: string } | null): DiscountKind[] {
92
- const kind = kindOf(editing);
93
- return kind === "BUNDLE_PRICE" ? [...DISCOUNT_KINDS, kind] : [...DISCOUNT_KINDS];
94
- }
95
-
96
- /**
97
- * A combo's reward is one of the two plain types, chosen separately from the
98
- * kind — "15% off the combo" and "R$ 5,00 off the combo" are the same kind of
99
- * promotion with different arithmetic.
86
+ * A combo's reward, chosen separately from the kind — "15% off the combo",
87
+ * "R$ 5,00 off the combo" and "the combo for R$ 120" (FUT-4417) are the same
88
+ * kind of promotion with different arithmetic. In the order the toggle shows
89
+ * them.
100
90
  */
101
- export const COMBO_REWARDS = ["PERCENTAGE", "FIXED_AMOUNT"] as const;
91
+ export const COMBO_REWARDS = ["PERCENTAGE", "FIXED_AMOUNT", "BUNDLE_PRICE"] as const;
92
+ export type ComboReward = (typeof COMBO_REWARDS)[number];
102
93
 
103
- /** Which reward an existing combo was saved with. */
104
- export function comboRewardOf(rule: { type: string } | null): string {
105
- return rule?.type === "FIXED_AMOUNT" ? "FIXED_AMOUNT" : "PERCENTAGE";
94
+ /** Which reward an existing combo was saved with; a new one starts on the percentage. */
95
+ export function comboRewardOf(rule: { type: string } | null): ComboReward {
96
+ if (rule?.type === "FIXED_AMOUNT" || rule?.type === "BUNDLE_PRICE") return rule.type;
97
+ return "PERCENTAGE";
106
98
  }
107
99
 
108
100
  /**
@@ -114,17 +106,19 @@ export function comboRewardOf(rule: { type: string } | null): string {
114
106
  * rather than trusted: the toggle is unmounted at combo kinds, so its value is
115
107
  * whatever it was before the operator changed their mind.
116
108
  */
117
- export function typeAndScopeFor(values: {
118
- kind: string;
119
- comboReward: string;
120
- scope: string;
121
- }): { type: DiscountType; scope: DiscountScope } {
122
- if (values.kind === "COMBO") {
123
- const type = values.comboReward === "FIXED_AMOUNT" ? "FIXED_AMOUNT" : "PERCENTAGE";
124
- return { type, scope: "COMBO" };
125
- }
109
+ export function typeAndScopeFor(values: { kind: string; comboReward: string; scope: string }): {
110
+ type: DiscountType;
111
+ scope: DiscountScope;
112
+ } {
113
+ if (values.kind === "COMBO")
114
+ return {
115
+ type: comboRewardOf({ type: values.comboReward }),
116
+ scope: "COMBO",
117
+ };
126
118
  if (values.kind === "FREE_UNITS") return { type: "FREE_UNITS", scope: "COMBO" };
127
- if (values.kind === "BUNDLE_PRICE") return { type: "BUNDLE_PRICE", scope: "COMBO" };
128
119
  const type = values.kind === "FIXED_AMOUNT" ? "FIXED_AMOUNT" : "PERCENTAGE";
129
- return { type, scope: values.scope === "COMBO" ? "ORDER" : (values.scope as DiscountScope) };
120
+ return {
121
+ type,
122
+ scope: values.scope === "COMBO" ? "ORDER" : (values.scope as DiscountScope),
123
+ };
130
124
  }