@saasicat/ui-vue 0.25.0 → 0.26.1

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 (86) hide show
  1. package/README.md +55 -8
  2. package/dist/{catalog-DJcfm5N2.d.cts → catalog-Dch1Ryw0.d.cts} +353 -69
  3. package/dist/{catalog-DJcfm5N2.d.ts → catalog-Dch1Ryw0.d.ts} +353 -69
  4. package/dist/{chunk-F7NRM6KI.js → chunk-COVQLDBZ.js} +759 -24
  5. package/dist/{chunk-TQQMKWN6.js → chunk-DT25QAU2.js} +119 -7
  6. package/dist/{chunk-D74FFCCP.js → chunk-O3Y3BYYS.js} +96 -178
  7. package/dist/client/index.cjs +770 -84
  8. package/dist/client/index.d.cts +429 -9
  9. package/dist/client/index.d.ts +429 -9
  10. package/dist/client/index.js +84 -12
  11. package/dist/index.cjs +1337 -295
  12. package/dist/index.d.cts +205 -15
  13. package/dist/index.d.ts +205 -15
  14. package/dist/index.js +566 -240
  15. package/dist/quasar/index.cjs +585 -62
  16. package/dist/quasar/index.d.cts +61 -6
  17. package/dist/quasar/index.d.ts +61 -6
  18. package/dist/quasar/index.js +83 -19
  19. package/dist/{use-sa-theme-OUBlaqgl.d.ts → resource-registry-D7Xjs-5f.d.cts} +157 -7
  20. package/dist/{use-sa-theme-B5t7oXph.d.cts → resource-registry-TaR7rtq0.d.ts} +157 -7
  21. package/package.json +6 -4
  22. package/src/client/admin-error.ts +569 -0
  23. package/src/client/admin-resource-client.ts +9 -12
  24. package/src/client/attach-cause.ts +21 -0
  25. package/src/client/batch-column-fetcher.ts +2 -2
  26. package/src/client/boot-loader.ts +12 -1
  27. package/src/client/http/axios-http-client.ts +383 -0
  28. package/src/client/http/fetch-http-client.ts +89 -0
  29. package/src/client/http/index.ts +6 -0
  30. package/src/client/http-json.ts +82 -16
  31. package/src/client/i18n/messages/discovery.ts +0 -6
  32. package/src/client/i18n/messages/errors.ts +44 -0
  33. package/src/client/i18n/messages/marketing.ts +0 -6
  34. package/src/client/i18n/messages/promos.ts +0 -2
  35. package/src/client/i18n/messages/shell.ts +12 -0
  36. package/src/client/i18n/messages.ts +3 -0
  37. package/src/client/index.ts +6 -0
  38. package/src/client/manifest-loader.ts +65 -15
  39. package/src/client/nav-builder.ts +3 -1
  40. package/src/client/resources/audit.resource.ts +25 -0
  41. package/src/client/resources/define-resource.ts +166 -0
  42. package/src/client/resources/index.ts +42 -0
  43. package/src/client/resources/list-resource.ts +176 -0
  44. package/src/client/resources/plans.resource.ts +169 -0
  45. package/src/client/resources/resource-request.ts +117 -0
  46. package/src/client/resources/tenants.resource.ts +27 -0
  47. package/src/client/types.ts +10 -3
  48. package/src/components/BundleVersionPublishDialog.vue +1 -1
  49. package/src/components/MarketingPromotionsTab.vue +263 -241
  50. package/src/components/ThemeSwitcher.vue +85 -0
  51. package/src/components/bundle-editor/BundleCreatePanel.vue +1 -1
  52. package/src/components/dialogs/PromoCodeDialogFields.vue +20 -20
  53. package/src/components/plan/PlanCycleToggle.vue +1 -1
  54. package/src/components/plan-detail/PlanVersionsPanel.vue +5 -2
  55. package/src/index.ts +5 -0
  56. package/src/pages-standard/AdminLayout.vue +57 -0
  57. package/src/pages-standard/AdminManifestErrorPage.vue +4 -3
  58. package/src/pages-standard/DiscoveryPage.vue +8 -1
  59. package/src/pages-standard/MarketingCatalogPage.vue +14 -3
  60. package/src/pages-standard/SuperAdminLoginPage.vue +23 -2
  61. package/src/pages-standard/SuperAdminSetupWizard.vue +44 -7
  62. package/src/pages-standard/discovery-page/DiscoveryFeatureCard.vue +7 -2
  63. package/src/pages-standard/marketing-catalog/MarketingCatalogAdmin.vue +44 -6
  64. package/src/pages-standard/marketing-catalog/MarketingCatalogPreview.vue +8 -2
  65. package/src/pages-tenant/PackageSnapshotPanel.vue +31 -2
  66. package/src/quasar/confirm.ts +83 -0
  67. package/src/quasar/create-super-admin-app.ts +84 -10
  68. package/src/quasar/dark-bridge.ts +47 -10
  69. package/src/quasar/index.ts +1 -0
  70. package/src/vue/create-admin-routes.ts +9 -1
  71. package/src/vue/platform-loaders.ts +12 -2
  72. package/src/vue/resource-registry.ts +211 -0
  73. package/src/vue/super-admin-context.ts +20 -5
  74. package/src/vue/ui-confirm.ts +76 -0
  75. package/src/vue/use-api-list.ts +50 -45
  76. package/src/vue/use-async-action.ts +170 -0
  77. package/src/vue/use-async-data.ts +83 -0
  78. package/src/vue/use-bundles.ts +34 -7
  79. package/src/vue/use-catalog-entries.ts +37 -12
  80. package/src/vue/use-discovery.ts +27 -6
  81. package/src/vue/use-marketing-projections.ts +29 -7
  82. package/src/vue/use-plans.ts +36 -8
  83. package/src/vue/use-promotions.ts +27 -7
  84. package/src/vue/use-resource-list.ts +272 -0
  85. package/src/vue/use-sa-theme.ts +34 -5
  86. package/src/vue/use-tenant-subscription-bundles.ts +22 -3
