@12-apps/discounts 1.4.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@12-apps/discounts",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "type": "module",
5
5
  "description": "The discount/promotion domain: a host-agnostic evaluator (percentage or fixed amount, order/category/item scope, automatic or coupon-triggered, stacking and exclusivity, integer-cent allocation) plus the admin CRUD surface as wiring-contract route descriptors over a store port. No database, no framework, no baked user copy.",
6
6
  "exports": {
@@ -31,7 +31,7 @@
31
31
  "@12-apps/app-shell": "^5.1.0",
32
32
  "@12-apps/forms-core": "^1.20.0",
33
33
  "@12-apps/shared-helpers": "^2.0.2",
34
- "@12-apps/ui": "^6.2.0",
34
+ "@12-apps/ui": "^6.3.0",
35
35
  "@mui/icons-material": "^6.5.0",
36
36
  "zod": "^4.3.5"
37
37
  },
@@ -136,6 +136,7 @@ function ComboSlotRow({
136
136
  requiredMessage={undefined}
137
137
  ids={idsFor(slot, group.targetType)}
138
138
  onChange={(ids) => onChange(withIds(slot, group.targetType, ids))}
139
+ copy={copy.categorySelect}
139
140
  dataTestId={`combo-slot-${index}-${group.slug}`}
140
141
  />
141
142
  ))}
