@12-apps/discounts 1.16.0 → 1.17.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.
@@ -13,7 +13,9 @@ 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, type DiscountKind } from "./form-kind";
16
+ import { COMBO_REWARDS, isComboKind, SELECTABLE_DISCOUNT_SCOPES } from "./form-kind";
17
+ import { DateField } from "./date-field";
18
+ import { PercentField, WholeNumberField } from "./number-fields";
17
19
  import { ScheduleBuilder } from "./schedule-builder";
18
20
 
19
21
  /**
@@ -49,14 +51,14 @@ function HintedField({
49
51
  }): JSX.Element {
50
52
  return (
51
53
  <Stack spacing={0.5}>
52
- <Fields.TextField name={name} label={label} type="number" />
54
+ <WholeNumberField name={name} label={label} />
53
55
  <Text variant="caption">{hint}</Text>
54
56
  </Stack>
55
57
  );
56
58
  }
57
59
 
58
60
  /** One closed set as a toggle's options, labelled by the pack. */
59
- function optionsFor<TKey extends string>(
61
+ export function optionsFor<TKey extends string>(
60
62
  values: readonly TKey[],
61
63
  labels: Readonly<Record<TKey, string>>,
62
64
  ): { value: string; label: string }[] {
@@ -73,29 +75,6 @@ function optionsFor<TKey extends string>(
73
75
  */
74
76
  export type CurrencyFieldComponent = (props: { name: string; label: string }) => JSX.Element;
75
77
 
76
- /**
77
- * What kind of promotion this is — the form's first and most consequential
78
- * question, and the only one that decides what the rest of it looks like.
79
- *
80
- * The option list comes from the caller rather than from the constant, because
81
- * it is one longer when a legacy `BUNDLE_PRICE` rule is being edited.
82
- */
83
- export function KindField({
84
- copy,
85
- kinds,
86
- }: {
87
- copy: DiscountsWebCopy;
88
- kinds: readonly DiscountKind[];
89
- }): JSX.Element {
90
- return (
91
- <Fields.ToggleField
92
- name="kind"
93
- label={copy.form.kind}
94
- options={optionsFor(kinds, copy.labels.kind)}
95
- />
96
- );
97
- }
98
-
99
78
  /**
100
79
  * A combo's reward: which of the two, and how much.
101
80
  *
@@ -125,11 +104,7 @@ function ComboRewardFields({
125
104
  {values.comboReward === "FIXED_AMOUNT" ? (
126
105
  <Currency name="amountOff" label={copy.form.amountOff} />
127
106
  ) : (
128
- <Fields.TextField
129
- name="percentOff"
130
- label={copy.form.percentOff}
131
- placeholder={copy.form.percentPlaceholder}
132
- />
107
+ <PercentField copy={copy} />
133
108
  )}
134
109
  </Box>
135
110
  <Text variant="caption">{copy.form.comboRewardHint}</Text>
@@ -164,13 +139,7 @@ export function RewardFields({
164
139
  if (kind === "FREE_UNITS") return null;
165
140
  if (kind === "BUNDLE_PRICE") return <Currency name="bundlePrice" label={copy.form.bundlePrice} />;
166
141
  if (kind === "FIXED_AMOUNT") return <Currency name="amountOff" label={copy.form.amountOff} />;
167
- return (
168
- <Fields.TextField
169
- name="percentOff"
170
- label={copy.form.percentOff}
171
- placeholder={copy.form.percentPlaceholder}
172
- />
173
- );
142
+ return <PercentField copy={copy} />;
174
143
  }
175
144
 
176
145
  /** The coupon input, shown only for a CODE-triggered rule. */
@@ -252,6 +221,24 @@ export function ComboCapField({ copy }: { copy: DiscountsWebCopy }): JSX.Element
252
221
  );
253
222
  }
254
223
 
224
+ /** Início and Término, day-first on every browser (FUT-4156). */
225
+ function PeriodFields({ copy }: { copy: DiscountsWebCopy }): JSX.Element {
226
+ return (
227
+ <Box sx={{ display: "grid", gap: 2, gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" } }}>
228
+ {(["startsAt", "endsAt"] as const).map((name) => (
229
+ <DateField
230
+ key={name}
231
+ name={name}
232
+ label={copy.form[name]}
233
+ placeholder={copy.form.datePlaceholder}
234
+ invalidMessage={copy.form.invalidDate}
235
+ incompleteMessage={copy.form.dateIncomplete}
236
+ />
237
+ ))}
238
+ </Box>
239
+ );
240
+ }
241
+
255
242
  /**
256
243
  * "Quando vale" — the campaign PERIOD, and the weekly schedule inside it
257
244
  * (FUT-996).
@@ -281,14 +268,19 @@ export function WindowFields({
281
268
  onScheduleChange: (next: DiscountSchedule) => void;
282
269
  onScheduleEnabledChange: (next: boolean) => void;
283
270
  }): JSX.Element {
271
+ // The schedule is not a form value, so nothing paints `errors.schedule` for
272
+ // it: it is handed to the builder, and cleared on the next edit the way a
273
+ // field's own error is (FUT-4155).
274
+ const { errors, setFieldValue } = useFormContext();
275
+ const scheduleError = errors.schedule;
276
+ const clearScheduleError = (): void => {
277
+ if (scheduleError !== undefined) setFieldValue("schedule", "");
278
+ };
284
279
  return (
285
280
  <Stack spacing={1.5}>
286
281
  <Text variant="heading">{copy.schedule.sectionTitle}</Text>
287
282
  <Stack spacing={0.5}>
288
- <Box sx={{ display: "grid", gap: 2, gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" } }}>
289
- <Fields.TextField name="startsAt" label={copy.form.startsAt} type="date" />
290
- <Fields.TextField name="endsAt" label={copy.form.endsAt} type="date" />
291
- </Box>
283
+ <PeriodFields copy={copy} />
292
284
  <Text variant="caption">{copy.schedule.periodHint}</Text>
293
285
  </Stack>
294
286
  <FormControl>
@@ -303,7 +295,10 @@ export function WindowFields({
303
295
  type="radio"
304
296
  name="discount-repetition"
305
297
  checked={scheduleEnabled === value}
306
- onChange={() => onScheduleEnabledChange(value)}
298
+ onChange={() => {
299
+ clearScheduleError();
300
+ onScheduleEnabledChange(value);
301
+ }}
307
302
  data-testid={value ? "repetition-specific" : "repetition-always"}
308
303
  />
309
304
  <Text variant="body">{label}</Text>
@@ -317,7 +312,11 @@ export function WindowFields({
317
312
  schedule={schedule}
318
313
  copy={copy}
319
314
  timezone={timezoneLabel ?? ""}
320
- onChange={onScheduleChange}
315
+ error={scheduleError}
316
+ onChange={(next) => {
317
+ clearScheduleError();
318
+ onScheduleChange(next);
319
+ }}
321
320
  />
322
321
  <OrderScopeScheduleNote copy={copy} />
323
322
  </>
@@ -355,8 +354,8 @@ export function LimitFields({
355
354
  return (
356
355
  <Box sx={{ display: "grid", gap: 2, gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr 1fr" } }}>
357
356
  <Currency name="minSubtotal" label={copy.form.minSubtotal} />
358
- <Fields.TextField name="usageLimit" label={copy.form.usageLimit} type="number" />
359
- <Fields.TextField name="perBuyerLimit" label={copy.form.perBuyerLimit} type="number" />
357
+ <WholeNumberField name="usageLimit" label={copy.form.usageLimit} />
358
+ <WholeNumberField name="perBuyerLimit" label={copy.form.perBuyerLimit} />
360
359
  </Box>
361
360
  );
362
361
  }
@@ -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. */
@@ -134,6 +165,28 @@ function checkCombo(
134
165
  return errors;
135
166
  }
136
167
 
168
+ /**
169
+ * Início and Término. Each is blank or a whole real ISO day — the date box
170
+ * holds its typed text until it is one (FUT-4156) — and only two real days are
171
+ * compared. The window is half-open [start, end): equal dates are an EMPTY
172
+ * window, so the rule is "after", not "not before".
173
+ */
174
+ function checkPeriod(values: DiscountFormValues, copy: DiscountsWebCopy): Record<string, string> {
175
+ const errors: Record<string, string> = {};
176
+ for (const name of ["startsAt", "endsAt"] as const) {
177
+ if (values[name] === "" || isIsoDay(values[name])) continue;
178
+ // A whole DD/MM/AAAA that is no day does not exist; anything shorter is
179
+ // simply not finished, and is told so.
180
+ errors[name] = isCompleteMaskedDay(values[name])
181
+ ? copy.form.invalidDate
182
+ : copy.form.dateIncomplete;
183
+ }
184
+ if (isIsoDay(values.startsAt) && isIsoDay(values.endsAt) && values.endsAt <= values.startsAt) {
185
+ errors.endsAt = copy.form.endsBeforeStarts;
186
+ }
187
+ return errors;
188
+ }
189
+
137
190
  /** What the pickers and the builder currently hold, in one argument. */
138
191
  export interface FormTargets {
139
192
  categoryIds: string[];
@@ -164,13 +217,19 @@ function checkSchedule(
164
217
  if (windows.some((window) => window.days.length === 0)) {
165
218
  return { schedule: copy.schedule.daysRequired };
166
219
  }
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 } : {};
220
+ const ends = windows.flatMap((window) => [window.from, window.to]);
221
+ // Unfinished first: "1:00" mid-edit is not a pair of equal ends, it is a
222
+ // time nobody has finished typing.
223
+ if (ends.some((end) => !/^\d{2}:\d{2}$/u.test(end))) {
224
+ return { schedule: copy.schedule.timeIncomplete };
225
+ }
226
+ // Then a whole HH:MM that no day has (25:00), in the clock's own words —
227
+ // "start and end must differ" would be untrue of it.
228
+ if (ends.some((end) => toMinutes(end) === null)) {
229
+ return { schedule: copy.schedule.invalidTime };
230
+ }
231
+ const sameEnds = windows.some((window) => window.from === window.to);
232
+ return sameEnds ? { schedule: copy.schedule.timesRequired } : {};
174
233
  }
175
234
 
176
235
  /** The schedule editor's state: its switch and its rows, kept separate. */
@@ -187,8 +246,13 @@ export function validate(
187
246
  scheduleState: ScheduleState,
188
247
  ): Record<string, string> {
189
248
  const slots = targets.comboRequirements;
249
+ // Every rule in ONE pass, the name's included (FUT-4143): the name used to be
250
+ // the container's schema, which stops before this function runs, so a 150%
251
+ // sat unmarked beside a red name until the name was fixed.
190
252
  const errors: Record<string, string> = {
253
+ ...checkName(values.name, copy),
191
254
  ...checkValue(values, slots, formatters, copy),
255
+ ...checkLimits(values, copy),
192
256
  ...checkCombo(values, slots, copy),
193
257
  ...checkSchedule(scheduleState, copy),
194
258
  };
@@ -202,11 +266,7 @@ export function validate(
202
266
  if (scope === "ITEM" && targets.menuItemIds.length === 0) {
203
267
  errors.scope = copy.form.itemTargetRequired;
204
268
  }
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
- }
269
+ Object.assign(errors, checkPeriod(values, copy));
210
270
  return errors;
211
271
  }
212
272
 
@@ -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,
@@ -30,6 +28,7 @@ import {
30
28
  type CurrencyFieldComponent,
31
29
  } from "./discount-form-fields";
32
30
  import { DiscountTargetPicker } from "./discount-target-picker";
31
+ import { KindField } from "./kind-field";
33
32
  import { blankWindow } from "./schedule-builder";
34
33
  import { validate, type FormTargets } from "./discount-form-validate";
35
34
  import { initialValues, type DiscountFormValues } from "./discount-form-values";
@@ -147,8 +146,9 @@ function makeSubmit(
147
146
  schedule: extra.schedule,
148
147
  });
149
148
  if (Object.keys(invalid).length > 0) {
149
+ // ONE message for a client refusal: the red fields and the toast
150
+ // (FUT-4143). The `saveFailed` alert is the SERVER's, below.
150
151
  setFieldErrors(invalid);
151
- setError(copy.form.reviewFields);
152
152
  return;
153
153
  }
154
154
  const payload: DiscountFormPayload = {
@@ -188,8 +188,10 @@ export function DiscountForm(props: DiscountFormProps): JSX.Element {
188
188
 
189
189
  return (
190
190
  <FormContainer<DiscountFormValues>
191
+ // No `schema`: the container runs one before `onSubmit` and stops when
192
+ // it fails, which judged the name alone. Every rule, the name's too,
193
+ // lives in `validate` so one Criar marks every field (FUT-4143).
191
194
  initialValues={initialValues(editing, formatters)}
192
- schema={{ name: [required(copy.form.nameRequired), minLength(2)] }}
193
195
  onSubmit={handleSubmit}
194
196
  dataTestId="discount-form"
195
197
  >
@@ -245,7 +247,7 @@ export function DiscountForm(props: DiscountFormProps): JSX.Element {
245
247
  {editing ? copy.form.submitEdit : copy.form.submitCreate}
246
248
  </SubmitButton>
247
249
  </Stack>
248
- <FormErrorSnackbar />
250
+ <FormErrorSnackbar message={copy.form.errorSnackbar} />
249
251
  </FormContainer>
250
252
  );
251
253
  }
@@ -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,6 +132,8 @@ 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: {
@@ -172,8 +175,13 @@ export const EN_US_DISCOUNTS_WEB_COPY: DiscountsWebCopy = {
172
175
  firstOrderOnlyHint:
173
176
  "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
177
  reviewFields: "Check the highlighted fields.",
178
+ errorSnackbar: EN_US_UI_LABELS.formErrorSnackbar.message,
175
179
  saveFailed: "Could not save the discount",
176
180
  nameRequired: "Give the promotion a name.",
181
+ nameTooShort: "The name needs at least 2 characters.",
182
+ datePlaceholder: "DD/MM/YYYY",
183
+ invalidDate: "That date does not exist. Use DD/MM/YYYY, such as 25/12/2026.",
184
+ dateIncomplete: "Finish the date: DD/MM/YYYY.",
177
185
  invalidPercent: "Enter a percentage greater than 0 and at most 100.",
178
186
  invalidAmount: "Enter a discount amount greater than zero.",
179
187
  invalidBundlePrice: "Enter the bundle price, greater than zero.",
@@ -188,6 +196,9 @@ export const EN_US_DISCOUNTS_WEB_COPY: DiscountsWebCopy = {
188
196
  comboSlotTargetRequired: "Choose what can go in each group of the bundle.",
189
197
  invalidComboQuantity: "Enter how many units each group takes, from 1 upwards.",
190
198
  invalidMaxComboApplications: "Enter how many bundles fit in one order, from 1 upwards.",
199
+ invalidUsageLimit: "Enter a whole number of uses from 1 upwards, or leave it blank.",
200
+ invalidPerBuyerLimit: "Enter a whole number per customer from 1 upwards, or leave it blank.",
201
+ wholeNumberOnly: "Whole numbers only, no decimals.",
191
202
  endsBeforeStarts: "The end date must be after the start date.",
192
203
  },
193
204
  combo: {
@@ -201,7 +212,11 @@ export const EN_US_DISCOUNTS_WEB_COPY: DiscountsWebCopy = {
201
212
  // sentence is built around it rather than naming a noun of its own.
202
213
  pick: "{collection} in this group",
203
214
  empty: "No groups yet. Add the first one to build the bundle.",
204
- summary: "The bundle takes {units} items across {groups} groups.",
215
+ summary: "The bundle takes {units} across {groups}.",
216
+ unitsOne: "{count} item",
217
+ unitsOther: "{count} items",
218
+ groupsOne: "{count} group",
219
+ groupsOther: "{count} groups",
205
220
  freeUnitsTitle: "Buy more, pay less",
206
221
  freeUnitsHint:
207
222
  'Choose the products, how many the customer takes and how many are free — the classic "buy 3, pay for 2".',
@@ -3,7 +3,6 @@
3
3
  import type { JSX } from "react";
4
4
 
5
5
  import { FormControl, FormLabel, FormMessage } from "@12-apps/ui/form/Form";
6
- import { Input } from "@12-apps/ui/form/Input";
7
6
  import { Box } from "@12-apps/ui/mui/Box";
8
7
  import { Stack } from "@12-apps/ui/mui/Stack";
9
8
  import { Text } from "@12-apps/ui/typography/Text";
@@ -13,6 +12,7 @@ import type { ComboRequirement } from "../engine/types";
13
12
  import type { WireTargetGroup } from "./api";
14
13
  import { blankSlot } from "./combo-slot-builder";
15
14
  import { fill, type DiscountsWebCopy } from "./copy";
15
+ import { CountInput, WholeNumberInput } from "./number-fields";
16
16
  import { TargetPickerField } from "./target-picker-field";
17
17
 
18
18
  /**
@@ -41,6 +41,11 @@ function onlySlot(slots: readonly ComboRequirement[]): ComboRequirement {
41
41
  return slots[0] ?? blankSlot();
42
42
  }
43
43
 
44
+ /** Typed with something besides digits: marked as it is typed, like the other counts. */
45
+ function notDigits(typed: string): boolean {
46
+ return typed.trim() !== "" && !/^\d+$/u.test(typed.trim());
47
+ }
48
+
44
49
  /** How many the customer pays for: what they take, minus what is free. */
45
50
  function paidUnits(taken: number, free: number): number {
46
51
  return Math.max(taken - free, 0);
@@ -84,20 +89,22 @@ function FreeUnitsCounts({
84
89
  }): JSX.Element {
85
90
  return (
86
91
  <Box sx={{ display: "grid", gap: 2, gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" } }}>
87
- <Input
88
- type="number"
92
+ <CountInput
89
93
  label={copy.combo.buyQuantity}
90
- value={String(quantity)}
91
- onChange={(event) => onQuantity(Number(event.target.value))}
92
- data-testid="free-units-quantity"
94
+ value={quantity}
95
+ copy={copy}
96
+ invalidMessage={copy.form.invalidComboQuantity}
97
+ dataTestId="free-units-quantity"
98
+ onChange={onQuantity}
93
99
  />
94
- <Input
95
- type="number"
100
+ {/* Held as the form's string, so "2,5" stays as typed and the validator
101
+ refuses it with `invalidFreeUnits` below the builder. */}
102
+ <WholeNumberInput
96
103
  label={copy.form.freeUnits}
97
104
  value={freeUnits}
98
- onChange={(event) => onFreeUnits(event.target.value)}
99
- error={error !== undefined}
100
- data-testid="free-units-free"
105
+ error={error !== undefined || notDigits(freeUnits)}
106
+ dataTestId="free-units-free"
107
+ onChange={onFreeUnits}
101
108
  />
102
109
  </Box>
103
110
  );
@@ -160,9 +167,9 @@ export function FreeUnitsBuilder({
160
167
  autocompleteCopy={copy.autocomplete}
161
168
  dataTestId="free-units-items"
162
169
  />
163
- {error !== undefined && (
170
+ {(error !== undefined || notDigits(freeUnits)) && (
164
171
  <FormMessage error dataTestId="free-units-free-message">
165
- {error}
172
+ {error ?? copy.form.wholeNumberOnly}
166
173
  </FormMessage>
167
174
  )}
168
175
  {empty ? (
@@ -0,0 +1,67 @@
1
+ "use client";
2
+
3
+ import { useLayoutEffect, useRef, useState, type JSX, type RefObject } from "react";
4
+
5
+ import { Fields } from "@12-apps/ui/form/total-form";
6
+ import { Box } from "@12-apps/ui/mui/Box";
7
+
8
+ import type { DiscountsWebCopy } from "./copy";
9
+ import { optionsFor } from "./discount-form-fields";
10
+ import type { DiscountKind } from "./form-kind";
11
+
12
+ /**
13
+ * What kind of promotion this is — the form's first and most consequential
14
+ * question, and the only one that decides what the rest of it looks like.
15
+ *
16
+ * The option list comes from the caller rather than from the constant, because
17
+ * it is one longer when a legacy `BUNDLE_PRICE` rule is being edited.
18
+ *
19
+ * **One line, at every width** (FUT-4157, ADR 0190 in the host). Four equal
20
+ * parts need about 530 px before "Porcentagem" and "Itens grátis" wrap inside
21
+ * their buttons; at 320 and 390 the toggle wrapped every label AND pushed the
22
+ * page to 422 px. Where the FORM is narrower than its parts need, the same
23
+ * question is asked with a select: same field, same options in the same order,
24
+ * same refusal painted under it. What is measured is the form's own width, not
25
+ * the window's — the form also lives in a dialog narrower than the screen.
26
+ */
27
+
28
+ /** The widest pt-BR kind label at the toggle's type size (132 px measured), plus its padding. */
29
+ const PART_MIN_PX = 136;
30
+
31
+ /** The element's own width as it changes; `null` until measured, or where nothing can measure. */
32
+ function useElementWidth(ref: RefObject<HTMLElement | null>): number | null {
33
+ const [width, setWidth] = useState<number | null>(null);
34
+ useLayoutEffect(() => {
35
+ const element = ref.current;
36
+ if (element === null || typeof ResizeObserver === "undefined") return undefined;
37
+ const observer = new ResizeObserver((entries) => {
38
+ const measured = entries[0]?.contentRect.width;
39
+ if (measured !== undefined && measured > 0) setWidth(measured);
40
+ });
41
+ observer.observe(element);
42
+ return () => observer.disconnect();
43
+ }, [ref]);
44
+ return width;
45
+ }
46
+
47
+ export function KindField({
48
+ copy,
49
+ kinds,
50
+ }: {
51
+ copy: DiscountsWebCopy;
52
+ kinds: readonly DiscountKind[];
53
+ }): JSX.Element {
54
+ const box = useRef<HTMLDivElement>(null);
55
+ const width = useElementWidth(box);
56
+ const options = optionsFor(kinds, copy.labels.kind);
57
+ const fits = width === null || width >= kinds.length * PART_MIN_PX;
58
+ return (
59
+ <Box ref={box} sx={{ minWidth: 0 }}>
60
+ {fits ? (
61
+ <Fields.ToggleField name="kind" label={copy.form.kind} options={options} />
62
+ ) : (
63
+ <Fields.SelectField name="kind" label={copy.form.kind} options={options} />
64
+ )}
65
+ </Box>
66
+ );
67
+ }