@saasicat/ui-vue 0.24.2 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.md +32 -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-BPF2BMCQ.js} +748 -24
  5. package/dist/{chunk-D74FFCCP.js → chunk-NEXTZZRQ.js} +95 -177
  6. package/dist/{chunk-TQQMKWN6.js → chunk-O3J3ITF2.js} +119 -7
  7. package/dist/client/index.cjs +758 -83
  8. package/dist/client/index.d.cts +414 -9
  9. package/dist/client/index.d.ts +414 -9
  10. package/dist/client/index.js +84 -12
  11. package/dist/index.cjs +1325 -294
  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 +574 -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 +4 -3
  22. package/src/client/admin-error.ts +552 -0
  23. package/src/client/admin-resource-client.ts +9 -12
  24. package/src/client/batch-column-fetcher.ts +2 -2
  25. package/src/client/boot-loader.ts +12 -1
  26. package/src/client/http/axios-http-client.ts +383 -0
  27. package/src/client/http/fetch-http-client.ts +89 -0
  28. package/src/client/http/index.ts +6 -0
  29. package/src/client/http-json.ts +82 -16
  30. package/src/client/i18n/messages/discovery.ts +0 -6
  31. package/src/client/i18n/messages/errors.ts +44 -0
  32. package/src/client/i18n/messages/marketing.ts +0 -6
  33. package/src/client/i18n/messages/promos.ts +0 -2
  34. package/src/client/i18n/messages/shell.ts +12 -0
  35. package/src/client/i18n/messages.ts +3 -0
  36. package/src/client/index.ts +6 -0
  37. package/src/client/manifest-loader.ts +65 -15
  38. package/src/client/resources/audit.resource.ts +25 -0
  39. package/src/client/resources/define-resource.ts +166 -0
  40. package/src/client/resources/index.ts +42 -0
  41. package/src/client/resources/list-resource.ts +176 -0
  42. package/src/client/resources/plans.resource.ts +169 -0
  43. package/src/client/resources/resource-request.ts +117 -0
  44. package/src/client/resources/tenants.resource.ts +27 -0
  45. package/src/client/types.ts +10 -3
  46. package/src/components/BundleVersionPublishDialog.vue +1 -1
  47. package/src/components/MarketingPromotionsTab.vue +263 -241
  48. package/src/components/ThemeSwitcher.vue +85 -0
  49. package/src/components/admin-page/AdminAccordion.vue +125 -0
  50. package/src/components/bundle-editor/BundleCreatePanel.vue +1 -1
  51. package/src/components/dialogs/PromoCodeDialogFields.vue +20 -20
  52. package/src/components/plan/PlanCycleToggle.vue +1 -1
  53. package/src/components/plan-detail/PlanVersionsPanel.vue +5 -2
  54. package/src/index.ts +5 -0
  55. package/src/pages-standard/AdminLayout.vue +57 -0
  56. package/src/pages-standard/AdminManifestErrorPage.vue +4 -3
  57. package/src/pages-standard/BundlesPage.vue +5 -35
  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 +37 -3
  62. package/src/pages-standard/bundles-page/BundleAccordionList.vue +30 -32
  63. package/src/pages-standard/discovery-page/DiscoveryFeatureCard.vue +80 -88
  64. package/src/pages-standard/discovery-page/DiscoveryQuotaCard.vue +73 -79
  65. package/src/pages-standard/marketing-catalog/MarketingCatalogAdmin.vue +44 -6
  66. package/src/pages-standard/marketing-catalog/MarketingCatalogPreview.vue +8 -2
  67. package/src/pages-tenant/PackageSnapshotPanel.vue +31 -2
  68. package/src/quasar/confirm.ts +83 -0
  69. package/src/quasar/create-super-admin-app.ts +84 -10
  70. package/src/quasar/dark-bridge.ts +47 -10
  71. package/src/quasar/index.ts +1 -0
  72. package/src/ui/theme/components/accordion.css +118 -0
  73. package/src/ui/theme/index.css +1 -0
  74. package/src/vue/create-admin-routes.ts +9 -1
  75. package/src/vue/platform-loaders.ts +12 -2
  76. package/src/vue/resource-registry.ts +211 -0
  77. package/src/vue/super-admin-context.ts +20 -5
  78. package/src/vue/ui-confirm.ts +76 -0
  79. package/src/vue/use-api-list.ts +50 -45
  80. package/src/vue/use-async-action.ts +170 -0
  81. package/src/vue/use-async-data.ts +83 -0
  82. package/src/vue/use-bundles.ts +34 -7
  83. package/src/vue/use-catalog-entries.ts +37 -12
  84. package/src/vue/use-discovery.ts +27 -6
  85. package/src/vue/use-marketing-projections.ts +29 -7
  86. package/src/vue/use-plans.ts +36 -8
  87. package/src/vue/use-promotions.ts +27 -7
  88. package/src/vue/use-resource-list.ts +272 -0
  89. package/src/vue/use-sa-theme.ts +34 -5
  90. package/src/vue/use-tenant-subscription-bundles.ts +22 -3
