@12-apps/discounts 1.2.0 → 1.4.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.
Files changed (36) hide show
  1. package/README.md +231 -16
  2. package/package.json +55 -6
  3. package/prisma/discounts.prisma +181 -0
  4. package/prisma/migrations/20260821140000_discounts_package_owned/migration.sql +286 -0
  5. package/scripts/sync-discounts-schema.mjs +53 -0
  6. package/src/engine/kinds.ts +62 -0
  7. package/src/index.ts +5 -0
  8. package/src/manifest/index.ts +45 -17
  9. package/src/manifest/web.ts +55 -0
  10. package/src/react/api.ts +209 -0
  11. package/src/react/combo-slot-builder.tsx +213 -0
  12. package/src/react/copy.ts +296 -0
  13. package/src/react/create-web-discounts.tsx +162 -0
  14. package/src/react/discount-actions-menu.tsx +127 -0
  15. package/src/react/discount-card.tsx +92 -0
  16. package/src/react/discount-form-fields.tsx +245 -0
  17. package/src/react/discount-form.tsx +393 -0
  18. package/src/react/discount-list-card.tsx +199 -0
  19. package/src/react/discount-target-picker.tsx +114 -0
  20. package/src/react/discounts-screen.tsx +314 -0
  21. package/src/react/format.ts +126 -0
  22. package/src/react/index.ts +72 -0
  23. package/src/react/pt-BR.ts +157 -0
  24. package/src/react/row.ts +66 -0
  25. package/src/react/table-config.ts +238 -0
  26. package/src/react/target-picker-field.tsx +138 -0
  27. package/src/react/transport.ts +115 -0
  28. package/src/react/use-discounts-data.ts +103 -0
  29. package/src/server/collections.ts +290 -0
  30. package/src/server/copy.ts +12 -0
  31. package/src/server/index.ts +23 -0
  32. package/src/server/logging.ts +20 -3
  33. package/src/server/mcp.ts +33 -0
  34. package/src/server/pt-BR.ts +1 -0
  35. package/src/server/routes.ts +74 -9
  36. package/src/server/targets.ts +102 -0
