@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 +2 -2
- package/src/react/combo-slot-builder.tsx +1 -0
- package/src/react/copy.ts +19 -0
- package/src/react/discount-actions-menu.tsx +39 -15
- package/src/react/discount-target-picker.tsx +1 -0
- package/src/react/discounts-screen.tsx +113 -39
- package/src/react/pt-BR.ts +9 -0
- package/src/react/target-picker-field.tsx +5 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12-apps/discounts",
|
|
3
|
-
"version": "1.
|
|
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.
|
|
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 {
|
|
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 =
|
|
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({
|
|
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",
|
|
@@ -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 {
|
|
10
|
-
|
|
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}>
|
|
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) =>
|
|
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>(() =>
|
|
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 = {
|
|
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
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
305
|
-
appliedState={appliedState}
|
|
306
|
-
server={server}
|
|
307
|
-
onVisibleRowsChange={setVisibleRows}
|
|
308
|
-
bulkDelete={bulkDelete}
|
|
370
|
+
onCreate={() => setCreateOpen(true)}
|
|
309
371
|
/>
|
|
310
|
-
</
|
|
311
|
-
|
|
312
|
-
|
|
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
|
}
|
package/src/react/pt-BR.ts
CHANGED
|
@@ -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
|
);
|