@12-apps/discounts 1.3.0 → 1.5.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.
@@ -12,8 +12,10 @@ import {
12
12
  } from "@12-apps/ui/form/total-form";
13
13
  import { Stack } from "@12-apps/ui/mui/Stack";
14
14
 
15
+ import type { ComboRequirement } from "../engine/types";
16
+
15
17
  import type { DiscountFormPayload, DiscountsApiClient, DiscountWireRecord, WireTargetGroup } from "./api";
16
- import type { DiscountsWebCopy } from "./copy";
18
+ import { fill, type DiscountsWebCopy } from "./copy";
17
19
  import {
18
20
  DiscountSwitches,
19
21
  LimitFields,
@@ -22,6 +24,7 @@ import {
22
24
  WindowFields,
23
25
  type CurrencyFieldComponent,
24
26
  } from "./discount-form-fields";
27
+ import { comboUnits } from "./combo-slot-builder";
25
28
  import { DiscountTargetPicker } from "./discount-target-picker";
26
29
  import type { DiscountsFormatters } from "./format";
27
30
 
@@ -46,6 +49,9 @@ interface DiscountFormValues extends Record<string, string> {
46
49
  type: string;
47
50
  percentOff: string;
48
51
  amountOff: string;
52
+ bundlePrice: string;
53
+ freeUnits: string;
54
+ maxComboApplications: string;
49
55
  scope: string;
50
56
  trigger: string;
51
57
  code: string;
@@ -67,6 +73,9 @@ const BLANK: DiscountFormValues = {
67
73
  type: "PERCENTAGE",
68
74
  percentOff: "",
69
75
  amountOff: "",
76
+ bundlePrice: "",
77
+ freeUnits: "",
78
+ maxComboApplications: "",
70
79
  scope: "ORDER",
71
80
  trigger: "AUTOMATIC",
72
81
  code: "",
@@ -89,6 +98,10 @@ function initialValues(
89
98
  type: editing.type,
90
99
  percentOff: editing.percentOffBp === null ? "" : formatters.toInput(editing.percentOffBp / 100),
91
100
  amountOff: cents(editing.amountOffCents),
101
+ bundlePrice: cents(editing.bundlePriceCents ?? null),
102
+ freeUnits: editing.freeUnits == null ? "" : String(editing.freeUnits),
103
+ maxComboApplications:
104
+ editing.maxComboApplications == null ? "" : String(editing.maxComboApplications),
92
105
  scope: editing.scope,
93
106
  trigger: editing.trigger,
94
107
  code: editing.code ?? "",
@@ -100,31 +113,125 @@ function initialValues(
100
113
  };
101
114
  }
102
115
 
103
- /** The value half: a rate in (0, 100], or an amount above zero. */
116
+ /** A whole number above zero, as typed. `null` when it is neither. */
117
+ function positiveCount(typed: string): number | null {
118
+ const trimmed = typed.trim();
119
+ if (trimmed === "") return null;
120
+ const value = Number(trimmed);
121
+ return Number.isInteger(value) && value > 0 ? value : null;
122
+ }
123
+
124
+ /** A money field that has to hold something above zero. */
125
+ function checkMoney(
126
+ typed: string,
127
+ field: string,
128
+ message: string,
129
+ formatters: DiscountsFormatters,
130
+ ): Record<string, string> {
131
+ const amount = formatters.parseDecimal(typed);
132
+ return amount === null || amount <= 0 ? { [field]: message } : {};
133
+ }
134
+
135
+ /**
136
+ * The free count, which is the one reward bounded by another field.
137
+ *
138
+ * "Take 3, three free" is a giveaway rather than a promotion, so it is compared
139
+ * against what ONE application of the combo takes out of the cart. The ceiling
140
+ * is named in the message: an operator told only "too many" has to guess the
141
+ * number.
142
+ */
143
+ function checkFreeUnits(
144
+ typed: string,
145
+ slots: readonly ComboRequirement[],
146
+ copy: DiscountsWebCopy,
147
+ ): Record<string, string> {
148
+ const free = positiveCount(typed);
149
+ if (free === null) return { freeUnits: copy.form.invalidFreeUnits };
150
+ const units = comboUnits(slots);
151
+ if (free >= units) {
152
+ return {
153
+ freeUnits: fill(copy.form.freeUnitsExceedCombo, { units, max: Math.max(units - 1, 0) }),
154
+ };
155
+ }
156
+ return {};
157
+ }
158
+
159
+ /** The rate, in the 0–100 an operator thinks in rather than basis points. */
160
+ function checkPercent(
161
+ typed: string,
162
+ formatters: DiscountsFormatters,
163
+ copy: DiscountsWebCopy,
164
+ ): Record<string, string> {
165
+ const percent = formatters.parseDecimal(typed);
166
+ return percent === null || percent <= 0 || percent > 100
167
+ ? { percentOff: copy.form.invalidPercent }
168
+ : {};
169
+ }
170
+
171
+ /** The reward, checked against the input the chosen type actually mounted. */
104
172
  function checkValue(
105
173
  values: DiscountFormValues,
174
+ slots: readonly ComboRequirement[],
106
175
  formatters: DiscountsFormatters,
107
176
  copy: DiscountsWebCopy,
108
177
  ): Record<string, string> {
109
- if (values.type === "PERCENTAGE") {
110
- const percent = formatters.parseDecimal(values.percentOff);
111
- if (percent === null || percent <= 0 || percent > 100) {
112
- return { percentOff: copy.form.invalidPercent };
113
- }
114
- return {};
178
+ if (values.type === "FIXED_AMOUNT") {
179
+ return checkMoney(values.amountOff, "amountOff", copy.form.invalidAmount, formatters);
180
+ }
181
+ if (values.type === "BUNDLE_PRICE") {
182
+ return checkMoney(values.bundlePrice, "bundlePrice", copy.form.invalidBundlePrice, formatters);
115
183
  }
116
- const amount = formatters.parseDecimal(values.amountOff);
117
- return amount === null || amount <= 0 ? { amountOff: copy.form.invalidAmount } : {};
184
+ if (values.type === "FREE_UNITS") return checkFreeUnits(values.freeUnits, slots, copy);
185
+ return checkPercent(values.percentOff, formatters, copy);
186
+ }
187
+
188
+ /**
189
+ * The combo half: the groups themselves, and the per-cart cap.
190
+ *
191
+ * Every rule here is also the server's (`validate-combo.ts`), and deliberately
192
+ * so — this half exists to attach the refusal to the input the operator is
193
+ * looking at, never to be the authority.
194
+ */
195
+ function checkCombo(
196
+ values: DiscountFormValues,
197
+ slots: readonly ComboRequirement[],
198
+ copy: DiscountsWebCopy,
199
+ ): Record<string, string> {
200
+ if (values.scope !== "COMBO") return {};
201
+ if (slots.length === 0) return { scope: copy.form.comboSlotsRequired };
202
+ const errors: Record<string, string> = {};
203
+ if (slots.some((slot) => !Number.isInteger(slot.quantity) || slot.quantity <= 0)) {
204
+ errors.scope = copy.form.invalidComboQuantity;
205
+ } else if (
206
+ slots.some((slot) => slot.categoryIds.length === 0 && slot.menuItemIds.length === 0)
207
+ ) {
208
+ errors.scope = copy.form.comboSlotTargetRequired;
209
+ }
210
+ if (values.maxComboApplications.trim() !== "" && positiveCount(values.maxComboApplications) === null) {
211
+ errors.maxComboApplications = copy.form.invalidMaxComboApplications;
212
+ }
213
+ return errors;
214
+ }
215
+
216
+ /** What the pickers and the builder currently hold, in one argument. */
217
+ interface FormTargets {
218
+ categoryIds: string[];
219
+ menuItemIds: string[];
220
+ comboRequirements: ComboRequirement[];
118
221
  }
119
222
 
120
223
  /** The cross-field rules a per-input schema cannot express. */
121
224
  function validate(
122
225
  values: DiscountFormValues,
123
- targets: { categoryIds: string[]; menuItemIds: string[] },
226
+ targets: FormTargets,
124
227
  formatters: DiscountsFormatters,
125
228
  copy: DiscountsWebCopy,
126
229
  ): Record<string, string> {
127
- const errors: Record<string, string> = { ...checkValue(values, formatters, copy) };
230
+ const slots = targets.comboRequirements;
231
+ const errors: Record<string, string> = {
232
+ ...checkValue(values, slots, formatters, copy),
233
+ ...checkCombo(values, slots, copy),
234
+ };
128
235
  if (values.trigger === "CODE" && values.code.trim() === "") {
129
236
  errors.code = copy.form.codeRequired;
130
237
  }
@@ -148,15 +255,23 @@ function useNonStringState(editing: DiscountWireRecord | null) {
148
255
  const [stackable, setStackable] = useState<boolean>(editing?.stackable ?? true);
149
256
  const [categoryIds, setCategoryIds] = useState<string[]>(editing?.categoryIds ?? []);
150
257
  const [menuItemIds, setMenuItemIds] = useState<string[]>(editing?.menuItemIds ?? []);
258
+ // The combo's groups are the third array, and the one whose ORDER matters:
259
+ // the list the operator built is the list a card reads back, and the server
260
+ // stamps each group's index as its stored `position`.
261
+ const [comboRequirements, setComboRequirements] = useState<ComboRequirement[]>(() =>
262
+ editing?.comboRequirements ? [...editing.comboRequirements] : [],
263
+ );
151
264
  return {
152
265
  active,
153
266
  stackable,
154
267
  categoryIds,
155
268
  menuItemIds,
269
+ comboRequirements,
156
270
  setActive,
157
271
  setStackable,
158
272
  setCategoryIds,
159
273
  setMenuItemIds,
274
+ setComboRequirements,
160
275
  };
161
276
  }
162
277
 
@@ -190,7 +305,11 @@ function makeSubmit(
190
305
  const { api, copy, formatters, editing, onSaved, onError } = props;
191
306
  return async function handleSubmit(values, { setFieldErrors }): Promise<void> {
192
307
  setError(null);
193
- const targets = { categoryIds: extra.categoryIds, menuItemIds: extra.menuItemIds };
308
+ const targets: FormTargets = {
309
+ categoryIds: extra.categoryIds,
310
+ menuItemIds: extra.menuItemIds,
311
+ comboRequirements: extra.comboRequirements,
312
+ };
194
313
  const invalid = validate(values, targets, formatters, copy);
195
314
  if (Object.keys(invalid).length > 0) {
196
315
  setFieldErrors(invalid);
@@ -241,8 +360,10 @@ export function DiscountForm(props: DiscountFormProps): JSX.Element {
241
360
  selection={{
242
361
  categoryIds: extra.categoryIds,
243
362
  menuItemIds: extra.menuItemIds,
363
+ comboRequirements: extra.comboRequirements,
244
364
  onCategoryIdsChange: extra.setCategoryIds,
245
365
  onMenuItemIdsChange: extra.setMenuItemIds,
366
+ onComboRequirementsChange: extra.setComboRequirements,
246
367
  }}
247
368
  />
248
369
  <WindowFields copy={copy} />
@@ -1,23 +1,24 @@
1
1
  "use client";
2
2
 
3
- import { useState, type JSX } from "react";
3
+ import type { JSX } from "react";
4
4
 
5
- import { Autocomplete } from "@12-apps/ui/form/Autocomplete";
6
- import { CategorySelect } from "@12-apps/ui/form/CategorySelect";
7
- import { FormControl, FormLabel, FormMessage } from "@12-apps/ui/form/Form";
8
5
  import { useFormContext } from "@12-apps/ui/form/total-form";
9
6
 
7
+ import type { ComboRequirement } from "../engine/types";
8
+
9
+ import type { WireTargetGroup } from "./api";
10
+ import { ComboSlotBuilder } from "./combo-slot-builder";
10
11
  import { fill, type DiscountsWebCopy } from "./copy";
11
- import type { WireTarget, WireTargetGroup } from "./api";
12
+ import { TargetPickerField } from "./target-picker-field";
12
13
 
13
14
  /**
14
15
  * What a scoped discount points at, picked from the host's own collections.
15
16
  *
16
- * There is no shared multi-select in `@12-apps/ui`, so this composes
17
- * `Autocomplete` in multiple mode — chips plus type-to-filter, the same control
18
- * `AddressAutocomplete` is built on. A collection that NESTS gets
19
- * `CategorySelect` instead: a flat combobox would put a subcategory beside its
20
- * parent with nothing to tell them apart.
17
+ * This file is the ROUTER: it reads the scope and decides which shape the
18
+ * targets take. The controls themselves live next door — one collection's
19
+ * picker in `target-picker-field`, the quantified group list in
20
+ * `combo-slot-builder` — because the combo builder needs the former per group
21
+ * and a file cannot import its own importer.
21
22
  *
22
23
  * Which collections exist is not decided here. The surface reads them from
23
24
  * `GET /discounts/targets`, which answers whatever the host registered — so
@@ -32,62 +33,15 @@ import type { WireTarget, WireTargetGroup } from "./api";
32
33
  * leaving a stale one on screen would suggest otherwise.
33
34
  */
34
35
 
35
- /** One labelled multi-select of targets, with its own input-text state. */
36
- function TargetField({
37
- label,
38
- placeholder,
39
- requiredMessage,
40
- options,
41
- ids,
42
- onChange,
43
- dataTestId,
44
- }: {
45
- label: string;
46
- placeholder: string;
47
- requiredMessage: string;
48
- options: WireTarget[];
49
- ids: readonly string[];
50
- onChange: (next: string[]) => void;
51
- dataTestId: string;
52
- }): JSX.Element {
53
- const [query, setQuery] = useState("");
54
- const byId = new Map(options.map((option) => [option.id, option]));
55
- // An id whose option is missing — a target archived since the rule was saved
56
- // — still renders as a chip, so saving cannot silently drop it.
57
- const selected = ids.map((id) => byId.get(id) ?? { id, name: id });
58
-
59
- return (
60
- <FormControl fullWidth>
61
- <FormLabel error={ids.length === 0}>{label}</FormLabel>
62
- <div data-testid={dataTestId}>
63
- <Autocomplete<WireTarget>
64
- multiple
65
- value={query}
66
- onChange={setQuery}
67
- suggestions={options}
68
- selectedItems={selected}
69
- onSelectedItemsChange={(items) => onChange(items.map((item) => item.id))}
70
- getKey={(option) => option.id}
71
- getLabel={(option) => option.name}
72
- placeholder={placeholder}
73
- inputAriaLabel={label}
74
- />
75
- </div>
76
- {ids.length === 0 && (
77
- <FormMessage error dataTestId={`${dataTestId}-message`}>
78
- {requiredMessage}
79
- </FormMessage>
80
- )}
81
- </FormControl>
82
- );
83
- }
84
-
85
- /** The ids currently chosen for one dimension. */
36
+ /** What the form currently has chosen, in every shape a scope can need. */
86
37
  interface DiscountTargetSelection {
87
38
  categoryIds: string[];
88
39
  menuItemIds: string[];
40
+ /** The combo's groups, in the operator's own order. Empty at every other scope. */
41
+ comboRequirements: readonly ComboRequirement[];
89
42
  onCategoryIdsChange: (next: string[]) => void;
90
43
  onMenuItemIdsChange: (next: string[]) => void;
44
+ onComboRequirementsChange: (next: ComboRequirement[]) => void;
91
45
  }
92
46
 
93
47
  /** Which selection a dimension reads and writes. */
@@ -112,6 +66,12 @@ function scopeFor(targetType: string): string {
112
66
  * Reads the LIVE `scope` from the form context, so flipping the toggle swaps
113
67
  * the picker immediately without the parent form having to mirror the value
114
68
  * into its own state.
69
+ *
70
+ * COMBO is the third branch (FUT-268) and it is a different SHAPE, not another
71
+ * picker: a combo is a list of quantified groups, so it gets the builder and
72
+ * every collection at once, while CATEGORY and ITEM each get exactly one
73
+ * collection's picker. ORDER still renders nothing — an order-wide discount
74
+ * covers everything, so a target list there is not empty, it is meaningless.
115
75
  */
116
76
  export function DiscountTargetPicker({
117
77
  groups,
@@ -124,36 +84,32 @@ export function DiscountTargetPicker({
124
84
  }): JSX.Element | null {
125
85
  const { values } = useFormContext();
126
86
  const scope = values.scope ?? "ORDER";
127
- const group = groups.find((entry) => scopeFor(entry.targetType) === scope);
128
- if (!group) return null;
129
-
130
- const { ids, onChange } = bind(group.targetType, selection);
131
- const label = fill(copy.targets.pick, { collection: group.label });
132
- const dataTestId = `discount-${group.slug}-targets`;
133
87
 
134
- if (group.nests) {
88
+ if (scope === "COMBO") {
135
89
  return (
136
- <CategorySelect
137
- label={label}
138
- fullWidth
139
- allowParentSelection
140
- options={group.targets}
141
- value={ids}
142
- onChange={onChange}
143
- error={ids.length === 0 ? copy.targets.required : undefined}
144
- dataTestId={dataTestId}
90
+ <ComboSlotBuilder
91
+ slots={selection.comboRequirements}
92
+ groups={groups}
93
+ copy={copy}
94
+ onChange={selection.onComboRequirementsChange}
145
95
  />
146
96
  );
147
97
  }
98
+
99
+ const group = groups.find((entry) => scopeFor(entry.targetType) === scope);
100
+ if (!group) return null;
101
+
102
+ const { ids, onChange } = bind(group.targetType, selection);
148
103
  return (
149
- <TargetField
150
- label={label}
104
+ <TargetPickerField
105
+ group={group}
106
+ label={fill(copy.targets.pick, { collection: group.label })}
151
107
  placeholder={fill(copy.targets.search, { collection: group.label })}
152
108
  requiredMessage={copy.targets.required}
153
- options={group.targets}
154
109
  ids={ids}
155
110
  onChange={onChange}
156
- dataTestId={dataTestId}
111
+ copy={copy.categorySelect}
112
+ dataTestId={`discount-${group.slug}-targets`}
157
113
  />
158
114
  );
159
115
  }
@@ -6,8 +6,15 @@ import { useSearchParams } from "react-router-dom";
6
6
  import AddIcon from "@mui/icons-material/Add";
7
7
 
8
8
  import { useServerDataViews } from "@12-apps/app-shell/react";
9
- import { CardActionsProvider, useRowConfirm } from "@12-apps/ui/data-display/CardKit";
10
- import { DataViewsGrid, type DataViewState } from "@12-apps/ui/data-display/DataViews";
9
+ import {
10
+ CardActionsProvider,
11
+ useRowConfirm,
12
+ } from "@12-apps/ui/data-display/CardKit";
13
+ import {
14
+ DataViewsCopyProvider,
15
+ DataViewsGrid,
16
+ type DataViewState,
17
+ } from "@12-apps/ui/data-display/DataViews";
11
18
  import type { RowAction } from "@12-apps/ui/data-display/DataViews";
12
19
  import { ErrorState } from "@12-apps/ui/data-display/ErrorState";
13
20
  import { LoadingState } from "@12-apps/ui/data-display/LoadingState";
@@ -87,6 +94,7 @@ function useBulkDelete(
87
94
  confirmText: copy.actions.delete,
88
95
  },
89
96
  errorText: copy.actions.deleteFailed,
97
+ copy: copy.confirmAction,
90
98
  dataTestId: "discount-bulk-delete-confirm",
91
99
  });
92
100
  }
@@ -103,7 +111,9 @@ function HeaderControls({
103
111
  }): JSX.Element {
104
112
  return (
105
113
  <>
106
- <Dashboard.Info title={copy.screen.aboutTitle}>{copy.screen.aboutBody}</Dashboard.Info>
114
+ <Dashboard.Info title={copy.screen.aboutTitle}>
115
+ {copy.screen.aboutBody}
116
+ </Dashboard.Info>
107
117
  <Dashboard.Spacer />
108
118
  <Dashboard.Export
109
119
  formats={[
@@ -234,6 +244,68 @@ function CreateDialog({
234
244
  );
235
245
  }
236
246
 
247
+ /**
248
+ * Everything below the header: the create dialog and the grid.
249
+ *
250
+ * Split out of {@link DiscountsScreen} only to keep that component inside the
251
+ * size gate — this is one tree, not a second concern.
252
+ */
253
+ function ScreenBody({
254
+ createOpen,
255
+ setCreateOpen,
256
+ menu,
257
+ refresh,
258
+ rows,
259
+ copy,
260
+ appliedState,
261
+ server,
262
+ onVisibleRowsChange,
263
+ bulkDelete,
264
+ }: {
265
+ createOpen: boolean;
266
+ setCreateOpen: (open: boolean) => void;
267
+ menu: MenuBinding;
268
+ refresh: () => void;
269
+ rows: DiscountListItem[];
270
+ copy: DiscountsWebCopy;
271
+ appliedState: DataViewState;
272
+ server: ReturnType<typeof useServerDataViews>;
273
+ onVisibleRowsChange: (rows: DiscountListItem[]) => void;
274
+ bulkDelete: ReturnType<typeof useBulkDelete>;
275
+ }): JSX.Element {
276
+ return (
277
+ <Dashboard.Body>
278
+ <CreateDialog
279
+ open={createOpen}
280
+ menu={menu}
281
+ onClose={() => setCreateOpen(false)}
282
+ onSaved={() => {
283
+ setCreateOpen(false);
284
+ refresh();
285
+ }}
286
+ />
287
+ <CardActionsProvider
288
+ // The menus read the refresh and the error channel from here; the
289
+ // tenant is already baked into `apiBase`, so there is nothing for a
290
+ // slug to do but be a second place it could disagree.
291
+ tenantSlug=""
292
+ onRefresh={refresh}
293
+ errorTitle={copy.actions.actionFailed}
294
+ >
295
+ <DiscountsGrid
296
+ rows={rows}
297
+ copy={copy}
298
+ menu={menu}
299
+ appliedState={appliedState}
300
+ server={server}
301
+ onVisibleRowsChange={onVisibleRowsChange}
302
+ bulkDelete={bulkDelete}
303
+ />
304
+ </CardActionsProvider>
305
+ </Dashboard.Body>
306
+ );
307
+ }
308
+
237
309
  export function DiscountsScreen(props: DiscountsScreenProps): JSX.Element {
238
310
  const { api, copy, formatters, currencyField, onError, breadcrumb } = props;
239
311
  const [searchParams] = useSearchParams();
@@ -246,12 +318,16 @@ export function DiscountsScreen(props: DiscountsScreenProps): JSX.Element {
246
318
  // "now"s could disagree about which of them is still running.
247
319
  const rows = useMemo(() => {
248
320
  const now = new Date();
249
- return (data.page?.data ?? []).map((record) => toListItem(record, formatters, copy, now));
321
+ return (data.page?.data ?? []).map((record) =>
322
+ toListItem(record, formatters, copy, now),
323
+ );
250
324
  }, [data.page, formatters, copy]);
251
325
 
252
326
  // Seeded ONCE from the URL. Re-applying it on every render would wipe the
253
327
  // operator's column-visibility choices each time the query synced back.
254
- const [appliedState] = useState<DataViewState>(() => discountsAppliedState(searchParams, copy));
328
+ const [appliedState] = useState<DataViewState>(() =>
329
+ discountsAppliedState(searchParams, copy),
330
+ );
255
331
 
256
332
  const server = useServerDataViews({
257
333
  totalCount: data.page?.pagination.total ?? 0,
@@ -260,7 +336,14 @@ export function DiscountsScreen(props: DiscountsScreenProps): JSX.Element {
260
336
  toParams: discountsQueryToParams,
261
337
  });
262
338
 
263
- const menu: MenuBinding = { api, copy, formatters, currencyField, groups: data.groups, onError };
339
+ const menu: MenuBinding = {
340
+ api,
341
+ copy,
342
+ formatters,
343
+ currencyField,
344
+ groups: data.groups,
345
+ onError,
346
+ };
264
347
 
265
348
  if (data.loading) return <LoadingState dataTestId="discounts-loading" />;
266
349
  if (data.error !== null) {
@@ -275,40 +358,31 @@ export function DiscountsScreen(props: DiscountsScreenProps): JSX.Element {
275
358
  }
276
359
 
277
360
  return (
278
- <Dashboard testIdPrefix="discounts-dashboard">
279
- {breadcrumb && <Dashboard.Breadcrumb items={[...breadcrumb]} />}
280
- <Dashboard.Header title={copy.screen.title}>
281
- <HeaderControls rows={visibleRows} copy={copy} onCreate={() => setCreateOpen(true)} />
282
- </Dashboard.Header>
283
- <Dashboard.Body>
284
- <CreateDialog
285
- open={createOpen}
286
- menu={menu}
287
- onClose={() => setCreateOpen(false)}
288
- onSaved={() => {
289
- setCreateOpen(false);
290
- data.refresh();
291
- }}
292
- />
293
- <CardActionsProvider
294
- // The menus read the refresh and the error channel from here; the
295
- // tenant is already baked into `apiBase`, so there is nothing for a
296
- // slug to do but be a second place it could disagree.
297
- tenantSlug=""
298
- onRefresh={data.refresh}
299
- errorTitle={copy.actions.actionFailed}
300
- >
301
- <DiscountsGrid
302
- rows={rows}
361
+ // The grid, its toolbar and its saved-view dialogs read their words from
362
+ // here: `@12-apps/ui` ships none and throws rather than falling back.
363
+ <DataViewsCopyProvider copy={copy.dataViews}>
364
+ <Dashboard testIdPrefix="discounts-dashboard">
365
+ {breadcrumb && <Dashboard.Breadcrumb items={[...breadcrumb]} />}
366
+ <Dashboard.Header title={copy.screen.title}>
367
+ <HeaderControls
368
+ rows={visibleRows}
303
369
  copy={copy}
304
- menu={menu}
305
- appliedState={appliedState}
306
- server={server}
307
- onVisibleRowsChange={setVisibleRows}
308
- bulkDelete={bulkDelete}
370
+ onCreate={() => setCreateOpen(true)}
309
371
  />
310
- </CardActionsProvider>
311
- </Dashboard.Body>
312
- </Dashboard>
372
+ </Dashboard.Header>
373
+ <ScreenBody
374
+ createOpen={createOpen}
375
+ setCreateOpen={setCreateOpen}
376
+ menu={menu}
377
+ refresh={data.refresh}
378
+ rows={rows}
379
+ copy={copy}
380
+ appliedState={appliedState}
381
+ server={server}
382
+ onVisibleRowsChange={setVisibleRows}
383
+ bulkDelete={bulkDelete}
384
+ />
385
+ </Dashboard>
386
+ </DataViewsCopyProvider>
313
387
  );
314
388
  }
@@ -1,3 +1,9 @@
1
+ import {
2
+ PT_BR_CATEGORY_SELECT_COPY,
3
+ PT_BR_CONFIRM_ACTION_COPY,
4
+ PT_BR_DATA_VIEWS_COPY,
5
+ } from "@12-apps/ui/pt-BR";
6
+
1
7
  import type { DiscountsWebCopy } from "./copy";
2
8
 
3
9
  /**
@@ -75,6 +81,10 @@ export const PT_BR_DISCOUNTS_WEB_COPY: DiscountsWebCopy = {
75
81
  percentOff: "Desconto (%)",
76
82
  percentPlaceholder: "10",
77
83
  amountOff: "Valor do desconto",
84
+ bundlePrice: "Preço do combo",
85
+ freeUnits: "Itens grátis",
86
+ freeUnitsHint:
87
+ "Quantas unidades do combo saem de graça. Em um grupo de 3, deixar 1 grátis é o clássico \"leve 3, pague 2\".",
78
88
  trigger: "Ativação",
79
89
  code: "Código do cupom",
80
90
  codePlaceholder: "BEMVINDO10",
@@ -84,6 +94,8 @@ export const PT_BR_DISCOUNTS_WEB_COPY: DiscountsWebCopy = {
84
94
  minSubtotal: "Pedido mínimo",
85
95
  usageLimit: "Limite de usos",
86
96
  perBuyerLimit: "Limite por cliente",
97
+ maxComboApplications: "Combos por pedido",
98
+ maxComboApplicationsHint: "Deixe em branco para valer quantas vezes couber no carrinho.",
87
99
  active: "Ativo",
88
100
  activeHint: "Desligue para pausar a promoção sem excluí-la.",
89
101
  stackable: "Acumulável",
@@ -94,11 +106,30 @@ export const PT_BR_DISCOUNTS_WEB_COPY: DiscountsWebCopy = {
94
106
  nameRequired: "Informe o nome da promoção.",
95
107
  invalidPercent: "Informe uma porcentagem maior que 0 e no máximo 100.",
96
108
  invalidAmount: "Informe um valor de desconto maior que zero.",
109
+ invalidBundlePrice: "Informe o preço do combo, maior que zero.",
110
+ invalidFreeUnits: "Informe quantos itens saem de graça, a partir de 1.",
111
+ freeUnitsExceedCombo:
112
+ "O combo tem {units} itens; deixe grátis no máximo {max} para ainda ser uma promoção.",
97
113
  codeRequired: "Informe o código do cupom que o cliente vai digitar.",
98
114
  categoryTargetRequired: "Selecione ao menos uma categoria para este desconto.",
99
115
  itemTargetRequired: "Selecione ao menos um produto para este desconto.",
116
+ comboSlotsRequired: "Monte o combo: adicione ao menos um grupo de itens.",
117
+ comboSlotTargetRequired: "Escolha o que pode entrar em cada grupo do combo.",
118
+ invalidComboQuantity: "Informe quantas unidades cada grupo leva, a partir de 1.",
119
+ invalidMaxComboApplications: "Informe quantos combos cabem por pedido, a partir de 1.",
100
120
  endsBeforeStarts: "A data de término deve ser posterior à data de início.",
101
121
  },
122
+ combo: {
123
+ title: "Monte o combo",
124
+ hint: "Cada grupo é uma parte do combo: quantas unidades leva e o que pode entrar nela.",
125
+ addSlot: "Adicionar grupo",
126
+ removeSlot: "Remover o grupo {position}",
127
+ slot: "Grupo {position}",
128
+ quantity: "Quantidade",
129
+ pick: "{collection} deste grupo",
130
+ empty: "Nenhum grupo ainda. Adicione o primeiro para montar o combo.",
131
+ summary: "O combo leva {units} itens em {groups} grupos.",
132
+ },
102
133
  targets: {
103
134
  pick: "{collection} com desconto",
104
135
  search: "Buscar em {collection}…",
@@ -129,4 +160,7 @@ export const PT_BR_DISCOUNTS_WEB_COPY: DiscountsWebCopy = {
129
160
  perBuyerLimit: "Limite por cliente",
130
161
  unlimited: "Sem limite",
131
162
  },
163
+ confirmAction: PT_BR_CONFIRM_ACTION_COPY,
164
+ dataViews: PT_BR_DATA_VIEWS_COPY,
165
+ categorySelect: PT_BR_CATEGORY_SELECT_COPY,
132
166
  };