@@ -1,3 +1,295 @@
1
+ // src/client/i18n/format.ts
2
+ import { formatErrorMessage } from "@saasicat/types";
3
+ function formatMessage(template, params) {
4
+ return formatErrorMessage(template, params);
5
+ }
6
+
7
+ // src/client/attach-cause.ts
8
+ function attachCause(error, cause) {
9
+ Object.defineProperty(error, "cause", {
10
+ value: cause,
11
+ configurable: true,
12
+ writable: true
13
+ });
14
+ return error;
15
+ }
16
+
17
+ // src/client/admin-error.ts
18
+ var ADMIN_ERROR = /* @__PURE__ */ Symbol.for("@saasicat/ui-vue/AdminError");
19
+ var PLATFORM_ERROR = /* @__PURE__ */ Symbol.for("@saasicat/ui-vue/PlatformError");
20
+ function markPlatformError(error) {
21
+ Object.defineProperty(error, PLATFORM_ERROR, { value: true });
22
+ }
23
+ function isPlatformError(value) {
24
+ return typeof value === "object" && value !== null && PLATFORM_ERROR in value;
25
+ }
26
+ var EMPTY_RESPONSE = /* @__PURE__ */ Symbol.for("@saasicat/ui-vue/EmptyResponse");
27
+ function markEmptyResponse(error) {
28
+ Object.defineProperty(error, EMPTY_RESPONSE, { value: true });
29
+ return error;
30
+ }
31
+ function isEmptyResponse(value) {
32
+ return typeof value === "object" && value !== null && EMPTY_RESPONSE in value;
33
+ }
34
+ var TRANSPORT_FAILURE = /* @__PURE__ */ Symbol.for("@saasicat/ui-vue/TransportFailure");
35
+ function markTransportFailure(error) {
36
+ if (typeof error === "object" && error !== null) {
37
+ try {
38
+ Object.defineProperty(error, TRANSPORT_FAILURE, { value: true });
39
+ } catch {
40
+ }
41
+ }
42
+ return error;
43
+ }
44
+ function isTransportFailure(value) {
45
+ return typeof value === "object" && value !== null && TRANSPORT_FAILURE in value;
46
+ }
47
+ function describe(init) {
48
+ const status = init.status ?? 0;
49
+ const where = [init.method, init.url].filter(Boolean).join(" ");
50
+ const what = status > 0 ? `HTTP ${status}` : "request failed";
51
+ const head = where ? `${where} \u2192 ${what}` : what;
52
+ const code = init.code ? ` (${init.code})` : "";
53
+ return init.detail ? `${head}${code}: ${init.detail}` : `${head}${code}`;
54
+ }
55
+ var AdminError = class extends Error {
56
+ /** HTTP status. `0` means the request never produced one. */
57
+ status;
58
+ /** Machine-readable code from the response body, when it carried one. */
59
+ code;
60
+ /** Parsed response body, when there was one. */
61
+ body;
62
+ /** Requested URL, when known. */
63
+ url;
64
+ /** Request method, when known. */
65
+ method;
66
+ /**
67
+ * Text the failing side supplied — the only candidate for user-facing
68
+ * output. `undefined` when nothing was supplied.
69
+ */
70
+ detail;
71
+ /** The request completed but the answer was unusable. See `AdminErrorInit`. */
72
+ emptyResponse;
73
+ /** The request never reached the server. See `AdminErrorInit`. */
74
+ transportFailure;
75
+ constructor(init = {}) {
76
+ super(init.message ?? describe(init));
77
+ if (init.cause !== void 0) attachCause(this, init.cause);
78
+ this.name = "AdminError";
79
+ this.status = init.status ?? 0;
80
+ this.code = init.code;
81
+ this.body = init.body;
82
+ this.url = init.url;
83
+ this.method = init.method;
84
+ this.detail = init.detail;
85
+ this.emptyResponse = init.emptyResponse ?? false;
86
+ this.transportFailure = init.transportFailure ?? false;
87
+ Object.defineProperty(this, ADMIN_ERROR, { value: true });
88
+ markPlatformError(this);
89
+ }
90
+ };
91
+ function isAdminError(err) {
92
+ return typeof err === "object" && err !== null && ADMIN_ERROR in err;
93
+ }
94
+ function asRecord(value) {
95
+ return typeof value === "object" && value !== null ? value : void 0;
96
+ }
97
+ function asString(value) {
98
+ return typeof value === "string" && value.trim().length > 0 ? value : void 0;
99
+ }
100
+ function isAxiosNoResponseError(value) {
101
+ const record = asRecord(value);
102
+ if (!record) return false;
103
+ return record.isAxiosError === true && record.request != null && record.response == null;
104
+ }
105
+ function readErrorDetail(body) {
106
+ const record = asRecord(body);
107
+ if (!record) return asString(body);
108
+ const message = record.message;
109
+ if (Array.isArray(message)) {
110
+ const parts = message.filter((part) => typeof part === "string");
111
+ return parts.length > 0 ? parts.join(", ") : void 0;
112
+ }
113
+ return asString(message) ?? asString(record.error) ?? asString(record.reason);
114
+ }
115
+ function readErrorCode(body) {
116
+ return asString(asRecord(body)?.code);
117
+ }
118
+ function toAdminError(err) {
119
+ if (isAdminError(err)) return err;
120
+ const record = asRecord(err);
121
+ const response = asRecord(record?.response);
122
+ if (response && typeof response.status === "number") {
123
+ const config = asRecord(record?.config);
124
+ const body = response.data;
125
+ return new AdminError({
126
+ status: response.status,
127
+ code: readErrorCode(body),
128
+ body,
129
+ url: asString(config?.url),
130
+ method: asString(config?.method)?.toUpperCase(),
131
+ // Only what the response actually said. `err.message` here is
132
+ // axios's own generated line ("Request failed with status code
133
+ // 401") — English, untranslated, and about the transport rather
134
+ // than about what went wrong. Letting it through as `detail` would
135
+ // make `adminErrorMessage` return it in preference to the
136
+ // localized wording, which is the whole point of that wording.
137
+ detail: readErrorDetail(body),
138
+ cause: err
139
+ });
140
+ }
141
+ if (record && typeof record.status === "number") {
142
+ const ours = isPlatformError(err);
143
+ return new AdminError({
144
+ status: record.status,
145
+ code: readErrorCode(record.body) ?? asString(record.code),
146
+ body: record.body,
147
+ // Same reasoning as above: `BundlesApiError`'s message is
148
+ // "Bundles API responded with HTTP 403", a diagnostic. It stays on
149
+ // `message`, where logs read it.
150
+ detail: readErrorDetail(record.body) ?? (ours ? void 0 : asString(record.message)),
151
+ // Asked of the throw site, not of the class: only the site that
152
+ // read the response knows whether one arrived. `status === 0 &&
153
+ // ours` was the same question put to the wrong witness — the
154
+ // loaders raise branded errors from a status the client chose, so
155
+ // a client that resolves a transport failure as `0` turned a
156
+ // failed GET into "the change may have been applied".
157
+ emptyResponse: isEmptyResponse(err),
158
+ transportFailure: isTransportFailure(err),
159
+ message: asString(record.message),
160
+ cause: err
161
+ });
162
+ }
163
+ if (err instanceof Error) {
164
+ const transport = isTransportFailure(err) || isAxiosNoResponseError(err);
165
+ const saidSomethingReadable = !transport && !(err instanceof TypeError);
166
+ return new AdminError({
167
+ detail: saidSomethingReadable ? asString(err.message) : void 0,
168
+ transportFailure: transport,
169
+ message: err.message,
170
+ cause: err
171
+ });
172
+ }
173
+ const text = asString(err) ?? asString(record?.message);
174
+ return new AdminError({
175
+ code: asString(record?.code),
176
+ detail: text,
177
+ message: text,
178
+ cause: err
179
+ });
180
+ }
181
+ function statusKey(status) {
182
+ if (status === 400 || status === 422) return "validation";
183
+ if (status === 401) return "unauthorized";
184
+ if (status === 403) return "forbidden";
185
+ if (status === 404) return "notFound";
186
+ if (status === 409) return "conflict";
187
+ if (status === 429) return "rateLimited";
188
+ if (status >= 500) return "server";
189
+ return void 0;
190
+ }
191
+ function adminErrorMessage(err, msgs) {
192
+ const error = toAdminError(err);
193
+ if (error.detail) return error.detail;
194
+ if (error.emptyResponse) return msgs.emptyResponse;
195
+ if (error.transportFailure) return msgs.network;
196
+ if (error.status > 0) {
197
+ const key = statusKey(error.status);
198
+ return key ? msgs[key] : formatMessage(msgs.httpStatus, { status: error.status });
199
+ }
200
+ return msgs.unexpected;
201
+ }
202
+
203
+ // src/client/http-json.ts
204
+ function serverAnswered(status) {
205
+ return status > 0;
206
+ }
207
+ function requireServerAnswer(status, method, url, raise) {
208
+ if (serverAnswered(status)) return;
209
+ throw markTransportFailure(
210
+ raise(`${method} ${url} produced no HTTP status \u2014 the request did not reach the server`)
211
+ );
212
+ }
213
+ var HttpJsonError = AdminError;
214
+ async function readErrorBody(res) {
215
+ try {
216
+ return await res.json();
217
+ } catch {
218
+ return void 0;
219
+ }
220
+ }
221
+ async function failed(res, method, url) {
222
+ const body = await readErrorBody(res);
223
+ return new AdminError({
224
+ status: res.status,
225
+ // Read here rather than left to the status: `status: 0` reaches
226
+ // `adminErrorMessage` as "nothing is known about this failure", and
227
+ // this seam knows better — it held the response.
228
+ transportFailure: !serverAnswered(res.status),
229
+ code: readErrorCode(body),
230
+ body,
231
+ url,
232
+ method,
233
+ // Shared with `toAdminError` on purpose: a NestJS `ValidationPipe`
234
+ // rejection carries `message` as an array, and reading it here in a
235
+ // second, narrower way is how those constraints went missing.
236
+ detail: readErrorDetail(body)
237
+ });
238
+ }
239
+ function trimTrailingSlashes(url) {
240
+ let end = url.length;
241
+ while (end > 0 && url[end - 1] === "/") end--;
242
+ return url.slice(0, end);
243
+ }
244
+ async function getJson(http, url) {
245
+ const res = await http(url);
246
+ if (res.status < 200 || res.status >= 300) {
247
+ throw await failed(res, "GET", url);
248
+ }
249
+ return await res.json();
250
+ }
251
+ async function postJson(http, url, body) {
252
+ const res = await http(url, {
253
+ method: "POST",
254
+ headers: { "Content-Type": "application/json" },
255
+ body: JSON.stringify(body)
256
+ });
257
+ if (res.status < 200 || res.status >= 300) {
258
+ throw await failed(res, "POST", url);
259
+ }
260
+ return await res.json();
261
+ }
262
+
263
+ // src/client/http/fetch-http-client.ts
264
+ var ABSOLUTE_URL = /^[a-z][a-z0-9+.-]*:|^\/\//i;
265
+ function resolveUrl(url, baseUrl) {
266
+ if (!baseUrl || ABSOLUTE_URL.test(url)) return url;
267
+ return `${trimTrailingSlashes(baseUrl)}${url.startsWith("/") ? "" : "/"}${url}`;
268
+ }
269
+ function createFetchHttpClient(options = {}) {
270
+ return async (url, init) => {
271
+ const headers = new Headers(await options.headers?.());
272
+ if (!headers.has("Accept")) headers.set("Accept", "application/json");
273
+ for (const [name, value] of Object.entries(init?.headers ?? {})) {
274
+ headers.set(name, value);
275
+ }
276
+ if (init?.body !== void 0 && !headers.has("Content-Type")) {
277
+ headers.set("Content-Type", "application/json");
278
+ }
279
+ let response;
280
+ try {
281
+ response = await fetch(resolveUrl(url, options.baseUrl), {
282
+ method: init?.method ?? "GET",
283
+ headers,
284
+ body: init?.body
285
+ });
286
+ } catch (err) {
287
+ throw markTransportFailure(err);
288
+ }
289
+ return response;
290
+ };
291
+ }
292
+
1
293
  // src/client/types.ts