@@ -5,10 +5,10 @@
5
5
  // (e.g. `/api/admin` or `/api/v1/admin`).
6
6
 
7
7
  import { ref, type Ref } from 'vue';
8
+ import { markEmptyResponse, markPlatformError } from '../client/admin-error.js';
8
9
  import type { CreatePromotionData, PromotionRow, UpdatePromotionData } from '@saasicat/types';
9
- import { formatMessage } from '../client/i18n/format.js';
10
+ import { requireServerAnswer } from '../client/http-json.js';
10
11
  import { defaultHttpClient, type HttpClient } from '../client/types.js';
11
- import { useSaMessages } from './use-super-admin-i18n.js';
12
12
 
13
13
  export interface UsePromotionsOptions {
14
14
  adminEndpoint: string;
@@ -18,6 +18,12 @@ export interface UsePromotionsOptions {
18
18
  autoLoad?: boolean;
19
19
  }
20
20
 
21
+ /**
22
+ * A promotions call failed. Its `message` is a diagnostic for the log, in
23
+ * English like every other developer-facing string in the repository — the
24
+ * sentence a user is shown comes from the `errors` catalog through
25
+ * `adminErrorMessage`, in whichever language the shell speaks.
26
+ */
21
27
  export class PromotionsApiError extends Error {
22
28
  constructor(
23
29
  public readonly status: number,
@@ -26,6 +32,9 @@ export class PromotionsApiError extends Error {
26
32
  ) {
27
33
  super(message);
28
34
  this.name = 'PromotionsApiError';
35
+ // Identity, so `toAdminError` can tell this diagnostic from a
36
+ // consumer error whose message an operator needs to read.
37
+ markPlatformError(this);
29
38
  }
30
39
  }
31
40
 
@@ -47,7 +56,6 @@ export function usePromotions(options: UsePromotionsOptions): UsePromotionsResul
47
56
  throw new Error('usePromotions: `projectKey` is required.');
48
57
  }
49
58
 
50
- const msg = useSaMessages('promos');
51
59
  const http = options.http ?? defaultHttpClient();
52
60
  const promotions = ref<PromotionRow[]>([]);
53
61
  const loading = ref(false);
@@ -62,18 +70,28 @@ export function usePromotions(options: UsePromotionsOptions): UsePromotionsResul
62
70
  }
63
71
 
