@12-apps/discounts 1.2.0 → 1.3.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.
@@ -0,0 +1,185 @@
1
+ import type { ComboRequirement } from "../engine/types";
2
+
3
+ import type { DiscountsFormatters } from "./format";
4
+ import type { DiscountsResult, DiscountsTransport } from "./transport";
5
+
6
+ /**
7
+ * The wire, and the one place the FORM's shape becomes the API's.
8
+ *
9
+ * A form holds strings — every value in a text input is one — while the API
10
+ * takes integers, basis points and nulls. That coercion is a real rule set, not
11
+ * plumbing, and it has exactly one rule worth stating: a BLANK field means "no
12
+ * value" (`null`), never zero, because `0` is a real and different answer for a
13
+ * limit or a minimum. A host repeating this per call site is how one screen
14
+ * starts saving `0` where another saves `null`.
15
+ */
16
+
17
+ /** A discount as the CRUD endpoints echo it — JSON, so dates are ISO strings. */
18
+ export interface DiscountWireRecord {
19
+ id: string;
20
+ name: string;
21
+ type: string;
22
+ percentOffBp: number | null;
23
+ amountOffCents: number | null;
24
+ bundlePriceCents?: number | null;
25
+ freeUnits?: number | null;
26
+ maxComboApplications?: number | null;
27
+ comboRequirements?: ComboRequirement[];
28
+ scope: string;
29
+ trigger: string;
30
+ code: string | null;
31
+ startsAt: string | null;
32
+ endsAt: string | null;
33
+ minSubtotalCents: number | null;
34
+ usageLimit: number | null;
35
+ perBuyerLimit: number | null;
36
+ usageCount: number;
37
+ stackable: boolean;
38
+ active: boolean;
39
+ categoryIds: string[];
40
+ menuItemIds: string[];
41
+ createdAt: string;
42
+ }
43
+
44
+ /** The pagination envelope the list endpoint answers with. */
45
+ export interface DiscountsPagination {
46
+ page: number;
47
+ pageSize: number;
48
+ total: number;
49
+ pageCount: number;
50
+ hasNextPage: boolean;
51
+ hasPreviousPage: boolean;
52
+ }
53
+
54
+ export interface DiscountsPage {
55
+ data: DiscountWireRecord[];
56
+ pagination: DiscountsPagination;
57
+ }
58
+
59
+ /** One selectable target, as `GET /discounts/targets` answers it. */
60
+ export interface WireTarget {
61
+ id: string;
62
+ name: string;
63
+ parentId?: string | null;
64
+ }
65
+
66
+ /** One registered collection and its rows. */
67
+ export interface WireTargetGroup {
68
+ targetType: string;
69
+ slug: string;
70
+ label: string;
71
+ nests: boolean;
72
+ targets: WireTarget[];
73
+ }
74
+
75
+ /** The form's payload, still in FORM shape. */
76
+ export interface DiscountFormPayload {
77
+ name: string;
78
+ type: string;
79
+ /** 0–100 with up to two decimals, in the operator's own notation. */
80
+ percentOff: string;
81
+ /** A money amount, as the currency field stores it. */
82
+ amountOff: string;
83
+ scope: string;
84
+ trigger: string;
85
+ code: string;
86
+ startsAt: string;
87
+ endsAt: string;
88
+ minSubtotal: string;
89
+ usageLimit: string;
90
+ perBuyerLimit: string;
91
+ stackable: boolean;
92
+ active: boolean;
93
+ categoryIds: string[];
94
+ menuItemIds: string[];
95
+ }
96
+
97
+ /** A money amount as integer cents, or null when blank. */
98
+ function toCents(typed: string, formatters: DiscountsFormatters): number | null {
99
+ const amount = formatters.parseDecimal(typed);
100
+ return amount === null ? null : Math.round(amount * 100);
101
+ }
102
+
103
+ /** A whole-number cap, or null when blank ("no cap"). */
104
+ function toCount(typed: string, formatters: DiscountsFormatters): number | null {
105
+ const parsed = formatters.parseDecimal(typed);
106
+ return parsed === null ? null : Math.trunc(parsed);
107
+ }
108
+
109
+ /**
110
+ * A percentage as BASIS POINTS.
111
+ *
112
+ * The form takes 0–100 with two decimals because that is how an operator thinks
113
+ * about a promotion; the column stores basis points because that is how the
114
+ * engine computes without a float. The `Math.round` after the ×100 is what
115
+ * keeps "12,5" exactly 1250 rather than 1249.9999999999998.
116
+ */
117
+ function toBasisPoints(typed: string, formatters: DiscountsFormatters): number | null {
118
+ const percent = formatters.parseDecimal(typed);
119
+ return percent === null ? null : Math.round(percent * 100);
120
+ }
121
+
122
+ /** Blank → null, so an unused branch's leftover text never reaches a column. */
123
+ function toNullable(value: string): string | null {
124
+ const trimmed = value.trim();
125
+ return trimmed === "" ? null : trimmed;
126
+ }
127
+
128
+ /** The wire body both writes send — a discount is always saved WHOLE. */
129
+ export function toWriteBody(
130
+ input: DiscountFormPayload,
131
+ formatters: DiscountsFormatters,
132
+ ): Record<string, unknown> {
133
+ const isPercentage = input.type === "PERCENTAGE";
134
+ return {
135
+ name: input.name.trim(),
136
+ type: input.type,
137
+ percentOffBp: isPercentage ? toBasisPoints(input.percentOff, formatters) : null,
138
+ amountOffCents: isPercentage ? null : toCents(input.amountOff, formatters),
139
+ scope: input.scope,
140
+ trigger: input.trigger,
141
+ code: input.trigger === "CODE" ? toNullable(input.code) : null,
142
+ startsAt: toNullable(input.startsAt),
143
+ endsAt: toNullable(input.endsAt),
144
+ minSubtotalCents: toCents(input.minSubtotal, formatters),
145
+ usageLimit: toCount(input.usageLimit, formatters),
146
+ perBuyerLimit: toCount(input.perBuyerLimit, formatters),
147
+ stackable: input.stackable,
148
+ active: input.active,
149
+ // Narrowed to the scope on the way OUT as well as on the way in: leaving a
150
+ // stale id list on a rule whose scope changed is how a promotion goes on
151
+ // covering something its screen no longer shows.
152
+ categoryIds: input.scope === "CATEGORY" ? input.categoryIds : [],
153
+ menuItemIds: input.scope === "ITEM" ? input.menuItemIds : [],
154
+ };
155
+ }
156
+
157
+ /** The bound wire client the screens use. Nothing else performs I/O. */
158
+ export interface DiscountsApiClient {
159
+ list(search: string): Promise<DiscountsPage>;
160
+ targets(): Promise<readonly WireTargetGroup[]>;
161
+ create(input: DiscountFormPayload): Promise<DiscountsResult<DiscountWireRecord>>;
162
+ update(id: string, input: DiscountFormPayload): Promise<DiscountsResult<{ id: string }>>;
163
+ remove(id: string): Promise<DiscountsResult<{ id: string }>>;
164
+ }
165
+
166
+ export function createDiscountsApiClient(
167
+ apiBase: string,
168
+ transport: DiscountsTransport,
169
+ formatters: DiscountsFormatters,
170
+ ): DiscountsApiClient {
171
+ const collection = `${apiBase}/discounts`;
172
+ const item = (id: string): string => `${collection}/${encodeURIComponent(id)}`;
173
+ return {
174
+ list: (search) => transport.get<DiscountsPage>(search ? `${collection}?${search}` : collection),
175
+ targets: () =>
176
+ transport
177
+ .get<{ data: WireTargetGroup[] }>(`${collection}/targets`)
178
+ .then((payload) => payload.data),
179
+ create: (input) =>
180
+ transport.send<DiscountWireRecord>(collection, "POST", toWriteBody(input, formatters)),
181
+ update: (id, input) =>
182
+ transport.send<{ id: string }>(item(id), "PATCH", toWriteBody(input, formatters)),
183
+ remove: (id) => transport.send<{ id: string }>(item(id), "DELETE"),
184
+ };
185
+ }
@@ -0,0 +1,239 @@
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
+ readonly trigger: string;
105
+ readonly code: string;
106
+ /** The example inside the empty coupon input. */
107
+ readonly codePlaceholder: string;
108
+ readonly scope: string;
109
+ readonly startsAt: string;
110
+ readonly endsAt: string;
111
+ readonly minSubtotal: string;
112
+ readonly usageLimit: string;
113
+ readonly perBuyerLimit: string;
114
+ readonly active: string;
115
+ readonly activeHint: string;
116
+ readonly stackable: string;
117
+ readonly stackableHint: string;
118
+ /** The banner over a form the operator must fix before it will send. */
119
+ readonly reviewFields: string;
120
+ /** The banner over a write the SERVER refused. */
121
+ readonly saveFailed: string;
122
+ /** Per-input refusals, each attached to the input it names. */
123
+ readonly nameRequired: string;
124
+ readonly invalidPercent: string;
125
+ readonly invalidAmount: string;
126
+ readonly codeRequired: string;
127
+ readonly categoryTargetRequired: string;
128
+ readonly itemTargetRequired: string;
129
+ readonly endsBeforeStarts: string;
130
+ }
131
+
132
+ /** The target pickers, and what an empty selection says. */
133
+ export interface DiscountsTargetCopy {
134
+ /** The picker's label, `{collection}` — the registration's own label. */
135
+ readonly pick: string;
136
+ /** The placeholder inside a searchable picker, `{collection}`. */
137
+ readonly search: string;
138
+ /** Shown under a picker the scope requires and nothing is chosen in. */
139
+ readonly required: string;
140
+ }
141
+
142
+ /** The row and card menus, and the two destructive confirmations. */
143
+ export interface DiscountsActionsCopy {
144
+ /** The kebab's accessible name — announced verbatim by a screen reader. */
145
+ readonly menu: string;
146
+ readonly edit: string;
147
+ readonly delete: string;
148
+ /** The delete popup for ONE rule. */
149
+ readonly deleteTitle: string;
150
+ readonly deleteDescription: string;
151
+ /** The delete popup for a SELECTION. `{count}`. */
152
+ readonly deleteManyTitle: string;
153
+ readonly deleteManyDescription: string;
154
+ /** When a delete fails and the server said nothing useful. */
155
+ readonly deleteFailed: string;
156
+ /** The shared snackbar's heading, over whatever an action reported. */
157
+ readonly actionFailed: string;
158
+ }
159
+
160
+ /** The two card layouts. */
161
+ export interface DiscountsCardCopy {
162
+ /** The chip a switched-off rule carries. Shown only when it is off. */
163
+ readonly paused: string;
164
+ /** Headings inside an expanded list-card body. */
165
+ readonly ruleHeading: string;
166
+ readonly targetsHeading: string;
167
+ /** What an ORDER-scoped rule covers. */
168
+ readonly wholeOrder: string;
169
+ /** How many rows it points at: one, and `{count}` for the rest. */
170
+ readonly oneTarget: string;
171
+ readonly manyTargets: string;
172
+ /** A CODE rule's secondary line, `{code}`. */
173
+ readonly withCode: string;
174
+ /** An empty target run inside a card. */
175
+ readonly noTargets: string;
176
+ /** Row labels inside the expanded body. */
177
+ readonly usage: string;
178
+ readonly minSubtotal: string;
179
+ readonly perBuyerLimit: string;
180
+ readonly unlimited: string;
181
+ }
182
+
183
+ export interface DiscountsWebCopy {
184
+ readonly screen: DiscountsScreenCopy;
185
+ readonly labels: DiscountsVocabularyCopy;
186
+ readonly window: DiscountsWindowCopy;
187
+ readonly form: DiscountsFormCopy;
188
+ readonly targets: DiscountsTargetCopy;
189
+ readonly actions: DiscountsActionsCopy;
190
+ readonly card: DiscountsCardCopy;
191
+ }
192
+
193
+ /**
194
+ * Fill `{name}` placeholders. Unknown names are left alone rather than blanked,
195
+ * so a pack with a typo shows the typo instead of a hole nobody can trace.
196
+ */
197
+ export function fill(template: string, values: Readonly<Record<string, string | number>>): string {
198
+ return template.replace(/\{(\w+)\}/g, (whole, key: string) =>
199
+ key in values ? String(values[key]) : whole,
200
+ );
201
+ }
202
+
203
+ /** Every leaf path of an object, dotted. */
204
+ function leafPaths(value: unknown, prefix = ""): string[] {
205
+ if (value === null || typeof value !== "object") return prefix === "" ? [] : [prefix];
206
+ return Object.entries(value as Record<string, unknown>).flatMap(([key, child]) =>
207
+ leafPaths(child, prefix === "" ? key : `${prefix}.${key}`),
208
+ );
209
+ }
210
+
211
+ /** One dotted path out of a pack, or `undefined` where the path does not exist. */
212
+ function read(copy: unknown, path: string): unknown {
213
+ return path.split(".").reduce<unknown>(
214
+ (node, key) =>
215
+ node !== null && typeof node === "object"
216
+ ? (node as Record<string, unknown>)[key]
217
+ : undefined,
218
+ copy,
219
+ );
220
+ }
221
+
222
+ /**
223
+ * The keys a pack is missing or left blank, as dotted paths.
224
+ *
225
+ * Walked from a REFERENCE pack rather than from a hand-kept key list: this
226
+ * interface is seventy-odd keys deep in seven groups, and a list beside it
227
+ * would be wrong within one release. Only the reference's SHAPE is read, never
228
+ * its words — `createWebDiscounts` passes the pt-BR pack for that and nothing
229
+ * of it survives into the answer.
230
+ */
231
+ export function missingWebCopy(
232
+ copy: DiscountsWebCopy | undefined,
233
+ reference: DiscountsWebCopy,
234
+ ): string[] {
235
+ return leafPaths(reference).filter((path) => {
236
+ const value = read(copy, path);
237
+ return typeof value !== "string" || value.trim() === "";
238
+ });
239
+ }
@@ -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
+ }