2
294
  var NOOP_KV_STORE = {
3
295
  get: () => null,
@@ -39,9 +331,386 @@ function resolveLocalStorage() {
39
331
  }
40
332
  }
41
333
  function defaultHttpClient() {
42
- return (url, init) => fetch(url, init);
334
+ return createFetchHttpClient();
335
+ }
336
+
337
+ // src/client/resources/define-resource.ts
338
+ function defineResource(key, ops, options = {}) {
339
+ return { key, ops, projectScoped: options.projectScoped ?? false };
340
+ }
341
+ function requireProjectKey(def, ctx) {
342
+ if (!def.projectScoped) return;
343
+ if (typeof ctx.projectKey === "string" && ctx.projectKey.trim() !== "") return;
344
+ throw new Error(
345
+ `Resource "${def.key}" is project-scoped, but the context it is bound to names no project. Its requests would go out as \`?projectKey=\`, which the admin API answers for no project at all: the catalogue reads as empty and the first create fails validation instead. Name the project this admin administers \u2014 \`endpoints.projectKey\` for createSuperAdminApp(), \`context.projectKey\` for createResourceRegistry().`
346
+ );
347
+ }
348
+ function bindResource(def, http, ctx) {
349
+ const readContext = typeof ctx === "function" ? ctx : () => ctx;
350
+ if (def.projectScoped) requireProjectKey(def, readContext());
351
+ const bound = {};
352
+ for (const [name, op] of Object.entries(def.ops)) {
353
+ bound[name] = async (...args) => {
354
+ const context = readContext();
355
+ requireProjectKey(def, context);
356
+ return op(http, context, ...args);
357
+ };
358
+ }
359
+ return bound;
360
+ }
361
+
362
+ // src/client/resources/resource-request.ts
363
+ async function requestJson(http, url, init = {}) {
364
+ const method = init.method ?? "GET";
365
+ const response = await http(url, {
366
+ method,
367
+ headers: { "content-type": "application/json", ...init.headers },
368
+ body: init.body === void 0 ? void 0 : JSON.stringify(init.body)
369
+ });
370
+ requireServerAnswer(
371
+ response.status,
372
+ method,
373
+ url,
374
+ (diagnostic) => new AdminError({ status: response.status, url, method, message: diagnostic })
375
+ );
376
+ if (response.status === 204) return null;
377
+ const body = await response.json().catch(() => null);
378
+ if (response.status >= 400) {
379
+ throw new AdminError({
380
+ status: response.status,
381
+ code: readErrorCode(body),
382
+ body,
383
+ // The third place this had to be read, and the second time it was
384
+ // read a narrower way: without `detail` a page migrating onto these
385
+ // resources loses "Plan already exists" and shows the generic
386
+ // status sentence instead. One decision, one function.
387
+ detail: readErrorDetail(body),
388
+ url,
389
+ method
390
+ });
391
+ }
392
+ return body;
393
+ }
394
+ async function requestJsonBody(http, url, what, init = {}) {
395
+ const body = await requestJson(http, url, init);
396
+ if (body === null || body === void 0) {
397
+ throw new AdminError({
398
+ status: 0,
399
+ url,
400
+ method: init.method ?? "GET",
401
+ // `what` is a diagnostic — "Create returned no body" — so it goes
402
+ // on `message`, not `detail`. As `detail` it out-ranked the
403
+ // localized wording and the operator read an English internal
404
+ // string instead of being told the change may already have landed.
405
+ message: what,
406
+ emptyResponse: true
407
+ });
408
+ }
409
+ return body;
43
410
  }