@@ -0,0 +1,296 @@
1
+ import type {
2
+ DiscountScope,
3
+ DiscountTrigger,
4
+ DiscountType,
5
+ DiscountWindowState,
6
+ } from "../engine/kinds";
7
+
8
+ /**
9
+ * Every word the discounts ADMIN screens put in front of a human — required
10
+ * host config, with NO defaults.
11
+ *
12
+ * The third copy port in this package, after `DiscountRejectionCopy` (what a
13
+ * buyer is told) and `DiscountsServerCopy` (what an operator is told when a
14
+ * write is refused). Same rule, for the third time and for the same reason: a
15
+ * default in the origin's language reads as finished to the next host right up
16
+ * until a user sees it, and the copy-portability gate exists to refuse exactly
17
+ * that.
18
+ *
19
+ * It is a LOT of keys, and that is the honest count rather than a design
20
+ * failure. This surface is a grid, a form of fourteen inputs, two pickers, two
21
+ * card layouts and four confirmation popups; the origin held roughly 1,800
22
+ * lines, three quarters of which was this. Nothing here has been folded
23
+ * together to make the interface look smaller — two sentences that happen to
24
+ * match today in one language are two sentences.
25
+ *
26
+ * Grouped by SURFACE rather than alphabetically, because a host fills it in by
27
+ * walking a screen.
28
+ *
29
+ * Interpolation is `{name}`-style and there are exactly four of them, each
30
+ * documented at its key. A template rather than a function so the pack stays
31
+ * plain data a host can lint, diff and translate.
32
+ */
33
+
34
+ /** The labels for one closed set — every member, so nothing renders raw. */
35
+ type LabelsFor<TKey extends string> = Readonly<Record<TKey, string>>;
36
+
37
+ /** The list page: its chrome, its columns and its filters. */
38
+ export interface DiscountsScreenCopy {
39
+ /** The page title, and the last breadcrumb crumb. */
40
+ readonly title: string;
41
+ /** The header's explanatory panel — its heading and its paragraph. */
42
+ readonly aboutTitle: string;
43
+ readonly aboutBody: string;
44
+ /** The primary action that opens the create dialog. */
45
+ readonly create: string;
46
+ /** Shown when the tenant has no promotions at all. */
47
+ readonly empty: string;
48
+ /** The exported file's base name, without extension. */
49
+ readonly exportFileName: string;
50
+ /** While the first page is loading. */
51
+ readonly loading: string;
52
+ /** The read failed: a heading, and the button that tries again. */
53
+ readonly loadFailed: string;
54
+ readonly retry: string;
55
+ /** Column headers, which are also the export's column keys. */
56
+ readonly columns: {
57
+ readonly name: string;
58
+ readonly value: string;
59
+ readonly type: string;
60
+ readonly scope: string;
61
+ readonly trigger: string;
62
+ readonly window: string;
63
+ readonly code: string;
64
+ readonly usageCount: string;
65
+ readonly active: string;
66
+ };
67
+ /** The two words a boolean column and its filter pill render. */
68
+ readonly yes: string;
69
+ readonly no: string;
70
+ }
71
+
72
+ /** The four closed sets, each labelled in full. */
73
+ export interface DiscountsVocabularyCopy {
74
+ readonly type: LabelsFor<DiscountType>;
75
+ readonly scope: LabelsFor<DiscountScope>;
76
+ readonly trigger: LabelsFor<DiscountTrigger>;
77
+ readonly window: LabelsFor<DiscountWindowState>;
78
+ }
79
+
80
+ /** The validity sentence, in all four shapes a nullable window can take. */
81
+ export interface DiscountsWindowCopy {
82
+ /** Neither bound set — it runs until somebody stops it. */
83
+ readonly always: string;
84
+ /** Only a start. `{date}`. */
85
+ readonly from: string;
86
+ /** Only an end. `{date}`. */
87
+ readonly until: string;
88
+ /** Both. `{from}` and `{to}`. */
89
+ readonly between: string;
90
+ }
91
+
92
+ /** The create/edit form: its inputs, and every rule it can refuse for. */
93
+ export interface DiscountsFormCopy {
94
+ readonly createTitle: string;
95
+ readonly editTitle: string;
96
+ readonly submitCreate: string;
97
+ readonly submitEdit: string;
98
+ readonly name: string;
99
+ readonly type: string;
100
+ readonly percentOff: string;
101
+ /** The example inside the empty percentage input. */
102
+ readonly percentPlaceholder: string;
103
+ readonly amountOff: string;
104
+ /** The combo's total price, shown only for a BUNDLE_PRICE rule. */
105
+ readonly bundlePrice: string;
106
+ /** How many of the matched units are given away, for a FREE_UNITS rule. */
107
+ readonly freeUnits: string;
108
+ /** Under it: what "leve 3 pague 2" means in this rule's own numbers. */
109
+ readonly freeUnitsHint: string;
110
+ readonly trigger: string;
111
+ readonly code: string;
112
+ /** The example inside the empty coupon input. */
113
+ readonly codePlaceholder: string;
114
+ readonly scope: string;
115
+ readonly startsAt: string;
116
+ readonly endsAt: string;
117
+ readonly minSubtotal: string;
118
+ readonly usageLimit: string;
119
+ readonly perBuyerLimit: string;
120
+ /** How many times ONE cart may claim the combo. Blank = as often as it fits. */
121
+ readonly maxComboApplications: string;
122
+ readonly maxComboApplicationsHint: string;
123
+ readonly active: string;
124
+ readonly activeHint: string;
125
+ readonly stackable: string;
126
+ readonly stackableHint: string;
127
+ /** The banner over a form the operator must fix before it will send. */
128
+ readonly reviewFields: string;
129
+ /** The banner over a write the SERVER refused. */
130
+ readonly saveFailed: string;
131
+ /** Per-input refusals, each attached to the input it names. */
132
+ readonly nameRequired: string;
133
+ readonly invalidPercent: string;
134
+ readonly invalidAmount: string;
135
+ readonly invalidBundlePrice: string;
136
+ readonly invalidFreeUnits: string;
137
+ /**
138
+ * "Take 3, three free" is a giveaway, not a promotion — the free count has to
139
+ * be smaller than what one application takes out of the cart. `{units}` is
140
+ * that number, so the operator is told the ceiling rather than just refused.
141
+ */
142
+ readonly freeUnitsExceedCombo: string;
143
+ readonly codeRequired: string;
144
+ readonly categoryTargetRequired: string;
145
+ readonly itemTargetRequired: string;
146
+ /** COMBO scope with no groups: there is nothing for the reward to apply to. */
147
+ readonly comboSlotsRequired: string;
148
+ /** A group naming nothing can never be filled, so the combo never fires. */
149
+ readonly comboSlotTargetRequired: string;
150
+ readonly invalidComboQuantity: string;
151
+ readonly invalidMaxComboApplications: string;
152
+ readonly endsBeforeStarts: string;
153
+ }
154
+
155
+ /**
156
+ * The combo builder — the "2 refrigerantes + 2 hambúrgueres + 2 batatas" half
157
+ * of a promotion (FUT-268).
158
+ *
159
+ * A combo is a list of GROUPS, each a quantity and the rows that can fill it,
160
+ * and the reward is whichever value the type asks for. So this group covers the
161
+ * builder's chrome; the reward's own input is a `form` key beside the other
162
+ * value fields, because to the operator it is one.
163
+ */
164
+ export interface DiscountsComboCopy {
165
+ /** The section heading over the group list. */
166
+ readonly title: string;
167
+ /** One sentence saying what a group is, in the merchant's terms. */
168
+ readonly hint: string;
169
+ /** The button that appends a group. */
170
+ readonly addSlot: string;
171
+ /** The accessible name of one group's remove control. `{position}`. */
172
+ readonly removeSlot: string;
173
+ /** One group's heading. `{position}` is 1-based — an operator counts from 1. */
174
+ readonly slot: string;
175
+ /** The units this group takes. */
176
+ readonly quantity: string;
177
+ /** The group's picker label, `{collection}` — the registration's own label. */
178
+ readonly pick: string;
179
+ /** Shown when COMBO is chosen and no group exists yet. */
180
+ readonly empty: string;
181
+ /**
182
+ * The offer read back in the operator's own numbers, so a combo can be
183
+ * checked without saving it. `{units}` and `{groups}`.
184
+ */
185
+ readonly summary: string;
186
+ }
187
+
188
+ /** The target pickers, and what an empty selection says. */
189
+ export interface DiscountsTargetCopy {
190
+ /** The picker's label, `{collection}` — the registration's own label. */
191
+ readonly pick: string;
192
+ /** The placeholder inside a searchable picker, `{collection}`. */
193
+ readonly search: string;
194
+ /** Shown under a picker the scope requires and nothing is chosen in. */
195
+ readonly required: string;
196
+ }
197
+
198
+ /** The row and card menus, and the two destructive confirmations. */
199
+ export interface DiscountsActionsCopy {
200
+ /** The kebab's accessible name — announced verbatim by a screen reader. */
201
+ readonly menu: string;
202
+ readonly edit: string;
203
+ readonly delete: string;
204
+ /** The delete popup for ONE rule. */
205
+ readonly deleteTitle: string;
206
+ readonly deleteDescription: string;
207
+ /** The delete popup for a SELECTION. `{count}`. */
208
+ readonly deleteManyTitle: string;
209
+ readonly deleteManyDescription: string;
210
+ /** When a delete fails and the server said nothing useful. */
211
+ readonly deleteFailed: string;
212
+ /** The shared snackbar's heading, over whatever an action reported. */
213
+ readonly actionFailed: string;
214
+ }
215
+
216
+ /** The two card layouts. */
217
+ export interface DiscountsCardCopy {
218
+ /** The chip a switched-off rule carries. Shown only when it is off. */
219
+ readonly paused: string;
220
+ /** Headings inside an expanded list-card body. */
221
+ readonly ruleHeading: string;
222
+ readonly targetsHeading: string;
223
+ /** What an ORDER-scoped rule covers. */
224
+ readonly wholeOrder: string;
225
+ /** How many rows it points at: one, and `{count}` for the rest. */
226
+ readonly oneTarget: string;
227
+ readonly manyTargets: string;
228
+ /** A CODE rule's secondary line, `{code}`. */
229
+ readonly withCode: string;
230
+ /** An empty target run inside a card. */
231
+ readonly noTargets: string;
232
+ /** Row labels inside the expanded body. */
233
+ readonly usage: string;
234
+ readonly minSubtotal: string;
235
+ readonly perBuyerLimit: string;
236
+ readonly unlimited: string;
237
+ }
238
+
239
+ export interface DiscountsWebCopy {
240
+ readonly screen: DiscountsScreenCopy;
241
+ readonly labels: DiscountsVocabularyCopy;
242
+ readonly window: DiscountsWindowCopy;
243
+ readonly form: DiscountsFormCopy;
244
+ readonly combo: DiscountsComboCopy;
245
+ readonly targets: DiscountsTargetCopy;
246
+ readonly actions: DiscountsActionsCopy;
247
+ readonly card: DiscountsCardCopy;
248
+ }
249
+
250
+ /**
251
+ * Fill `{name}` placeholders. Unknown names are left alone rather than blanked,
252
+ * so a pack with a typo shows the typo instead of a hole nobody can trace.
253
+ */
254
+ export function fill(template: string, values: Readonly<Record<string, string | number>>): string {
255
+ return template.replace(/\{(\w+)\}/g, (whole, key: string) =>
256
+ key in values ? String(values[key]) : whole,
257
+ );
258
+ }
259
+
260
+ /** Every leaf path of an object, dotted. */
261
+ function leafPaths(value: unknown, prefix = ""): string[] {
262
+ if (value === null || typeof value !== "object") return prefix === "" ? [] : [prefix];
263
+ return Object.entries(value as Record<string, unknown>).flatMap(([key, child]) =>
264
+ leafPaths(child, prefix === "" ? key : `${prefix}.${key}`),
265
+ );
266
+ }
267
+
268
+ /** One dotted path out of a pack, or `undefined` where the path does not exist. */
269
+ function read(copy: unknown, path: string): unknown {
270
+ return path.split(".").reduce<unknown>(
271
+ (node, key) =>
272
+ node !== null && typeof node === "object"
273
+ ? (node as Record<string, unknown>)[key]
274
+ : undefined,
275
+ copy,
276
+ );
277
+ }
278
+
279
+ /**
280
+ * The keys a pack is missing or left blank, as dotted paths.
281
+ *
282
+ * Walked from a REFERENCE pack rather than from a hand-kept key list: this
283
+ * interface is seventy-odd keys deep in seven groups, and a list beside it
284
+ * would be wrong within one release. Only the reference's SHAPE is read, never
285
+ * its words — `createWebDiscounts` passes the pt-BR pack for that and nothing
286
+ * of it survives into the answer.
287
+ */
288
+ export function missingWebCopy(
289
+ copy: DiscountsWebCopy | undefined,
290
+ reference: DiscountsWebCopy,
291
+ ): string[] {
292
+ return leafPaths(reference).filter((path) => {
293
+ const value = read(copy, path);
294
+ return typeof value !== "string" || value.trim() === "";
295
+ });
296
+ }
@@ -0,0 +1,162 @@
1
+ "use client";
2
+
3
+ import { useMemo, type ComponentType, type JSX } from "react";
4
+
5
+ import { createDiscountsApiClient, type DiscountsApiClient } from "./api";
6
+ import { missingWebCopy, type DiscountsWebCopy } from "./copy";
7
+ import { DiscountForm, type DiscountFormProps } from "./discount-form";
8
+ import type { CurrencyFieldComponent } from "./discount-form-fields";
9
+ import { DiscountsScreen, type DiscountsScreenProps } from "./discounts-screen";
10
+ import { createFormatters, type DiscountsFormatters } from "./format";
11
+ import { PT_BR_DISCOUNTS_WEB_COPY } from "./pt-BR";
12
+ import { httpDiscountsTransport, type DiscountsTransport } from "./transport";
13
+
14
+ /**
15
+ * The one thing this package exposes to a FRONTEND host.
16
+ *
17
+ * Everything the promotions admin IS — the server-driven grid with its filters
18
+ * and its export, the create/edit form and its fourteen inputs, the target
19
+ * pickers, both card layouts, the four confirmations and every wire call
20
+ * between them — lives inside this package now. The host names where the API is
21
+ * mounted, supplies every sentence, says which locale its operators read, and
22
+ * that is the whole wiring.
23
+ *
24
+ * ## Build it ONCE, at module scope
25
+ *
26
+ * The members are component TYPES. Rebuilding the surface per render gives
27
+ * React new types every time, which unmounts and remounts the entire tree below
28
+ * — a form loses what the operator typed on every keystroke that reaches the
29
+ * page above it. Every `createWeb*` in this org carries the same warning
30
+ * because it is the same hazard, and it is silent: the screen works, it just
31
+ * cannot be typed into.
32
+ */
33
+
34
+ export interface DiscountsWebConfig {
35
+ /** The admin mount the routes live under, e.g. `/api/admin/my-store`. */
36
+ apiBase: string;
37
+ /**
38
+ * Every sentence the screens render — REQUIRED, the host's words. A pt-BR
39
+ * host passes `PT_BR_DISCOUNTS_WEB_COPY`.
40
+ */
41
+ copy: DiscountsWebCopy;
42
+ /**
43
+ * How money, percentages and dates are written — and READ BACK. A form takes
44
+ * what the operator typed in their own notation, so this decides parsing as
45
+ * much as rendering.
46
+ */
47
+ locale: string;
48
+ /** ISO 4217, e.g. `BRL`. */
49
+ currency: string;
50
+ /**
51
+ * Where a failure goes, beyond the operator's own screen — REQUIRED, and for
52
+ * the same reason `createApiDiscounts` requires a logger.
53
+ *
54
+ * The screens already SHOW an operator what went wrong; that is not the same
55
+ * as anybody knowing. A refused write, a catalog that would not load, a page
56
+ * read that failed — each of those reaches a person only if a host routes it
57
+ * somewhere, and a no-op default here would make "nothing is broken" and
58
+ * "nothing is watching" look identical. A host wires this to its browser
59
+ * error reporter.
60
+ *
61
+ * `context` is a stable dotted token (`discounts.create`, `discounts.list`)
62
+ * rather than a sentence, so a reporter can group on it.
63
+ */
64
+ onError: (error: unknown, context: string) => void;
65
+ /**
66
+ * The money input. Currency entry is a host decision — masking, which side
67
+ * the symbol sits on, whether cents are typed or implied — and there is no
68
+ * neutral answer to ship. A host with nothing special passes a plain text
69
+ * field.
70
+ */
71
+ currencyField: CurrencyFieldComponent;
72
+ /** How the surface reaches its data. Default: same-origin fetch. */
73
+ transport?: DiscountsTransport;
74
+ /** The crumbs above the title. The host owns its information hierarchy. */
75
+ breadcrumb?: readonly { label: string; href?: string }[];
76
+ }
77
+
78
+ export interface WebDiscounts {
79
+ /** The whole promotions admin. */
80
+ Screen: ComponentType;
81
+ /**
82
+ * The form on its own, for a host embedding it somewhere of its own — an
83
+ * onboarding step, a wizard. Already bound to the same client and copy.
84
+ */
85
+ DiscountForm: ComponentType<
86
+ Pick<DiscountFormProps, "editing" | "groups" | "onSaved">
87
+ >;
88
+ /** The bound wire client, for host glue. */
89
+ api: DiscountsApiClient;
90
+ /** The bound formatters, for a host rendering a discount outside these screens. */
91
+ formatters: DiscountsFormatters;
92
+ }
93
+
94
+ /** Assert the config at construction, where the wiring is written. */
95
+ function assertConfig(config: DiscountsWebConfig): void {
96
+ const missing = missingWebCopy(config.copy, PT_BR_DISCOUNTS_WEB_COPY);
97
+ if (missing.length > 0) {
98
+ throw new Error(
99
+ `@12-apps/discounts: createWebDiscounts is missing copy for ${missing.join(", ")} — ` +
100
+ "every sentence these screens render is host config, with no defaults.",
101
+ );
102
+ }
103
+ if (typeof config.onError !== "function") {
104
+ throw new Error(
105
+ "@12-apps/discounts: createWebDiscounts needs an onError — a surface whose " +
106
+ "failures reach nobody is indistinguishable from one that never fails.",
107
+ );
108
+ }
109
+ }
110
+
111
+ export function createWebDiscounts(config: DiscountsWebConfig): WebDiscounts {
112
+ assertConfig(config);
113
+ const { copy, onError, currencyField, breadcrumb } = config;
114
+ const formatters = createFormatters(config.locale, config.currency);
115
+ const transport = config.transport ?? httpDiscountsTransport(copy.form.saveFailed);
116
+ const api = createDiscountsApiClient(config.apiBase, transport, formatters);
117
+
118
+ const screenProps: DiscountsScreenProps = {
119
+ api,
120
+ copy,
121
+ formatters,
122
+ currencyField,
123
+ onError,
124
+ ...(breadcrumb ? { breadcrumb } : {}),
125
+ };
126
+
127
+ return {
128
+ Screen: () => <DiscountsScreen {...screenProps} />,
129
+ DiscountForm: (props) => (
130
+ <DiscountForm
131
+ api={api}
132
+ copy={copy}
133
+ formatters={formatters}
134
+ currencyField={currencyField}
135
+ onError={onError}
136
+ {...props}
137
+ />
138
+ ),
139
+ api,
140
+ formatters,
141
+ };
142
+ }
143
+
144
+ /**
145
+ * The same factory, memoised on config IDENTITY — the shape a host that must
146
+ * build inside a component should reach for.
147
+ *
148
+ * Not a substitute for building at module scope: `useMemo` keyed on the config
149
+ * object still rebuilds whenever that object is a fresh literal, which it is on
150
+ * every render unless the host memoised it too. It exists for the case where
151
+ * the mount path genuinely is dynamic (the api base carries a tenant slug from
152
+ * the router), and it makes the dependency explicit instead of silent.
153
+ */
154
+ export function useWebDiscounts(config: DiscountsWebConfig): WebDiscounts {
155
+ return useMemo(() => createWebDiscounts(config), [config]);
156
+ }
157
+
158
+ /** The screen as a bare component, for a host that wires everything itself. */
159
+ export type { DiscountsScreenProps };
160
+ export function DiscountsSurface(props: DiscountsScreenProps): JSX.Element {
161
+ return <DiscountsScreen {...props} />;
162
+ }
@@ -0,0 +1,127 @@
1
+ "use client";
2
+
3
+ import { useState, type JSX } from "react";
4
+
5
+ import { CardKebab, useCardActions, useRemoveConfirm } from "@12-apps/ui/data-display/CardKit";
6
+ import { Dialog, DialogContent } from "@12-apps/ui/feedback/Dialog";
7
+ import type { DropdownMenuItem } from "@12-apps/ui/navigation/DropdownMenu";
8
+
9
+ import type { DiscountsApiClient, WireTargetGroup } from "./api";
10
+ import type { DiscountsWebCopy } from "./copy";
11
+ import { DiscountForm } from "./discount-form";
12
+ import type { CurrencyFieldComponent } from "./discount-form-fields";
13
+ import type { DiscountsFormatters } from "./format";
14
+ import type { DiscountListItem } from "./row";
15
+
16
+ /**
17
+ * One rule's self-contained "⋮" menu: it owns its edit dialog inline and its
18
+ * delete write, reading the refresh and the error channel from
19
+ * `CardActionsProvider`.
20
+ *
21
+ * Reused by BOTH the grid row and the card, so the two can never offer a
22
+ * different set of actions — which is the whole reason it is one component
23
+ * rather than a menu per surface.
24
+ *
25
+ * There is deliberately no version-history entry. A discount is not versioned
26
+ * catalog content: restoring "version 3" of a coupon whose redemption counter
27
+ * has since moved is meaningless, so the surface does not offer it.
28
+ */
29
+
30
+ export interface DiscountActionsMenuProps {
31
+ row: DiscountListItem;
32
+ api: DiscountsApiClient;
33
+ copy: DiscountsWebCopy;
34
+ formatters: DiscountsFormatters;
35
+ currencyField: CurrencyFieldComponent;
36
+ /**
37
+ * The registered collections, for the edit form's target pickers.
38
+ *
39
+ * Optional, and the absence is a real state rather than an oversight: while
40
+ * they are still loading, only DELETE is offered — an edit form opened
41
+ * without them would show empty pickers, and saving would silently clear a
42
+ * target list it never had the chance to display.
43
+ */
44
+ groups?: readonly WireTargetGroup[];
45
+ onError: (error: unknown, context: string) => void;
46
+ }
47
+
48
+ export function DiscountActionsMenu({
49
+ row,
50
+ api,
51
+ copy,
52
+ formatters,
53
+ currencyField,
54
+ groups,
55
+ onError,
56
+ }: DiscountActionsMenuProps): JSX.Element {
57
+ const { onRefresh } = useCardActions();
58
+ const [editOpen, setEditOpen] = useState(false);
59
+ const canEdit = groups !== undefined;
60
+
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
+ });
74
+
75
+ const items: DropdownMenuItem[] = [];
76
+ if (canEdit) {
77
+ items.push({ id: "edit", label: copy.actions.edit, onClick: () => setEditOpen(true) });
78
+ }
79
+ items.push({
80
+ id: "delete",
81
+ label: copy.actions.delete,
82
+ color: "danger",
83
+ onClick: remove.request,
84
+ });
85
+
86
+ return (
87
+ <>
88
+ <CardKebab
89
+ items={items}
90
+ menuLabel={copy.actions.menu}
91
+ dataTestId={`discount-actions-${row.id}`}
92
+ />
93
+ {remove.dialog}
94
+ {canEdit && (
95
+ <Dialog
96
+ open={editOpen}
97
+ onClose={() => setEditOpen(false)}
98
+ title={copy.form.editTitle}
99
+ size="md"
100
+ showCloseButton
101
+ dataTestId="discount-dialog"
102
+ >
103
+ {/* Mounted only while open, so the next open re-seeds from the row
104
+ instead of keeping the last attempt's values. */}
105
+ {editOpen && (
106
+ <DialogContent>
107
+ <DiscountForm
108
+ key={row.id}
109
+ api={api}
110
+ copy={copy}
111
+ formatters={formatters}
112
+ currencyField={currencyField}
113
+ groups={groups}
114
+ editing={row.record}
115
+ onError={onError}
116
+ onSaved={() => {
117
+ setEditOpen(false);
118
+ onRefresh();
119
+ }}
120
+ />
121
+ </DialogContent>
122
+ )}
123
+ </Dialog>
124
+ )}
125
+ </>
126
+ );
127
+ }
@@ -0,0 +1,92 @@
1
+ "use client";
2
+
3
+ import type { JSX } from "react";
4
+
5
+ import LocalOfferOutlinedIcon from "@mui/icons-material/LocalOfferOutlined";
6
+
7
+ import type { KindCardProps } from "@12-apps/ui/data-display/CardKit";
8
+ import { BaseCard } from "@12-apps/ui/data-display/DataViews";
9
+ import { Chip } from "@12-apps/ui/data-display/Chip";
10
+ import { Box } from "@12-apps/ui/mui/Box";
11
+
12
+ import type { DiscountsWebCopy } from "./copy";
13
+ import { DiscountActionsMenu, type DiscountActionsMenuProps } from "./discount-actions-menu";
14
+ import type { DiscountListItem } from "./row";
15
+
16
+ /**
17
+ * One rule as a tile for the grid's card layout.
18
+ *
19
+ * The subtitle carries the two numbers an operator scans a promotions board
20
+ * for: what it takes off, and how long it runs. The chips carry the two facts
21
+ * that decide WHERE and WHEN it fires. "Paused" is shown only when true —
22
+ * a card shouting "Active" on every tile would make the one paused promotion
23
+ * harder to spot, not easier.
24
+ */
25
+
26
+ /** The semantic colour a scope's chip carries. Not copy: it is a token. */
27
+ const SCOPE_COLOR: Record<string, "neutral" | "warning" | "success" | "info"> = {
28
+ ORDER: "neutral",
29
+ CATEGORY: "warning",
30
+ ITEM: "success",
31
+ COMBO: "info",
32
+ };
33
+
34
+ const TRIGGER_COLOR: Record<string, "success" | "info"> = {
35
+ AUTOMATIC: "success",
36
+ CODE: "info",
37
+ };
38
+
39
+ type DiscountCardProps = KindCardProps &
40
+ Omit<DiscountActionsMenuProps, "row"> & { row: DiscountListItem };
41
+
42
+ export function DiscountCard({
43
+ row,
44
+ selection,
45
+ aspectRatio,
46
+ copy,
47
+ ...menu
48
+ }: DiscountCardProps & { copy: DiscountsWebCopy }): JSX.Element {
49
+ const scopeLabel = copy.labels.scope[row.scope as keyof typeof copy.labels.scope] ?? row.scope;
50
+ const triggerLabel =
51
+ copy.labels.trigger[row.trigger as keyof typeof copy.labels.trigger] ?? row.trigger;
52
+ return (
53
+ <BaseCard
54
+ aspectRatio={aspectRatio ?? "4:3"}
55
+ scale={selection.scale}
56
+ selected={selection.selected}
57
+ onToggleSelect={selection.onToggleSelect}
58
+ testId={`discount-card-${row.id}`}
59
+ title={row.name}
60
+ subtitle={`${row.valueLabel} · ${row.windowLabel}`}
61
+ imageFallback={<LocalOfferOutlinedIcon sx={{ fontSize: 40, opacity: 0.35 }} />}
62
+ menu={<DiscountActionsMenu row={row} copy={copy} {...menu} />}
63
+ >
64
+ <Box sx={{ display: "flex", gap: 0.5, flexWrap: "wrap" }}>
65
+ <Chip
66
+ label={scopeLabel}
67
+ size="sm"
68
+ variant="filled"
69
+ color={SCOPE_COLOR[row.scope] ?? "neutral"}
70
+ dataTestId={`discount-card-scope-${row.id}`}
71
+ />
72
+ <Chip
73
+ // A coupon identifies itself; the word "code" beside a code says less.
74
+ label={row.trigger === "CODE" ? (row.code ?? triggerLabel) : triggerLabel}
75
+ size="sm"
76
+ variant="outlined"
77
+ color={TRIGGER_COLOR[row.trigger] ?? "neutral"}
78
+ dataTestId={`discount-card-trigger-${row.id}`}
79
+ />
80
+ {!row.active && (
81
+ <Chip
82
+ label={copy.card.paused}
83
+ size="sm"
84
+ variant="outlined"
85
+ color="neutral"
86
+ dataTestId={`discount-card-paused-${row.id}`}
87
+ />
88
+ )}
89
+ </Box>
90
+ </BaseCard>
91
+ );
92
+ }