64
72
  async function fetchJson<T>(url: string, init?: Parameters<HttpClient>[1]): Promise<T | null> {
73
+ const method = init?.method ?? 'GET';
65
74
  const res = await http(url, {
66
- method: init?.method ?? 'GET',
75
+ method,
67
76
  headers: { 'content-type': 'application/json', ...authHeaders(), ...init?.headers },
68
77
  body: init?.body,
69
78
  });
79
+ // Before any body is read: `null` below has to mean "the server
80
+ // answered without one", which is what the callers' empty-response
81
+ // sentinels claim.
82
+ requireServerAnswer(
83
+ res.status,
84
+ method,
85
+ url,
86
+ (diagnostic) => new PromotionsApiError(res.status, null, diagnostic),
87
+ );
70
88
  if (res.status === 204) return null;
71
89
  const body = await res.json().catch(() => null);
72
90
  if (res.status >= 400) {
73
91
  throw new PromotionsApiError(
74
92
  res.status,
75
93
  body,
76
- formatMessage(msg.value.apiErrorHttpStatus, { status: res.status }),
94
+ `Promotions API responded with HTTP ${res.status}`,
77
95
  );
78
96
  }
79
97
  return body as T;
@@ -97,7 +115,8 @@ export function usePromotions(options: UsePromotionsOptions): UsePromotionsResul
97
115
  method: 'POST',
98
116
  body: JSON.stringify(data),
99
117
  });
100
- if (!created) throw new PromotionsApiError(0, null, 'Create returned no body');
118
+ if (!created)
119
+ throw markEmptyResponse(new PromotionsApiError(0, null, 'Create returned no body'));
101
120
  promotions.value = [...promotions.value, created];
102
121
  return created;
103
122
  }
@@ -107,7 +126,8 @@ export function usePromotions(options: UsePromotionsOptions): UsePromotionsResul
107
126
  method: 'PATCH',
108
127
  body: JSON.stringify(data),
109
128
  });
110
- if (!updated) throw new PromotionsApiError(0, null, 'Update returned no body');
129
+ if (!updated)
130
+ throw markEmptyResponse(new PromotionsApiError(0, null, 'Update returned no body'));
111
131
  promotions.value = promotions.value.map((p) => (p.id === id ? updated : p));
112
132
  return updated;
113
133
  }