44
411
 
412
+ // src/client/resources/list-resource.ts
413
+ var LIST_FIRST_PAGE = 1;
414
+ var LIST_PAGE_SIZE_DEFAULT = 50;
415
+ var LIST_PAGE_SIZE_MAX = 200;
416
+ var LIST_PAGINATION_PARAMS = ["page", "pageSize"];
417
+ function isSentInQuery(value) {
418
+ return value !== void 0 && value !== null && value !== "";
419
+ }
420
+ function appendFilter(params, filter) {
421
+ for (const [key, value] of Object.entries(filter ?? {})) {
422
+ if (!isSentInQuery(value)) continue;
423
+ params.set(key, String(value));
424
+ }
425
+ }
426
+ function filterQueryString(filter) {
427
+ const params = new URLSearchParams();
428
+ appendFilter(params, filter);
429
+ const query = params.toString();
430
+ return query ? `?${query}` : "";
431
+ }
432
+ function listUrl(endpoint, query = {}) {
433
+ const params = new URLSearchParams();
434
+ params.set("page", String(query.page ?? LIST_FIRST_PAGE));
435
+ params.set("pageSize", String(query.pageSize ?? LIST_PAGE_SIZE_DEFAULT));
436
+ appendFilter(params, query.filter);
437
+ return `${endpoint}${endpoint.includes("?") ? "&" : "?"}${params.toString()}`;
438
+ }
439
+ function readListPage(raw) {
440
+ if (Array.isArray(raw)) return { items: raw, total: raw.length };
441
+ if (raw === null || typeof raw !== "object") return { items: [], total: 0 };
442
+ const body = raw;
443
+ const page = {
444
+ items: Array.isArray(body.items) ? body.items : []
445
+ };
446
+ if (typeof body.total === "number") page.total = body.total;
447
+ if (typeof body.page === "number") page.page = body.page;
448
+ if (typeof body.pageSize === "number") page.pageSize = body.pageSize;
449
+ return page;
450
+ }
451
+ function clampListPage(page) {
452
+ return Math.max(LIST_FIRST_PAGE, Math.floor(page));
453
+ }
454
+ function clampListPageSize(size) {
455
+ return Math.min(LIST_PAGE_SIZE_MAX, Math.max(1, Math.floor(size)));
456
+ }
457
+ function defineListOp(endpoint) {
458
+ return async (http, ctx, query) => readListPage(await requestJson(http, listUrl(endpoint(ctx), query)));
459
+ }
460
+
461
+ // src/client/resources/plans.resource.ts
462
+ function plansUrl(ctx) {
463
+ return `${ctx.apiBase}/catalog/plans`;
464
+ }
465
+ function versionsUrl(ctx) {
466
+ return `${ctx.apiBase}/catalog/plan-versions`;
467
+ }
468
+ function scoped(url, ctx) {
469
+ return `${url}?projectKey=${encodeURIComponent(ctx.projectKey)}`;
470
+ }
471
+ var plansResource = defineResource(
472
+ "plans",
473
+ {
474
+ list: async (http, ctx) => await requestJson(http, scoped(plansUrl(ctx), ctx)) ?? [],
475
+ tenantCounts: async (http, ctx) => await requestJson(
476
+ http,
477
+ scoped(`${plansUrl(ctx)}/tenant-counts`, ctx)
478
+ ) ?? {},
479
+ /**
480
+ * The project comes from the context, not from the caller.
481
+ *
482
+ * The endpoint takes it in the body, and the composable this replaces
483
+ * made that the caller's job — so a page had to keep hold of the
484
+ * project it was administering just to create in it, and `list()` and
485
+ * `create()` read two different sources for one value. A stale one
486
+ * creates a plan outside the project the list is showing.
487
+ */
488
+ create: (http, ctx, data) => requestJsonBody(http, plansUrl(ctx), "Create returned no body", {
489
+ method: "POST",
490
+ body: { ...data, projectKey: ctx.projectKey }
491
+ }),
492
+ update: (http, ctx, planId, data) => requestJsonBody(
493
+ http,
494
+ `${plansUrl(ctx)}/${planId}`,
495
+ "Update returned no body",
496
+ {
497
+ method: "PATCH",
498
+ body: data
499
+ }
500
+ ),
501
+ /** Marks the plan deleted. Fails while it still has versions. */
502
+ softDelete: async (http, ctx, planId) => {
503
+ await requestJson(http, `${plansUrl(ctx)}/${planId}`, { method: "DELETE" });
504
+ },
505
+ /** Removes the plan for good. 422 `PLAN_HAS_VERSIONS` when it cannot. */
506
+ hardDelete: async (http, ctx, planId) => {
507
+ await requestJson(http, `${plansUrl(ctx)}/${planId}/purge`, { method: "DELETE" });
508
+ }
509
+ },
510
+ { projectScoped: true }
511
+ );
512
+ var planVersionsResource = defineResource("planVersions", {
513
+ listForPlan: async (http, ctx, planId) => await requestJson(http, `${plansUrl(ctx)}/${planId}/versions`) ?? [],
514
+ createDraft: (http, ctx, planId, data) => requestJsonBody(
515
+ http,
516
+ `${plansUrl(ctx)}/${planId}/versions`,
517
+ "CreateDraft returned no body",
518
+ { method: "POST", body: data }
519
+ ),
520
+ updateDraft: (http, ctx, versionId, data) => requestJsonBody(
521
+ http,
522
+ `${versionsUrl(ctx)}/${versionId}`,
523
+ "UpdateDraft returned no body",
524
+ { method: "PATCH", body: data }
525
+ ),
526
+ publish: (http, ctx, versionId, options = {}) => requestJsonBody(
527
+ http,
528
+ `${versionsUrl(ctx)}/${versionId}/publish`,
529
+ "Publish returned no body",
530
+ { method: "POST", body: options }
531
+ ),
532
+ discardDraft: async (http, ctx, versionId) => {
533
+ await requestJson(http, `${versionsUrl(ctx)}/${versionId}`, { method: "DELETE" });
534
+ },
535
+ /**
536
+ * Ends a live version on `endsAt`, with no successor. Idempotent — a
537
+ * second call with another date overwrites the first.
538
+ *
539
+ * `endsAt` is the date itself, not a payload: the endpoint's body is
540
+ * `{ endsAt }` and wrapping it is this operation's job, not the caller's.
541
+ */
542
+ terminate: (http, ctx, versionId, endsAt) => requestJsonBody(
543
+ http,
544
+ `${versionsUrl(ctx)}/${versionId}/terminate`,
545
+ "Terminate returned no body",
546
+ { method: "POST", body: { endsAt } }
547
+ )
548
+ });
549
+
550
+ // src/client/resources/tenants.resource.ts
551
+ var tenantsResource = defineResource("tenants", {
552
+ list: defineListOp((ctx) => `${ctx.apiBase}/tenants`)
553
+ });
554
+
555
+ // src/client/resources/audit.resource.ts
556
+ var auditResource = defineResource("audit", {
557
+ list: defineListOp((ctx) => `${ctx.apiBase}/audit`)
558
+ });
559
+
560
+ // src/client/resources/index.ts
561
+ var platformResources = {
562
+ plans: plansResource,
563
+ planVersions: planVersionsResource,
564
+ tenants: tenantsResource,
565
+ audit: auditResource
566
+ };
567
+
568
+ // src/client/boot-loader.ts
569
+ var BootLoadError = class extends Error {
570
+ constructor(status, message) {
571
+ super(message);
572
+ this.status = status;
573
+ this.name = "BootLoadError";
574
+ markPlatformError(this);
575
+ }
576
+ status;
577
+ };
578
+ var BootLoader = class {
579
+ endpoint;
580
+ http;
581
+ constructor(options) {
582
+ if (!options?.endpoint) {
583
+ throw new Error(
584
+ 'BootLoader: `endpoint` is required (e.g. "/api/admin/boot" or "/api/v1/admin/boot"). The platform has no default because apps use different globalPrefix conventions.'
585
+ );
586
+ }
587
+ this.endpoint = options.endpoint;
588
+ this.http = options.http ?? defaultHttpClient();
589
+ }
590
+ async load() {
591
+ const res = await this.http(this.endpoint);
592
+ requireServerAnswer(
593
+ res.status,
594
+ "GET",
595
+ this.endpoint,
596
+ (diagnostic) => new BootLoadError(res.status, diagnostic)
597
+ );
598
+ if (res.status !== 200) {
599
+ throw new BootLoadError(res.status, `Boot endpoint responded with HTTP ${res.status}`);
600
+ }
601
+ return await res.json();
602
+ }
603
+ };
604
+
605
+ // src/client/manifest-loader.ts
606
+ var NOT_MODIFIED = 304;
607
+ var ManifestLoadError = class extends Error {
608
+ constructor(status, message) {
609
+ super(message);
610
+ this.status = status;
611
+ this.name = "ManifestLoadError";
612
+ markPlatformError(this);
613
+ }
614
+ status;
615
+ };
616
+ var ManifestLoader = class {
617
+ endpoint;
618
+ http;
619
+ storage;
620
+ bodyKey;
621
+ etagKey;
622
+ getAuthToken;
623
+ constructor(options) {
624
+ if (!options?.endpoint) {
625
+ throw new Error(
626
+ 'ManifestLoader: `endpoint` is required (e.g. "/api/admin/manifest" or "/api/v1/admin/manifest"). The platform has no default because apps use different globalPrefix conventions.'
627
+ );
628
+ }
629
+ this.endpoint = options.endpoint;
630
+ this.http = options.http ?? defaultHttpClient();
631
+ this.storage = options.storage ?? defaultKvStore();
632
+ const prefix = options.storageKeyPrefix ?? "";
633
+ this.bodyKey = `${prefix}manifest:body`;
634
+ this.etagKey = `${prefix}manifest:etag`;
635
+ this.getAuthToken = options.getAuthToken;
636
+ }
637
+ /**
638
+ * Loads the current manifest. On a cache hit (304) the cached
639
+ * body is returned — otherwise the fresh server body.
640
+ *
641
+ * A 304 with no usable cached body is not a failure to report but one to
642
+ * undo: the request only carried `If-None-Match` because this loader put
643
+ * the ETag there, and the cache it validated against is this loader's own.
644
+ * So it drops the pair and asks once more without the header, which is the
645
+ * same recovery a person would have had to trigger by hand — except that
646
+ * the person reading an admin screen has neither this loader nor a
647
+ * console. Exactly one retry: a 304 to a request that asked nothing
648
+ * conditional is a server fault, and no repetition fixes it.
649
+ */
650
+ async load() {
651
+ const cachedEtag = this.storage.get(this.etagKey);
652
+ const res = await this.request(cachedEtag);
653
+ if (res.status !== NOT_MODIFIED) return this.acceptFresh(res);
654
+ const cached = this.readCachedBody();
655
+ if (cached) return cached.body;
656
+ if (cachedEtag) {
657
+ this.clearCache();
658
+ const fresh = await this.request(null);
659
+ if (fresh.status !== NOT_MODIFIED) return this.acceptFresh(fresh);
660
+ }
661
+ throw new ManifestLoadError(
662
+ NOT_MODIFIED,
663
+ "Manifest endpoint answered HTTP 304 to a request without If-None-Match"
664
+ );
665
+ }
666
+ /** Sends the manifest GET, conditional when an ETag is supplied. */
667
+ request(etag) {
668
+ const headers = {};
669
+ const token = this.getAuthToken?.();
670
+ if (token) headers.Authorization = `Bearer ${token}`;
671
+ if (etag) headers["If-None-Match"] = etag;
672
+ return this.http(this.endpoint, { method: "GET", headers });
673
+ }
674
+ /** Takes a non-304 response as the current manifest and caches it. */
675
+ async acceptFresh(res) {
676
+ requireServerAnswer(
677
+ res.status,
678
+ "GET",
679
+ this.endpoint,
680
+ (diagnostic) => new ManifestLoadError(res.status, diagnostic)
681
+ );
682
+ if (res.status !== 200) {
683
+ throw new ManifestLoadError(
684
+ res.status,
685
+ `Manifest endpoint responded with HTTP ${res.status}`
686
+ );
687
+ }
688
+ const body = await res.json();
689
+ const etag = res.headers.get("ETag") ?? res.headers.get("etag");
690
+ if (etag) {
691
+ this.storage.set(this.etagKey, etag);
692
+ this.storage.set(this.bodyKey, JSON.stringify(body));
693
+ }
694
+ return body;
695
+ }
696
+ /** Reads the cached manifest body from storage; null if absent. */
697
+ readCachedBody() {
698
+ const etag = this.storage.get(this.etagKey);
699
+ const bodyStr = this.storage.get(this.bodyKey);
700
+ if (!etag || !bodyStr) return null;
701
+ try {
702
+ return { etag, body: JSON.parse(bodyStr) };
703
+ } catch {
704
+ return null;
705
+ }
706
+ }
707
+ /** Clears the cache — e.g. on logout or after `manifest reload`. */
708
+ clearCache() {
709
+ this.storage.remove(this.etagKey);
710
+ this.storage.remove(this.bodyKey);
711
+ }
712
+ };
713
+
45
714
  // src/client/i18n/locale.ts