package/src/react/copy.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { CategorySelectCopy, ConfirmActionCopy } from "@12-apps/ui/copy";
2
+ import type { DataViewsCopy } from "@12-apps/ui/data-display/DataViews";
1
3
  import type {
2
4
  DiscountScope,
3
5
  DiscountTrigger,
@@ -245,6 +247,17 @@ export interface DiscountsWebCopy {
245
247
  readonly targets: DiscountsTargetCopy;
246
248
  readonly actions: DiscountsActionsCopy;
247
249
  readonly card: DiscountsCardCopy;
250
+ /**
251
+ * The words of the `@12-apps/ui` components these screens MOUNT but do not
252
+ * own — the confirmation popup and the category picker.
253
+ *
254
+ * They arrive here because that package stopped shipping defaults for them
255
+ * (FUT-760): a design system's Portuguese reached every adopter silently,
256
+ * so its copy is required config now and this surface is one of its hosts.
257
+ */
258
+ readonly confirmAction: ConfirmActionCopy;
259
+ readonly categorySelect: CategorySelectCopy;
260
+ readonly dataViews: DataViewsCopy;
248
261
  }
249
262
 
250
263
  /**
@@ -291,6 +304,12 @@ export function missingWebCopy(
291
304
  ): string[] {
292
305
  return leafPaths(reference).filter((path) => {
293
306
  const value = read(copy, path);
307
+ // A leaf is satisfied by a non-blank string OR a function. The `@12-apps/ui`
308
+ // packs folded in below carry entries that take the interpolated value as
309
+ // an argument — `noResults.title(query)`, `footer.selectedCount(count)` —
310
+ // because Portuguese decides word order and plural agreement per sentence.
311
+ // Demanding a string here would report every one of them as missing.
312
+ if (typeof value === "function") return false;
294
313
  return typeof value !== "string" || value.trim() === "";
295
314
  });
296
315
  }
@@ -2,7 +2,11 @@
2
2
 
3
3
  import { useState, type JSX } from "react";
4
4
 
5
- import { CardKebab, useCardActions, useRemoveConfirm } from "@12-apps/ui/data-display/CardKit";
5
+ import {
6
+ CardKebab,
7
+ useCardActions,
8
+ useRemoveConfirm,
9
+ } from "@12-apps/ui/data-display/CardKit";
6
10
  import { Dialog, DialogContent } from "@12-apps/ui/feedback/Dialog";
7
11
  import type { DropdownMenuItem } from "@12-apps/ui/navigation/DropdownMenu";
8
12
 
@@ -45,6 +49,34 @@ export interface DiscountActionsMenuProps {
45
49
  onError: (error: unknown, context: string) => void;
46
50
  }
47
51
 
52
+ /**
53
+ * The row's delete, popup and all.
54
+ *
55
+ * Split out of the menu only to keep that component inside the size gate: the
56
+ * options object is a dozen lines of pure wording, and it reads better named.
57
+ */
58
+ function useDeleteConfirm(
59
+ row: DiscountListItem,
60
+ api: DiscountsApiClient,
61
+ copy: DiscountsWebCopy,
62
+ onError: (error: unknown, context: string) => void,
63
+ ): ReturnType<typeof useRemoveConfirm> {
64
+ return useRemoveConfirm({
65
+ write: async () => {
66
+ const result = await api.remove(row.id);
67
+ if (!result.ok) onError(result, "discounts.delete");
68
+ return result;
69
+ },
70
+ title: copy.actions.deleteTitle,
71
+ entityName: row.name,
72
+ description: copy.actions.deleteDescription,
73
+ confirmText: copy.actions.delete,
74
+ fallbackError: copy.actions.deleteFailed,
75
+ copy: copy.confirmAction,
76
+ dataTestId: "discount-delete-confirm",
77
+ });
78
+ }
79
+
48
80
  export function DiscountActionsMenu({
49
81
  row,
50
82
  api,
@@ -58,23 +90,15 @@ export function DiscountActionsMenu({
58
90
  const [editOpen, setEditOpen] = useState(false);
59
91
  const canEdit = groups !== undefined;
60
92
 
61
- const remove = useRemoveConfirm({
62
- write: async () => {
63
- const result = await api.remove(row.id);
64
- if (!result.ok) onError(result, "discounts.delete");
65
- return result;
66
- },
67
- title: copy.actions.deleteTitle,
68
- entityName: row.name,
69
- description: copy.actions.deleteDescription,
70
- confirmText: copy.actions.delete,
71
- fallbackError: copy.actions.deleteFailed,
72
- dataTestId: "discount-delete-confirm",
73
- });
93
+ const remove = useDeleteConfirm(row, api, copy, onError);
74
94
 
75
95
  const items: DropdownMenuItem[] = [];
76
96
  if (canEdit) {
77
- items.push({ id: "edit", label: copy.actions.edit, onClick: () => setEditOpen(true) });
97
+ items.push({
98
+ id: "edit",
99
+ label: copy.actions.edit,
100
+ onClick: () => setEditOpen(true),
101
+ });
78
102
  }
79
103
  items.push({
80
104
  id: "delete",
@@ -108,6 +108,7 @@ export function DiscountTargetPicker({
108
108
  requiredMessage={copy.targets.required}
109
109
  ids={ids}
110
110
  onChange={onChange}
111
+ copy={copy.categorySelect}
111
112
  dataTestId={`discount-${group.slug}-targets`}
112
113
  />
113
114
  );
@@ -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
  /**
@@ -154,4 +160,7 @@ export const PT_BR_DISCOUNTS_WEB_COPY: DiscountsWebCopy = {
154
160
  perBuyerLimit: "Limite por cliente",
155
161
  unlimited: "Sem limite",
156
162
  },
163
+ confirmAction: PT_BR_CONFIRM_ACTION_COPY,
164
+ dataViews: PT_BR_DATA_VIEWS_COPY,
165
+ categorySelect: PT_BR_CATEGORY_SELECT_COPY,
157
166
  };
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
 
3
+ import type { CategorySelectCopy } from "@12-apps/ui/copy";
3
4
  import { useState, type JSX } from "react";
4
5
 
5
6
  import { Autocomplete } from "@12-apps/ui/form/Autocomplete";
@@ -100,6 +101,7 @@ export function TargetPickerField({
100
101
  ids,
101
102
  onChange,
102
103
  dataTestId,
104
+ copy,
103
105
  }: {
104
106
  group: WireTargetGroup;
105
107
  label: string;
@@ -108,6 +110,8 @@ export function TargetPickerField({
108
110
  ids: readonly string[];
109
111
  onChange: (next: string[]) => void;
110
112
  dataTestId: string;
113
+ /** `CategorySelect`'s own words — required since FUT-760, never defaulted. */
114
+ copy: CategorySelectCopy;
111
115
  }): JSX.Element {
112
116
  if (group.nests) {
113
117
  return (
@@ -119,6 +123,7 @@ export function TargetPickerField({
119
123
  value={[...ids]}
120
124
  onChange={onChange}
121
125
  error={requiredMessage !== undefined && ids.length === 0 ? requiredMessage : undefined}
126
+ copy={copy}
122
127
  dataTestId={dataTestId}
123
128
  />
124
129
  );