@@ -0,0 +1,272 @@
1
+ // One page of a platform list, typed, with no endpoint to pass in.
2
+ //
3
+ // The difference from `useApiList` is where the URL comes from. `useApiList`
4
+ // takes the endpoint per call site, which is why every page built on it carries
5
+ // an `endpoint` prop and every consumer app spells the same path again — and
6
+ // why the row type is whatever the caller claims. This asks the resource
7
+ // registry, which already knows the API base, the project and the locale, and
8
+ // the row type follows from the operation rather than from an assertion.
9
+ //
10
+ // It adds exactly one thing to `useAsyncData`: the pagination — two refs and
11
+ // the two controls that move them. The in-flight flag, the error, the reset to
12
+ // `initial` on failure, and the generation guard that keeps a superseded load
13
+ // from overwriting a newer one are all `useAsyncData`'s, deliberately not
14
+ // rewritten here. That last one is not cosmetic: `useApiList` has no such
15
+ // guard, so two quick filter keystrokes there can land the loser last.
16
+ //
17
+ // `useApiList` stays as the untyped escape hatch for an app's own endpoints.
18
+
19
+ import { computed, ref, watch, type ComputedRef, type Ref } from 'vue';
20
+
21
+ import { AdminError } from '../client/admin-error.js';
22
+ import type { ResourceOps } from '../client/resources/define-resource.js';
23
+ import type { PlatformResources } from '../client/resources/index.js';
24
+ import {
25
+ LIST_FIRST_PAGE,
26
+ LIST_PAGE_SIZE_DEFAULT,
27
+ LIST_PAGINATION_PARAMS,
28
+ clampListPage,
29
+ clampListPageSize,
30
+ isSentInQuery,
31
+ type ListQuery,
32
+ type ResourceListPage,
33
+ } from '../client/resources/list-resource.js';
34
+ import { useAsyncData } from './use-async-data.js';
35
+ import { useResource, type ResourceMap } from './resource-registry.js';
36
+
37
+ /** The default operation name, and the only one the platform resources offer. */
38
+ const DEFAULT_LIST_OP = 'list';
39
+
40
+ /**
41
+ * The state before the first load and after a failed one.
42
+ *
43
+ * Frozen and shared: `useAsyncData` restores this exact object, so a page that
44
+ * pushed into it would be pushing into every other list's empty state. Frozen,
45
+ * that is a thrown error instead of a row appearing somewhere else.
46
+ */
47
+ const EMPTY_PAGE = Object.freeze({
48
+ items: Object.freeze([]) as readonly never[],
49
+ total: 0,
50
+ }) as ResourceListPage<never>;
51
+
52
+ /** The operations of a resource that answer with one page of a list. */
53
+ export type ListOpNames<TOps extends ResourceOps> = {
54
+ [N in keyof TOps]: Awaited<ReturnType<TOps[N]>> extends ResourceListPage<unknown> ? N : never;
55
+ }[keyof TOps];
56
+
57
+ /**
58
+ * The resources that have such an operation at all.
59
+ *
60
+ * Without this, `useResourceList('plans')` compiled: `plans.list` answers with
61
+ * a plain `PlanRow[]`, the row type collapsed to `never`, and the first thing
62
+ * to notice was `items.value` being `undefined` at runtime. Derived from the
63
+ * operations rather than listed, so a resource that grows a list operation
64
+ * becomes available here by being written, not by being remembered.
65
+ */
66
+ export type ListResourceKey<TMap extends ResourceMap> = {
67
+ [K in keyof TMap]: [ListOpNames<TMap[K]['ops']>] extends [never] ? never : K;
68
+ }[keyof TMap];
69
+
70
+ /**
71
+ * The row type a named list operation yields — read off the operation, not
72
+ * declared next to it. A hand-maintained map of resource to row is the same
73
+ * defect one level up: it can be right today and silently wrong tomorrow.
74
+ */
75
+ export type RowOf<TOps extends ResourceOps, N extends keyof TOps> =
76
+ Awaited<ReturnType<TOps[N]>> extends ResourceListPage<infer TRow> ? TRow : never;
77
+
78
+ /** A paginated read: what a standard admin list binds its table to. */
79
+ export interface AsyncList<T> {
80
+ /** The rows of the current page. */
81
+ items: ComputedRef<T[]>;
82
+ /**
83
+ * Rows matching the filter when the endpoint reports a total, and rows on
84
+ * this page when it does not. Several admin controllers answer with a bare
85
+ * array, and there is no honest way to tell the two apart afterwards — see
86
+ * `ResourceListPage.total`.
87
+ */
88
+ total: ComputedRef<number>;
89
+ /** 1-based. Moved by `goToPage`, and reset to 1 by a filter change. */
90
+ page: Ref<number>;
91
+ pageSize: Ref<number>;
92
+ /**
93
+ * True while a load is in flight. Named as in `useAsyncData` and
94
+ * `useAsyncAction`, whose flag this is — `useApiList` calls its own
95
+ * `loading`.
96
+ */
97
+ pending: Ref<boolean>;
98
+ /** The last failure, or `null`. Cleared at the start of every load. */
99
+ error: Ref<AdminError | null>;
100
+ /** Loads the current page again — after a mutation, or from a retry button. */
101
+ reload: () => Promise<void>;
102
+ /** Jumps to a page (1-based, clamped) and loads. */
103
+ goToPage: (page: number) => Promise<void>;
104
+ /** Changes the page size, returns to page 1, and loads. */
105
+ setPageSize: (size: number) => Promise<void>;
106
+ }
107
+
108
+ export interface UseResourceListOptions<TOps extends ResourceOps, N extends ListOpNames<TOps>> {
109
+ /** Which operation to page through. Defaults to `list`. */
110
+ op?: N;
111
+ /**
112
+ * Reactive filter. A change returns to page 1 and reloads — a filter that
113
+ * narrows the list while the operator is on page 4 would otherwise ask for
114
+ * a page that no longer exists and render nothing.
115
+ *
116
+ * `page` and `pageSize` are not filter keys; see the guard below.
117
+ */
118
+ filter?: Ref<Record<string, unknown>>;
119
+ /**
120
+ * Rows per page to start with. Defaults to `LIST_PAGE_SIZE_DEFAULT`.
121
+ *
122
+ * Here rather than through `setPageSize` after construction, which is what
123
+ * a page with a `pageSize` prop had to do: `setPageSize` loads, and the
124
+ * first load was already queued, so the list fetched the same rows twice on
125
+ * every mount.
126
+ */
127
+ pageSize?: number;
128
+ /** Load once on creation. Default `true`. */
129
+ immediate?: boolean;
130
+ }
131
+
132
+ /**
133
+ * The failure for a filter that carries the pagination's own parameters.
134
+ *
135
+ * An `AdminError` with a diagnostic `message` and no `detail`, because the two
136
+ * are not the same thing: `message` is for the log, and leaving `detail` unset
137
+ * is what lets `adminErrorMessage` answer an operator with its translated
138
+ * wording instead of an English sentence about a filter key.
139
+ */
140
+ function paginationCollision(key: string, op: string, claimed: string[]): AdminError {
141
+ return new AdminError({
142
+ message:
143
+ `useResourceList("${key}", { op: "${op}" }): the filter carries ` +
144
+ `${claimed.join(' and ')}, which the list already sends. The filter is serialised ` +
145
+ `after the pagination, so its value wins on the wire while page.value keeps the ` +
146
+ `one goToPage() set — the list would report a page it is not showing. Drive the ` +
147
+ `page with goToPage()/setPageSize() and leave those keys out of the filter.`,
148
+ });
149
+ }
150
+
151
+ /** The pagination keys a filter is currently sending, if any. */
152
+ function paginationKeysIn(filter: Record<string, unknown>): string[] {
153
+ return LIST_PAGINATION_PARAMS.filter((param) => isSentInQuery(filter[param]));
154
+ }
155
+
156
+ /**
157
+ * The named operation, or a failure that says what the resource does offer.
158
+ *
159
+ * `Object.hasOwn` rather than an indexed read: indexing walks the prototype
160
+ * chain, so `op: 'toString'` would find `Object.prototype`'s and call it. The
161
+ * registry documents the same trap for overrides, where a typo failed loudly
162
+ * while a prototype name passed silently.
163
+ */
164
+ function readListOp<TRow>(
165
+ key: string,
166
+ ops: object,
167
+ name: string,
168
+ ): (query: ListQuery) => Promise<ResourceListPage<TRow>> {
169
+ const op = Object.hasOwn(ops, name) ? (ops as Record<string, unknown>)[name] : undefined;
170
+ if (typeof op !== 'function') {
171
+ throw new Error(
172
+ `useResourceList("${key}", { op: "${name}" }): that resource has no such ` +
173
+ `operation. It offers: ${Object.keys(ops).join(', ')}.`,
174
+ );
175
+ }
176
+ return op as (query: ListQuery) => Promise<ResourceListPage<TRow>>;
177
+ }
178
+
179
+ export function useResourceList<
180
+ K extends ListResourceKey<TMap> & keyof TMap & string,
181
+ TMap extends ResourceMap = PlatformResources,
182
+ N extends ListOpNames<TMap[K]['ops']> = ListOpNames<TMap[K]['ops']>,
183
+ >(key: K, opts?: UseResourceListOptions<TMap[K]['ops'], N>): AsyncList<RowOf<TMap[K]['ops'], N>> {
184
+ type Row = RowOf<TMap[K]['ops'], N>;
185
+
186
+ const ops = useResource<K, TMap>(key);
187
+ const opName = (opts?.op ?? DEFAULT_LIST_OP) as string;
188
+ const load = readListOp<Row>(key, ops as object, opName);
189
+
190
+ // Once, synchronously, so the ordinary case — a filter type that still
191
+ // declares `page` — fails where it is written rather than on the first
192
+ // load. The per-load check below covers a filter that gains the key later.
193
+ if (opts?.filter) {
194
+ const claimed = paginationKeysIn(opts.filter.value);
195
+ if (claimed.length > 0) throw paginationCollision(key, opName, claimed);
196
+ }
197
+
198
+ const page = ref(LIST_FIRST_PAGE);
199
+ const pageSize = ref(clampListPageSize(opts?.pageSize ?? LIST_PAGE_SIZE_DEFAULT));
200
+
201
+ function query(): ListQuery {
202
+ const filter = opts?.filter?.value;
203
+ if (filter) {
204
+ const claimed = paginationKeysIn(filter);
205
+ if (claimed.length > 0) throw paginationCollision(key, opName, claimed);
206
+ }
207
+ return { page: page.value, pageSize: pageSize.value, filter };
208
+ }
209
+
210
+ const state = useAsyncData<ResourceListPage<Row>>(() => load(query()), {
211
+ initial: EMPTY_PAGE,
212
+ immediate: opts?.immediate,
213
+ });
214
+
215
+ // The server decides which page it served, and says so. Asking for page 99
216
+ // of a list that has two returns page 2, and the next request has to ask
217
+ // for the page being shown — otherwise `reload` asks for 99 again while the
218
+ // paginator reads "99 of 1" over the rows of page 2. `useApiList` adopts
219
+ // the echo for the same reason; the descriptor carries it and this was the
220
+ // only reader that dropped it.
221
+ //
222
+ // Adopted from `state.data` rather than from inside the loader, because
223
+ // only the load that won writes there. Doing it in the loader put the
224
+ // assignment ahead of the generation check: two overlapping navigations
225
+ // then discarded the stale rows but kept the stale echo, leaving page 2's
226
+ // rows under a paginator reading 3 and a reload aimed at 3.
227
+ //
228
+ // An answer that says nothing about the page leaves the requested one
229
+ // standing. Silence is not a statement about what was served, and moving
230
+ // the paginator from it would move it on its own.
231
+ watch(state.data, (answer) => {
232
+ if (typeof answer.page === 'number') page.value = answer.page;
233
+ if (typeof answer.pageSize === 'number') pageSize.value = answer.pageSize;
234
+ });
235
+
236
+ async function goToPage(next: number): Promise<void> {
237
+ page.value = clampListPage(next);
238
+ await state.reload();
239
+ }
240
+
241
+ async function setPageSize(size: number): Promise<void> {
242
+ pageSize.value = clampListPageSize(size);
243
+ page.value = LIST_FIRST_PAGE;
244
+ await state.reload();
245
+ }
246
+
247
+ if (opts?.filter) {
248
+ // Deep, and the page reset happens before the reload. `useAsyncData`'s
249
+ // own `watch` option is neither — it is a plain watch that calls
250
+ // `reload` — so the watcher lives here rather than being passed down.
251
+ watch(
252
+ opts.filter,
253
+ () => {
254
+ page.value = LIST_FIRST_PAGE;
255
+ void state.reload();
256
+ },
257
+ { deep: true },
258
+ );
259
+ }
260
+
261
+ return {
262
+ items: computed(() => state.data.value.items),
263
+ total: computed(() => state.data.value.total ?? state.data.value.items.length),
264
+ page,
265
+ pageSize,
266
+ pending: state.pending,
267
+ error: state.error,
268
+ reload: state.reload,
269
+ goToPage,
270
+ setPageSize,
271
+ };
272
+ }
@@ -18,6 +18,7 @@
18
18
  import {
19
19
  computed,
20
20
  inject,
21
+ isReadonly,
21
22
  isRef,
22
23
  ref,
23
24
  watch,
@@ -31,14 +32,23 @@ import { defaultKvStore, type KvStore } from '../client/types.js';
31
32
  export type SaColorScheme = 'light' | 'dark' | 'system';
32
33
  export type SaResolvedScheme = 'light' | 'dark';
33
34
 
34
- const SCHEMES: readonly SaColorScheme[] = ['light', 'dark', 'system'];
35
- const STORAGE_KEY = 'saasicat.theme.scheme';
35
+ /** The schemes the switcher offers, in the order it lists them. */
36
+ export const SA_COLOR_SCHEMES: readonly SaColorScheme[] = ['light', 'dark', 'system'];
37
+
38
+ /** `KvStore` key holding the scheme the operator picked in the switcher. */
39
+ export const SA_THEME_STORAGE_KEY = 'saasicat.theme.scheme';
36
40
 
37
41
  export interface SaTheme {
38
42
  /** What the operator picked. Writable — assigning switches the theme. */
39
43
  scheme: Ref<SaColorScheme>;
40
44
  /** What that means right now: `'system'` resolved against the OS. */
41
45
  resolved: ComputedRef<SaResolvedScheme>;
46
+ /**
47
+ * Whether the shell renders its theme switcher. `false` when the app opted
48
+ * out or when `scheme` cannot be written to — a switcher that cannot change
49
+ * anything is worse than none.
50
+ */
51
+ switcherEnabled: boolean;
42
52
  /**
43
53
  * Releases the `prefers-color-scheme` subscription and the persistence
44
54
  * watcher. Idempotent.
@@ -65,10 +75,22 @@ export interface SaThemeOptions {
65
75
  persist?: boolean;
66
76
  /** Injection seam for tests. */
67
77
  storage?: KvStore;
78
+ /**
79
+ * Prefix for the persisted key, mirroring `createSuperAdminI18n`. Set it
80
+ * when several admin apps share one origin and must not inherit each
81
+ * other's colour scheme.
82
+ */
83
+ storageKeyPrefix?: string;
84
+ /**
85
+ * Renders the theme switcher in the shell chrome, default `true`. Set
86
+ * `false` for a deployment that ships one appearance. A readonly `scheme`
87
+ * ref disables it on its own — no need to set this as well.
88
+ */
89
+ switcher?: boolean;
68
90
  }
69
91
 
70
92
  function isScheme(value: unknown): value is SaColorScheme {
71
- return typeof value === 'string' && SCHEMES.includes(value as SaColorScheme);
93
+ return typeof value === 'string' && SA_COLOR_SCHEMES.includes(value as SaColorScheme);
72
94
  }
73
95
 
74
96
  /**
@@ -113,18 +135,25 @@ export function createSaTheme(options: SaThemeOptions = {}): SaTheme {
113
135
  const provided = options.scheme;
114
136
  const appOwnsIt = isRef(provided);
115
137
 
116
- const stored = persist && !appOwnsIt ? storage.get(STORAGE_KEY) : null;
138
+ const key = `${options.storageKeyPrefix ?? ''}${SA_THEME_STORAGE_KEY}`;
139
+
140
+ const stored = persist && !appOwnsIt ? storage.get(key) : null;
117
141
  const scheme = isRef(provided)
118
142
  ? provided
119
143
  : ref<SaColorScheme>(isScheme(stored) ? stored : (provided ?? 'system'));
120
144
  const { value: system, unsubscribe } = systemScheme();
121
145
 
122
146
  const stopPersisting =
123
- persist && !appOwnsIt ? watch(scheme, (next) => storage.set(STORAGE_KEY, next)) : null;
147
+ persist && !appOwnsIt ? watch(scheme, (next) => storage.set(key, next)) : null;
124
148
 
125
149
  return {
126
150
  scheme,
127
151
  resolved: computed(() => (scheme.value === 'system' ? system.value : scheme.value)),
152
+ // A readonly ref (typically a `computed` derived from a profile
153
+ // setting) silently swallows writes, so the switcher would be a dead
154
+ // control. TypeScript ignores `readonly` when checking assignability,
155
+ // so the guard has to be a runtime one — same as the locale next door.
156
+ switcherEnabled: (options.switcher ?? true) && !isReadonly(scheme),
128
157
  dispose: () => {
129
158
  unsubscribe();
130
159
  stopPersisting?.();
@@ -4,7 +4,9 @@
4
4
  // `SubscriptionBundleModule.forRoot({ controller: {...} })`).
5
5
 
6
6
  import { ref, type Ref } from 'vue';
7
+ import { markEmptyResponse, markPlatformError } from '../client/admin-error.js';
7
8
  import type { SubscriptionBundleRecord } from '@saasicat/types';
9
+ import { requireServerAnswer } from '../client/http-json.js';
8
10
  import { defaultHttpClient, type HttpClient } from '../client/types.js';
9
11
 
10
12
  export interface UseTenantSubscriptionBundlesOptions {
@@ -40,6 +42,9 @@ export class TenantSubscriptionBundlesApiError extends Error {
40
42
  ) {
41
43
  super(message);
42
44
  this.name = 'TenantSubscriptionBundlesApiError';
45
+ // Identity, so `toAdminError` can tell this diagnostic from a
46
+ // consumer error whose message an operator needs to read.
47
+ markPlatformError(this);
43
48
  }
44
49
  }
45
50
 
@@ -64,11 +69,21 @@ export function useTenantSubscriptionBundles(
64
69
  }
65
70
 
66
71
  async function fetchJson<T>(url: string, init?: Parameters<HttpClient>[1]): Promise<T | null> {
72
+ const method = init?.method ?? 'GET';
67
73
  const res = await http(url, {
68
- method: init?.method ?? 'GET',
74
+ method,
69
75
  headers: { 'content-type': 'application/json', ...authHeaders(), ...init?.headers },
70
76
  body: init?.body,
71
77
  });
78
+ // Before any body is read: `null` below has to mean "the server
79
+ // answered without one", which is what the callers' empty-response
80
+ // sentinels claim.
81
+ requireServerAnswer(
82
+ res.status,
83
+ method,
84
+ url,
85
+ (diagnostic) => new TenantSubscriptionBundlesApiError(res.status, null, diagnostic),
86
+ );
72
87
  if (res.status === 204) return null;
73
88
  const body = await res.json().catch(() => null);
74
89
  if (res.status >= 400) {
@@ -103,7 +118,9 @@ export function useTenantSubscriptionBundles(
103
118
  body: JSON.stringify(data),
104
119
  });
105
120
  if (!result) {
106
- throw new TenantSubscriptionBundlesApiError(0, null, 'add returned no body');
121
+ throw markEmptyResponse(
122
+ new TenantSubscriptionBundlesApiError(0, null, 'add returned no body'),
123
+ );
107
124
  }
108
125
  const hydrated = rehydrateDates(result);
109
126
  bundles.value = [hydrated, ...bundles.value];
@@ -119,7 +136,9 @@ export function useTenantSubscriptionBundles(
119
136
  { method: 'DELETE', body: JSON.stringify(opts) },
120
137
  );
121
138
  if (!result) {
122
- throw new TenantSubscriptionBundlesApiError(0, null, 'cancel returned no body');
139
+ throw markEmptyResponse(
140
+ new TenantSubscriptionBundlesApiError(0, null, 'cancel returned no body'),
141
+ );
123
142
  }
124
143
  const hydrated = rehydrateDates(result);
125
144
  bundles.value = bundles.value.map((b) => (b.id === subscriptionBundleId ? hydrated : b));