46
715
  var SA_LOCALES = ["de", "en"];
47
716
  var DEFAULT_SA_LOCALE = "de";
@@ -78,12 +747,6 @@ function mergeMessages(base, overrides) {
78
747
  return result;
79
748
  }
80
749
 
81
- // src/client/i18n/format.ts
82
- import { formatErrorMessage } from "@saasicat/types";
83
- function formatMessage(template, params) {
84
- return formatErrorMessage(template, params);
85
- }
86
-
87
750
  // src/client/i18n/currency.ts
88
751
  var DEFAULT_CURRENCY = "EUR";
89
752
  var formatters = /* @__PURE__ */ new Map();
@@ -781,9 +1444,6 @@ var discoveryMessages = defineMessages(
781
1444
  replacedBy: "ersetzt durch {key}",
782
1445
  replaces: "ersetzt: {keys}",
783
1446
  reapproveEmphasis: "erneut freigeben",
784
- errorDiscoveryHttp: "Discovery-Endpoint antwortete mit HTTP {status}",
785
- errorRescanHttp: "Discovery-Rescan antwortete mit HTTP {status}",
786
- errorCatalogHttp: "Catalog-Entries-API antwortete mit HTTP {status}",
787
1447
  capList: {
788
1448
  orphanFeature: "Feature im Katalog ohne implementierende Capability \u2014 blockiert im blocking-Strict-Mode das Plan-Publish.",
789
1449
  removedFromCode: "aus Code entfernt"
@@ -885,9 +1545,6 @@ var discoveryMessages = defineMessages(
885
1545
  replacedBy: "replaced by {key}",
886
1546
  replaces: "replaces: {keys}",
887
1547
  reapproveEmphasis: "approve again",
888
- errorDiscoveryHttp: "Discovery endpoint responded with HTTP {status}",
889
- errorRescanHttp: "Discovery rescan responded with HTTP {status}",
890
- errorCatalogHttp: "Catalog entries API responded with HTTP {status}",
891
1548
  capList: {
892
1549
  orphanFeature: "Feature in the catalog without an implementing capability \u2014 blocks plan publishing in blocking strict mode.",
893
1550
  removedFromCode: "removed from code"
@@ -1092,6 +1749,36 @@ var emailMessages = defineMessages(
1092
1749
  }
1093
1750
  );
1094
1751
 
1752
+ // src/client/i18n/messages/errors.ts
1753
+ var errorsMessages = defineMessages(
1754
+ {
1755
+ network: "Die Anfrage konnte nicht abgeschlossen werden. Bitte Verbindung pr\xFCfen.",
1756
+ emptyResponse: "Der Server hat nichts zur\xFCckgegeben. Bitte pr\xFCfen, ob die \xC4nderung angekommen ist.",
1757
+ unauthorized: "Die Sitzung ist abgelaufen. Bitte erneut anmelden.",
1758
+ forbidden: "F\xFCr diese Aktion fehlt die Berechtigung.",
1759
+ notFound: "Der angeforderte Eintrag existiert nicht mehr.",
1760
+ conflict: "Der Eintrag wurde zwischenzeitlich ge\xE4ndert. Bitte neu laden.",
1761
+ validation: "Die Eingaben wurden nicht akzeptiert.",
1762
+ rateLimited: "Zu viele Anfragen. Bitte kurz warten und erneut versuchen.",
1763
+ server: "Der Server konnte die Anfrage nicht verarbeiten.",
1764
+ httpStatus: "Die Anfrage ist fehlgeschlagen (HTTP {status}).",
1765
+ unexpected: "Es ist ein unerwarteter Fehler aufgetreten. Bitte erneut versuchen."
1766
+ },
1767
+ {
1768
+ network: "The request could not be completed. Please check your connection.",
1769
+ emptyResponse: "The server returned nothing. Please check whether the change was applied.",
1770
+ unauthorized: "Your session has expired. Please sign in again.",
1771
+ forbidden: "You do not have permission for this action.",
1772
+ notFound: "The requested entry no longer exists.",
1773
+ conflict: "The entry changed in the meantime. Please reload.",
1774
+ validation: "The input was not accepted.",
1775
+ rateLimited: "Too many requests. Please wait a moment and try again.",
1776
+ server: "The server could not process the request.",
1777
+ httpStatus: "The request failed (HTTP {status}).",
1778
+ unexpected: "Something went wrong. Please try again."
1779
+ }
1780
+ );
1781
+
1095
1782
  // src/client/i18n/messages/marketing.ts
1096
1783
  var marketingMessages = defineMessages(
1097
1784
  {
@@ -1216,9 +1903,6 @@ var marketingMessages = defineMessages(
1216
1903
  colorLabel: "Farbe",
1217
1904
  delete: "Aktion l\xF6schen",
1218
1905
  deleteConfirm: "Aktion \u201E{label}\u201C wirklich l\xF6schen?"
1219
- },
1220
- errors: {
1221
- projectionsApi: "MarketingProjections-API antwortete mit HTTP {status}"
1222
1906
  }
1223
1907
  },
1224
1908
  {
@@ -1343,9 +2027,6 @@ var marketingMessages = defineMessages(
1343
2027
  colorLabel: "Color",
1344
2028
  delete: "Delete promotion",
1345
2029
  deleteConfirm: 'Really delete promotion "{label}"?'
1346
- },
1347
- errors: {
1348
- projectionsApi: "MarketingProjections API responded with HTTP {status}"
1349
2030
  }
1350
2031
  }
1351
2032
  );
@@ -2589,7 +3270,6 @@ var promosMessages = defineMessages(
2589
3270
  {
2590
3271
  title: "Promo-Codes",
2591
3272
  createAction: "Promo-Code anlegen",
2592
- apiErrorHttpStatus: "Promotions-API antwortete mit HTTP {status}",
2593
3273
  list: {
2594
3274
  searchLabel: "Suche (Code)",
2595
3275
  tileScheduled: "Geplant",
@@ -2687,7 +3367,6 @@ var promosMessages = defineMessages(
2687
3367
  {
2688
3368
  title: "Promo codes",
2689
3369
  createAction: "Create promo code",
2690
- apiErrorHttpStatus: "Promotions API responded with HTTP {status}",
2691
3370
  list: {
2692
3371
  searchLabel: "Search (code)",
2693
3372
  tileScheduled: "Scheduled",
@@ -2795,7 +3474,13 @@ var shellMessages = defineMessages(
2795
3474
  subtitle: "SuperAdmin \xB7 Plattform-Verwaltung",
2796
3475
  roleBadge: "SUPER ADMIN",
2797
3476
  logout: "Abmelden",
2798
- language: "Sprache"
3477
+ language: "Sprache",
3478
+ theme: {
3479
+ label: "Darstellung",
3480
+ light: "Hell",
3481
+ dark: "Dunkel",
3482
+ system: "System"
3483
+ }
2799
3484
  },
2800
3485
  drawer: {
2801
3486
  docs: "Doku \xF6ffnen"
@@ -2864,7 +3549,13 @@ var shellMessages = defineMessages(
2864
3549
  subtitle: "SuperAdmin \xB7 Platform administration",
2865
3550
  roleBadge: "SUPER ADMIN",
2866
3551
  logout: "Sign out",
2867
- language: "Language"
3552
+ language: "Language",
3553
+ theme: {
3554
+ label: "Appearance",
3555
+ light: "Light",
3556
+ dark: "Dark",
3557
+ system: "System"
3558
+ }
2868
3559
  },
2869
3560
  drawer: {
2870
3561
  docs: "Open documentation"
@@ -3108,6 +3799,7 @@ var usersMessages = defineMessages(
3108
3799
  function catalogFor(locale) {
3109
3800
  return {
3110
3801
  common: commonMessages[locale],
3802
+ errors: errorsMessages[locale],
3111
3803
  nav: navMessages[locale],
3112
3804
  shell: shellMessages[locale],
3113
3805
  dashboard: dashboardMessages[locale],
@@ -3167,8 +3859,52 @@ function createSaCatalog(options = {}) {
3167
3859
  }
3168
3860
 
3169
3861
  export {
3862
+ formatMessage,
3863
+ markPlatformError,
3864
+ isPlatformError,
3865
+ markEmptyResponse,
3866
+ isEmptyResponse,
3867
+ markTransportFailure,
3868
+ isTransportFailure,
3869
+ AdminError,
3870
+ isAdminError,
3871
+ isAxiosNoResponseError,
3872
+ readErrorDetail,
3873
+ readErrorCode,
3874
+ toAdminError,
3875
+ adminErrorMessage,
3876
+ requireServerAnswer,
3877
+ HttpJsonError,
3878
+ trimTrailingSlashes,
3879
+ getJson,
3880
+ postJson,
3881
+ createFetchHttpClient,
3170
3882
  defaultKvStore,
3171
3883
  defaultHttpClient,
3884
+ defineResource,
3885
+ bindResource,
3886
+ requestJson,
3887
+ requestJsonBody,
3888
+ LIST_FIRST_PAGE,
3889
+ LIST_PAGE_SIZE_DEFAULT,
3890
+ LIST_PAGE_SIZE_MAX,
3891
+ LIST_PAGINATION_PARAMS,
3892
+ isSentInQuery,
3893
+ filterQueryString,
3894
+ listUrl,
3895
+ readListPage,
3896
+ clampListPage,
3897
+ clampListPageSize,
3898
+ defineListOp,
3899
+ plansResource,
3900
+ planVersionsResource,
3901
+ tenantsResource,
3902
+ auditResource,
3903
+ platformResources,
3904
+ BootLoadError,
3905
+ BootLoader,
3906
+ ManifestLoadError,
3907
+ ManifestLoader,
3172
3908
  SA_LOCALES,
3173
3909
  DEFAULT_SA_LOCALE,
3174
3910
  SA_INTL_LOCALES,
@@ -3177,7 +3913,6 @@ export {
3177
3913
  defineMessages,
3178
3914
  mergeMessages,
3179
3915
  navMessages,
3180
- formatMessage,
3181
3916
  formatCurrency,
3182
3917
  SA_MESSAGES,
3183
3918
  resolveMessages,