@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.
- package/README.md +231 -16
- package/package.json +55 -6
- package/prisma/discounts.prisma +181 -0
- package/prisma/migrations/20260821140000_discounts_package_owned/migration.sql +286 -0
- package/scripts/sync-discounts-schema.mjs +53 -0
- package/src/engine/kinds.ts +62 -0
- package/src/index.ts +5 -0
- package/src/manifest/index.ts +45 -17
- package/src/manifest/web.ts +55 -0
- package/src/react/api.ts +209 -0
- package/src/react/combo-slot-builder.tsx +213 -0
- package/src/react/copy.ts +296 -0
- package/src/react/create-web-discounts.tsx +162 -0
- package/src/react/discount-actions-menu.tsx +127 -0
- package/src/react/discount-card.tsx +92 -0
- package/src/react/discount-form-fields.tsx +245 -0
- package/src/react/discount-form.tsx +393 -0
- package/src/react/discount-list-card.tsx +199 -0
- package/src/react/discount-target-picker.tsx +114 -0
- package/src/react/discounts-screen.tsx +314 -0
- package/src/react/format.ts +126 -0
- package/src/react/index.ts +72 -0
- package/src/react/pt-BR.ts +157 -0
- package/src/react/row.ts +66 -0
- package/src/react/table-config.ts +238 -0
- package/src/react/target-picker-field.tsx +138 -0
- package/src/react/transport.ts +115 -0
- package/src/react/use-discounts-data.ts +103 -0
- package/src/server/collections.ts +290 -0
- package/src/server/copy.ts +12 -0
- package/src/server/index.ts +23 -0
- package/src/server/logging.ts +20 -3
- package/src/server/mcp.ts +33 -0
- package/src/server/pt-BR.ts +1 -0
- package/src/server/routes.ts +74 -9
- 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
|
+
}
|