@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
@@ -0,0 +1,83 @@
1
+ // One async read, with the state a page otherwise writes by hand.
2
+ //
3
+ // The counterpart to `useAsyncAction`: that one is for something the operator
4
+ // set off, this one is for something the page needs in order to render. The
5
+ // difference shows in what happens on failure — an action reports and stops,
6
+ // a read has to leave the page in a state it can still draw.
7
+ //
8
+ // On failure `data` goes back to `initial`. That is what `useApiList` already
9
+ // does (it resets `items` to `[]` and `total` to 0), and it is the safer of the
10
+ // two options: stale rows under an error message read as current data, and
11
+ // nothing on the page says which reload they came from.
12
+
13
+ import { ref, watch, type Ref, type WatchSource } from 'vue';
14
+
15
+ import { toAdminError, type AdminError } from '../client/admin-error.js';
16
+
17
+ export interface AsyncData<T> {
18
+ /** The loaded value, or `initial` before the first load and after a failure. */
19
+ data: Ref<T>;
20
+ /** True while a load is in flight. */
21
+ pending: Ref<boolean>;
22
+ /** The last failure, or `null`. Cleared at the start of every load. */
23
+ error: Ref<AdminError | null>;
24
+ /** Loads again — after a mutation, or from a retry button. */
25
+ reload: () => Promise<void>;
26
+ }
27
+
28
+ export interface UseAsyncDataOptions<T> {
29
+ /** Value before the first load, and the value restored when one fails. */
30
+ initial: T;
31
+ /** Load once on creation. Default `true`. */
32
+ immediate?: boolean;
33
+ /** Reload whenever any of these changes — a filter, a selected row, a locale. */
34
+ watch?: WatchSource[];
35
+ }
36
+
37
+ export function useAsyncData<T>(
38
+ fn: () => Promise<T>,
39
+ options: UseAsyncDataOptions<T>,
40
+ ): AsyncData<T> {
41
+ const data = ref(options.initial) as Ref<T>;
42
+ const pending = ref(false);
43
+ const error = ref<AdminError | null>(null);
44
+
45
+ // Which load is the current one. A watched source that changes twice in
46
+ // quick succession starts a second request before the first answers, and
47
+ // without this the loser writes last: the older result overwrites the
48
+ // newer, its `finally` clears `pending` while the newer request is still
49
+ // running, and — worst — its `catch` resets `data` to `initial` and raises
50
+ // an error for a filter the operator has already left.
51
+ let generation = 0;
52
+
53
+ async function reload(): Promise<void> {
54
+ const mine = ++generation;
55
+ pending.value = true;
56
+ error.value = null;
57
+ try {
58
+ const result = await fn();
59
+ if (mine !== generation) return;
60
+ data.value = result;
61
+ } catch (err: unknown) {
62
+ if (mine !== generation) return;
63
+ error.value = toAdminError(err);
64
+ data.value = options.initial;
65
+ } finally {
66
+ // Only the newest load owns the flag; a superseded one leaving it
67
+ // false would announce a page that is still loading as settled.
68
+ if (mine === generation) pending.value = false;
69
+ }
70
+ }
71
+
72
+ if (options.watch?.length) {
73
+ watch(options.watch, () => void reload());
74
+ }
75
+
76
+ if (options.immediate !== false) {
77
+ // One microtask later, so the composable does not block `setup()`
78
+ // during the initial synchronous phase — same reasoning as useApiList.
79
+ void Promise.resolve().then(() => reload());
80
+ }
81
+
82
+ return { data, pending, error, reload };
83
+ }
@@ -7,6 +7,7 @@
7
7
  // the catalog paths themselves — the consumer supplies only the admin prefix.
8
8
 
9
9
  import { ref, type Ref } from 'vue';
10
+ import { markEmptyResponse, markPlatformError } from '../client/admin-error.js';
10
11
  import type {
11
12
  BundleRow,
12
13
  BundleVersionMutationResult,
@@ -16,6 +17,7 @@ import type {
16
17
  UpdateBundleData,
17
18
  UpdateBundleVersionDraftData,
18
19
  } from '@saasicat/types';
20
+ import { requireServerAnswer } from '../client/http-json.js';
19
21
  import { defaultHttpClient, type HttpClient } from '../client/types.js';
20
22
 
21
23
  export interface UseBundlesOptions {
@@ -41,6 +43,9 @@ export class BundlesApiError extends Error {
41
43
  ) {
42
44
  super(message);
43
45
  this.name = 'BundlesApiError';
46
+ // Identity, so `toAdminError` can tell this diagnostic from a
47
+ // consumer error whose message an operator needs to read.
48
+ markPlatformError(this);
44
49
  }
45
50
  }
46
51
 
@@ -80,11 +85,21 @@ export function useBundles(options: UseBundlesOptions): UseBundlesResult {
80
85
  }
81
86
 
82
87
  async function fetchJson<T>(url: string, init?: Parameters<HttpClient>[1]): Promise<T | null> {
88
+ const method = init?.method ?? 'GET';
83
89
  const res = await http(url, {
84
- method: init?.method ?? 'GET',
90
+ method,
85
91
  headers: { 'content-type': 'application/json', ...authHeaders(), ...init?.headers },
86
92
  body: init?.body,
87
93
  });
94
+ // Before any body is read: `null` below has to mean "the server
95
+ // answered without one", which is what the callers' empty-response
96
+ // sentinels claim.
97
+ requireServerAnswer(
98
+ res.status,
99
+ method,
100
+ url,
101
+ (diagnostic) => new BundlesApiError(res.status, null, diagnostic),
102
+ );
88
103
  if (res.status === 204) return null;
89
104
  const body = await res.json().catch(() => null);
90
105
  if (res.status >= 400) {
@@ -117,7 +132,8 @@ export function useBundles(options: UseBundlesOptions): UseBundlesResult {
117
132
  method: 'POST',
118
133
  body: JSON.stringify(data),
119
134
  });
120
- if (!created) throw new BundlesApiError(0, null, 'Create returned no body');
135
+ if (!created)
136
+ throw markEmptyResponse(new BundlesApiError(0, null, 'Create returned no body'));
121
137
  bundles.value = [...bundles.value, created];
122
138
  return created;
123
139
  }
@@ -127,7 +143,8 @@ export function useBundles(options: UseBundlesOptions): UseBundlesResult {
127
143
  method: 'PATCH',
128
144
  body: JSON.stringify(data),
129
145
  });
130
- if (!updated) throw new BundlesApiError(0, null, 'Update returned no body');
146
+ if (!updated)
147
+ throw markEmptyResponse(new BundlesApiError(0, null, 'Update returned no body'));
131
148
  bundles.value = bundles.value.map((b) => (b.id === bundleId ? updated : b));
132
149
  return updated;
133
150
  }
@@ -209,11 +226,18 @@ export function useBundleVersions(options: UseBundleVersionsOptions): UseBundleV
209
226
  }
210
227
 
211
228
  async function fetchJson<T>(url: string, init?: Parameters<HttpClient>[1]): Promise<T | null> {
229
+ const method = init?.method ?? 'GET';
212
230
  const res = await http(url, {
213
- method: init?.method ?? 'GET',
231
+ method,
214
232
  headers: { 'content-type': 'application/json', ...authHeaders(), ...init?.headers },
215
233
  body: init?.body,
216
234
  });
235
+ requireServerAnswer(
236
+ res.status,
237
+ method,
238
+ url,
239
+ (diagnostic) => new BundlesApiError(res.status, null, diagnostic),
240
+ );
217
241
  if (res.status === 204) return null;
218
242
  const body = await res.json().catch(() => null);
219
243
  if (res.status >= 400) {
@@ -246,7 +270,8 @@ export function useBundleVersions(options: UseBundleVersionsOptions): UseBundleV
246
270
  method: 'POST',
247
271
  body: JSON.stringify(data),
248
272
  });
249
- if (!result) throw new BundlesApiError(0, null, 'CreateDraft returned no body');
273
+ if (!result)
274
+ throw markEmptyResponse(new BundlesApiError(0, null, 'CreateDraft returned no body'));
250
275
  versions.value = [...versions.value, result.bundleVersion];
251
276
  return result;
252
277
  }
@@ -259,7 +284,8 @@ export function useBundleVersions(options: UseBundleVersionsOptions): UseBundleV
259
284
  `${versionUrlBase}/${versionId}`,
260
285
  { method: 'PATCH', body: JSON.stringify(data) },
261
286
  );
262
- if (!result) throw new BundlesApiError(0, null, 'UpdateDraft returned no body');
287
+ if (!result)
288
+ throw markEmptyResponse(new BundlesApiError(0, null, 'UpdateDraft returned no body'));
263
289
  versions.value = versions.value.map((v) => (v.id === versionId ? result.bundleVersion : v));
264
290
  return result;
265
291
  }
@@ -277,7 +303,8 @@ export function useBundleVersions(options: UseBundleVersionsOptions): UseBundleV
277
303
  `${versionUrlBase}/${versionId}/publish`,
278
304
  { method: 'POST', body: JSON.stringify(opts) },
279
305
  );
280
- if (!result) throw new BundlesApiError(0, null, 'Publish returned no body');
306
+ if (!result)
307
+ throw markEmptyResponse(new BundlesApiError(0, null, 'Publish returned no body'));
281
308
  // Reload versions, because publishing may mark another version as
282
309
  // superseded — the local cache would otherwise be inconsistent.
283
310
  await load();
@@ -9,6 +9,7 @@
9
9
  // (e.g. `/api/admin` or `/api/v1/admin`).
10
10
 
11
11
  import { ref, type Ref } from 'vue';
12
+ import { markEmptyResponse, markPlatformError } from '../client/admin-error.js';
12
13
  import type {
13
14
  CapabilityCatalogEntryRow,
14
15
  CatalogEntryI18n,
@@ -19,9 +20,8 @@ import type {
19
20
  SyncDiscoveryResult,
20
21
  UpdateCatalogEntryBaseData,
21
22
  } from '@saasicat/types';
22
- import { formatMessage } from '../client/i18n/format.js';
23
+ import { requireServerAnswer } from '../client/http-json.js';
23
24
  import { defaultHttpClient, type HttpClient } from '../client/types.js';
24
- import { useSaMessages } from './use-super-admin-i18n.js';
25
25
 
26
26
  export interface UseCatalogEntriesOptions {
27
27
  /** Admin endpoint prefix incl. globalPrefix (`/api/admin`, `/api/v1/admin`). */
@@ -33,6 +33,12 @@ export interface UseCatalogEntriesOptions {
33
33
  autoLoad?: boolean;
34
34
  }
35
35
 
36
+ /**
37
+ * A catalog-entries call failed. Its `message` is a diagnostic for the log, in
38
+ * English like every other developer-facing string in the repository — the
39
+ * sentence a user is shown comes from the `errors` catalog through
40
+ * `adminErrorMessage`, in whichever language the shell speaks.
41
+ */
36
42
  export class CatalogEntriesApiError extends Error {
37
43
  constructor(
38
44
  public readonly status: number,
@@ -41,6 +47,9 @@ export class CatalogEntriesApiError extends Error {
41
47
  ) {
42
48
  super(message);
43
49
  this.name = 'CatalogEntriesApiError';
50
+ // Identity, so `toAdminError` can tell this diagnostic from a
51
+ // consumer error whose message an operator needs to read.
52
+ markPlatformError(this);
44
53
  }
45
54
  }
46
55
 
@@ -83,7 +92,6 @@ export function useCatalogEntries(options: UseCatalogEntriesOptions): UseCatalog
83
92
  throw new Error('useCatalogEntries: `projectKey` is required.');
84
93
  }
85
94
 
86
- const msg = useSaMessages('discovery');
87
95
  const http = options.http ?? defaultHttpClient();
88
96
  const capabilities = ref<CapabilityCatalogEntryRow[]>([]);
89
97
  const features = ref<FeatureCatalogEntryRow[]>([]);
@@ -100,18 +108,28 @@ export function useCatalogEntries(options: UseCatalogEntriesOptions): UseCatalog
100
108
  }
101
109
 
102
110
  async function fetchJson<T>(url: string, init?: Parameters<HttpClient>[1]): Promise<T | null> {
111
+ const method = init?.method ?? 'GET';
103
112
  const res = await http(url, {
104
- method: init?.method ?? 'GET',
113
+ method,
105
114
  headers: { 'content-type': 'application/json', ...authHeaders(), ...init?.headers },
106
115
  body: init?.body,
107
116
  });
117
+ // Before any body is read: `null` below has to mean "the server
118
+ // answered without one", which is what the callers' empty-response
119
+ // sentinels claim.
120
+ requireServerAnswer(
121
+ res.status,
122
+ method,
123
+ url,
124
+ (diagnostic) => new CatalogEntriesApiError(res.status, null, diagnostic),
125
+ );
108
126
  if (res.status === 204) return null;
109
127
  const body = await res.json().catch(() => null);
110
128
  if (res.status >= 400) {
111
129
  throw new CatalogEntriesApiError(
112
130
  res.status,
113
131
  body,
114
- formatMessage(msg.value.errorCatalogHttp, { status: res.status }),
132
+ `Catalog entries API responded with HTTP ${res.status}`,
115
133
  );
116
134
  }
117
135
  return body as T;
@@ -144,7 +162,8 @@ export function useCatalogEntries(options: UseCatalogEntriesOptions): UseCatalog
144
162
  `${base}/features/${encodeURIComponent(featureKey)}/review?projectKey=${pk}`,
145
163
  { method: 'PATCH', body: JSON.stringify(data) },
146
164
  );
147
- if (!updated) throw new CatalogEntriesApiError(0, null, 'Review returned no body');
165
+ if (!updated)
166
+ throw markEmptyResponse(new CatalogEntriesApiError(0, null, 'Review returned no body'));
148
167
  features.value = features.value.map((f) => (f.featureKey === featureKey ? updated : f));
149
168
  return updated;
150
169
  }
@@ -157,7 +176,8 @@ export function useCatalogEntries(options: UseCatalogEntriesOptions): UseCatalog
157
176
  `${base}/quotas/${encodeURIComponent(quotaKey)}/review?projectKey=${pk}`,
158
177
  { method: 'PATCH', body: JSON.stringify(data) },
159
178
  );
160
- if (!updated) throw new CatalogEntriesApiError(0, null, 'Review returned no body');
179
+ if (!updated)
180
+ throw markEmptyResponse(new CatalogEntriesApiError(0, null, 'Review returned no body'));
161
181
  quotas.value = quotas.value.map((q) => (q.quotaKey === quotaKey ? updated : q));
162
182
  return updated;
163
183
  }
@@ -170,7 +190,8 @@ export function useCatalogEntries(options: UseCatalogEntriesOptions): UseCatalog
170
190
  `${base}/features/${encodeURIComponent(featureKey)}/i18n?projectKey=${pk}`,
171
191
  { method: 'PATCH', body: JSON.stringify({ i18n }) },
172
192
  );
173
- if (!updated) throw new CatalogEntriesApiError(0, null, 'i18n returned no body');
193
+ if (!updated)
194
+ throw markEmptyResponse(new CatalogEntriesApiError(0, null, 'i18n returned no body'));
174
195
  features.value = features.value.map((f) => (f.featureKey === featureKey ? updated : f));
175
196
  return updated;
176
197
  }
@@ -183,7 +204,8 @@ export function useCatalogEntries(options: UseCatalogEntriesOptions): UseCatalog
183
204
  `${base}/quotas/${encodeURIComponent(quotaKey)}/i18n?projectKey=${pk}`,
184
205
  { method: 'PATCH', body: JSON.stringify({ i18n }) },
185
206
  );
186
- if (!updated) throw new CatalogEntriesApiError(0, null, 'i18n returned no body');
207
+ if (!updated)
208
+ throw markEmptyResponse(new CatalogEntriesApiError(0, null, 'i18n returned no body'));
187
209
  quotas.value = quotas.value.map((q) => (q.quotaKey === quotaKey ? updated : q));
188
210
  return updated;
189
211
  }
@@ -196,7 +218,8 @@ export function useCatalogEntries(options: UseCatalogEntriesOptions): UseCatalog
196
218
  `${base}/features/${encodeURIComponent(featureKey)}?projectKey=${pk}`,
197
219
  { method: 'PATCH', body: JSON.stringify(data) },
198
220
  );
199
- if (!updated) throw new CatalogEntriesApiError(0, null, 'Base returned no body');
221
+ if (!updated)
222
+ throw markEmptyResponse(new CatalogEntriesApiError(0, null, 'Base returned no body'));
200
223
  features.value = features.value.map((f) => (f.featureKey === featureKey ? updated : f));
201
224
  return updated;
202
225
  }
@@ -209,7 +232,8 @@ export function useCatalogEntries(options: UseCatalogEntriesOptions): UseCatalog
209
232
  `${base}/quotas/${encodeURIComponent(quotaKey)}?projectKey=${pk}`,
210
233
  { method: 'PATCH', body: JSON.stringify(data) },
211
234
  );
212
- if (!updated) throw new CatalogEntriesApiError(0, null, 'Base returned no body');
235
+ if (!updated)
236
+ throw markEmptyResponse(new CatalogEntriesApiError(0, null, 'Base returned no body'));
213
237
  quotas.value = quotas.value.map((q) => (q.quotaKey === quotaKey ? updated : q));
214
238
  return updated;
215
239
  }
@@ -219,7 +243,8 @@ export function useCatalogEntries(options: UseCatalogEntriesOptions): UseCatalog
219
243
  method: 'POST',
220
244
  body: JSON.stringify({ snapshot }),
221
245
  });
222
- if (!result) throw new CatalogEntriesApiError(0, null, 'Sync returned no body');
246
+ if (!result)
247
+ throw markEmptyResponse(new CatalogEntriesApiError(0, null, 'Sync returned no body'));
223
248
  await load();
224
249
  return result;
225
250
  }
@@ -11,10 +11,10 @@
11
11
  // (e.g. `/api/admin/discovery` or `/api/v1/admin/discovery`).
12
12
 
13
13
  import { ref, type Ref } from 'vue';
14
+ import { markPlatformError } from '../client/admin-error.js';
14
15
  import type { DiscoverySnapshot } from '@saasicat/types';
16
+ import { requireServerAnswer } from '../client/http-json.js';
15
17
  import { defaultHttpClient, type HttpClient } from '../client/types.js';
16
- import { formatMessage } from '../client/i18n/format.js';
17
- import { useSaMessages } from './use-super-admin-i18n.js';
18
18
 
19
19
  export interface UseDiscoveryOptions {
20
20
  /**
@@ -36,6 +36,12 @@ export interface UseDiscoveryOptions {
36
36
  autoLoad?: boolean;
37
37
  }
38
38
 
39
+ /**
40
+ * The discovery endpoint could not be read. Its `message` is a diagnostic for
41
+ * the log, in English like every other developer-facing string in the
42
+ * repository — the sentence a user is shown comes from the `errors` catalog
43
+ * through `adminErrorMessage`, in whichever language the shell speaks.
44
+ */
39
45
  export class DiscoveryLoadError extends Error {
40
46
  constructor(
41
47
  public readonly status: number,
@@ -43,6 +49,9 @@ export class DiscoveryLoadError extends Error {
43
49
  ) {
44
50
  super(message);
45
51
  this.name = 'DiscoveryLoadError';
52
+ // Identity, so `toAdminError` can tell this diagnostic from a
53
+ // consumer error whose message an operator needs to read.
54
+ markPlatformError(this);
46
55
  }
47
56
  }
48
57
 
@@ -70,7 +79,6 @@ export interface UseDiscoveryResult {
70
79
  }
71
80
 
72
81
  export function useDiscovery(options: UseDiscoveryOptions): UseDiscoveryResult {
73
- const msg = useSaMessages('discovery');
74
82
  if (!options?.endpoint) {
75
83
  throw new Error(
76
84
  'useDiscovery: `endpoint` is required (e.g. "/api/admin/discovery" ' +
@@ -97,6 +105,12 @@ export function useDiscovery(options: UseDiscoveryOptions): UseDiscoveryResult {
97
105
  }
98
106
 
99
107
  const res = await http(options.endpoint, { method: 'GET', headers });
108
+ requireServerAnswer(
109
+ res.status,
110
+ 'GET',
111
+ options.endpoint,
112
+ (diagnostic) => new DiscoveryLoadError(res.status, diagnostic),
113
+ );
100
114
 
101
115
  if (res.status === 304) {
102
116
  // Cache hit: snapshot stays unchanged, no re-parse.
@@ -105,7 +119,7 @@ export function useDiscovery(options: UseDiscoveryOptions): UseDiscoveryResult {
105
119
  if (res.status !== 200) {
106
120
  throw new DiscoveryLoadError(
107
121
  res.status,
108
- formatMessage(msg.value.errorDiscoveryHttp, { status: res.status }),
122
+ `Discovery endpoint responded with HTTP ${res.status}`,
109
123
  );
110
124
  }
111
125
 
@@ -136,11 +150,18 @@ export function useDiscovery(options: UseDiscoveryOptions): UseDiscoveryResult {
136
150
  const token = options.getAuthToken?.();
137
151
  if (token) headers.Authorization = `Bearer ${token}`;
138
152
 
139
- const res = await http(`${options.endpoint}/rescan`, { method: 'POST', headers });
153
+ const rescanUrl = `${options.endpoint}/rescan`;
154
+ const res = await http(rescanUrl, { method: 'POST', headers });
155
+ requireServerAnswer(
156
+ res.status,
157
+ 'POST',
158
+ rescanUrl,
159
+ (diagnostic) => new DiscoveryLoadError(res.status, diagnostic),
160
+ );
140
161
  if (res.status !== 200 && res.status !== 201) {
141
162
  throw new DiscoveryLoadError(
142
163
  res.status,
143
- formatMessage(msg.value.errorRescanHttp, { status: res.status }),
164
+ `Discovery rescan responded with HTTP ${res.status}`,
144
165
  );
145
166
  }
146
167
  const body = (await res.json()) as DiscoverySnapshot;
@@ -7,15 +7,15 @@
7
7
  // low-frequency and consistency matters more than performance.
8
8
 
9
9
  import { ref, type Ref } from 'vue';
10
+ import { markEmptyResponse, markPlatformError } from '../client/admin-error.js';
10
11
  import type {
11
12
  CreateMarketingProjectionData,
12
13
  MarketingProjectionFilter,
13
14
  MarketingProjectionRow,
14
15
  UpdateMarketingProjectionData,
15
16
  } from '@saasicat/types';
17
+ import { requireServerAnswer } from '../client/http-json.js';
16
18
  import { defaultHttpClient, type HttpClient } from '../client/types.js';
17
- import { formatMessage } from '../client/i18n/format.js';
18
- import { useSaMessages } from './use-super-admin-i18n.js';
19
19
 
20
20
  export interface UseMarketingProjectionsOptions {
21
21
  adminEndpoint: string;
@@ -26,6 +26,12 @@ export interface UseMarketingProjectionsOptions {
26
26
  autoLoad?: boolean;
27
27
  }
28
28
 
29
+ /**
30
+ * A marketing-projections call failed. Its `message` is a diagnostic for the
31
+ * log, in English like every other developer-facing string in the repository —
32
+ * the sentence a user is shown comes from the `errors` catalog through
33
+ * `adminErrorMessage`, in whichever language the shell speaks.
34
+ */
29
35
  export class MarketingProjectionsApiError extends Error {
30
36
  constructor(
31
37
  public readonly status: number,
@@ -34,6 +40,9 @@ export class MarketingProjectionsApiError extends Error {
34
40
  ) {
35
41
  super(message);
36
42
  this.name = 'MarketingProjectionsApiError';
43
+ // Identity, so `toAdminError` can tell this diagnostic from a
44
+ // consumer error whose message an operator needs to read.
45
+ markPlatformError(this);
37
46
  }
38
47
  }
39
48
 
@@ -61,7 +70,6 @@ export function useMarketingProjections(
61
70
  throw new Error('useMarketingProjections: `filter.projectKey` is required.');
62
71
  }
63
72
 
64
- const msg = useSaMessages('marketing');
65
73
  const http = options.http ?? defaultHttpClient();
66
74
  const projections = ref<MarketingProjectionRow[]>([]);
67
75
  const filter = ref<MarketingProjectionFilter>({ ...options.filter });
@@ -86,18 +94,28 @@ export function useMarketingProjections(
86
94
  }
87
95
 
88
96
  async function fetchJson<T>(url: string, init?: Parameters<HttpClient>[1]): Promise<T | null> {
97
+ const method = init?.method ?? 'GET';
89
98
  const res = await http(url, {
90
- method: init?.method ?? 'GET',
99
+ method,
91
100
  headers: { 'content-type': 'application/json', ...authHeaders(), ...init?.headers },
92
101
  body: init?.body,
93
102
  });
103
+ // Before any body is read: `null` below has to mean "the server
104
+ // answered without one", which is what the callers' empty-response
105
+ // sentinels claim.
106
+ requireServerAnswer(
107
+ res.status,
108
+ method,
109
+ url,
110
+ (diagnostic) => new MarketingProjectionsApiError(res.status, null, diagnostic),
111
+ );
94
112
  if (res.status === 204) return null;
95
113
  const body = await res.json().catch(() => null);
96
114
  if (res.status >= 400) {
97
115
  throw new MarketingProjectionsApiError(
98
116
  res.status,
99
117
  body,
100
- formatMessage(msg.value.errors.projectionsApi, { status: res.status }),
118
+ `Marketing projections API responded with HTTP ${res.status}`,
101
119
  );
102
120
  }
103
121
  return body as T;
@@ -127,7 +145,9 @@ export function useMarketingProjections(
127
145
  body: JSON.stringify(data),
128
146
  });
129
147
  if (!created) {
130
- throw new MarketingProjectionsApiError(0, null, 'Create returned no body');
148
+ throw markEmptyResponse(
149
+ new MarketingProjectionsApiError(0, null, 'Create returned no body'),
150
+ );
131
151
  }
132
152
  // After create: reload the list (a unique tuple insert can
133
153
  // affect the filter).
@@ -144,7 +164,9 @@ export function useMarketingProjections(
144
164
  body: JSON.stringify(data),
145
165
  });
146
166
  if (!updated) {
147
- throw new MarketingProjectionsApiError(0, null, 'Update returned no body');
167
+ throw markEmptyResponse(
168
+ new MarketingProjectionsApiError(0, null, 'Update returned no body'),
169
+ );
148
170
  }
149
171
  projections.value = projections.value.map((p) => (p.id === id ? updated : p));
150
172
  return updated;
@@ -4,6 +4,7 @@
4
4
  // Pack 2a → usePlanVersions (draft/publish lifecycle)
5
5
 
6
6
  import { ref, type Ref } from 'vue';
7
+ import { markEmptyResponse, markPlatformError } from '../client/admin-error.js';
7
8
  import type {
8
9
  CreatePlanData,
9
10
  CreatePlanVersionDraftData,
@@ -13,6 +14,7 @@ import type {
13
14
  UpdatePlanData,
14
15
  UpdatePlanVersionDraftData,
15
16
  } from '@saasicat/types';
17
+ import { requireServerAnswer } from '../client/http-json.js';
16
18
  import { defaultHttpClient, type HttpClient } from '../client/types.js';
17
19
 
18
20
  export interface UsePlansOptions {
@@ -37,6 +39,9 @@ export class PlansApiError extends Error {
37
39
  ) {
38
40
  super(message);
39
41
  this.name = 'PlansApiError';
42
+ // Identity, so `toAdminError` can tell this diagnostic from a
43
+ // consumer error whose message an operator needs to read.
44
+ markPlatformError(this);
40
45
  }
41
46
  }
42
47
 
@@ -94,8 +99,9 @@ export function usePlans(options: UsePlansOptions): UsePlansResult {
94
99
  }
95
100
 
96
101
  async function fetchJson<T>(url: string, init?: Parameters<HttpClient>[1]): Promise<T | null> {
102
+ const method = init?.method ?? 'GET';
97
103
  const res = await http(url, {
98
- method: init?.method ?? 'GET',
104
+ method,
99
105
  headers: {
100
106
  'content-type': 'application/json',
101
107
  ...authHeaders(),
@@ -103,6 +109,15 @@ export function usePlans(options: UsePlansOptions): UsePlansResult {
103
109
  },
104
110
  body: init?.body,
105
111
  });
112
+ // Before any body is read: `null` below has to mean "the server
113
+ // answered without one", which is what the callers' empty-response
114
+ // sentinels claim.
115
+ requireServerAnswer(
116
+ res.status,
117
+ method,
118
+ url,
119
+ (diagnostic) => new PlansApiError(res.status, null, diagnostic),
120
+ );
106
121
  if (res.status === 204) return null;
107
122
  const body = await res.json().catch(() => null);
108
123
  if (res.status >= 400) {
@@ -147,7 +162,8 @@ export function usePlans(options: UsePlansOptions): UsePlansResult {
147
162
  method: 'POST',
148
163
  body: JSON.stringify(data),
149
164
  });
150
- if (!created) throw new PlansApiError(0, null, 'Create returned no body');
165
+ if (!created)
166
+ throw markEmptyResponse(new PlansApiError(0, null, 'Create returned no body'));
151
167
  plans.value = [...plans.value, created];
152
168
  return created;
153
169
  }
@@ -157,7 +173,8 @@ export function usePlans(options: UsePlansOptions): UsePlansResult {
157
173
  method: 'PATCH',
158
174
  body: JSON.stringify(data),
159
175
  });
160
- if (!updated) throw new PlansApiError(0, null, 'Update returned no body');
176
+ if (!updated)
177
+ throw markEmptyResponse(new PlansApiError(0, null, 'Update returned no body'));
161
178
  plans.value = plans.value.map((p) => (p.id === planId ? updated : p));
162
179
  return updated;
163
180
  }
@@ -264,8 +281,9 @@ export function usePlanVersions(options: UsePlanVersionsOptions): UsePlanVersion
264
281
  }
265
282
 
266
283
  async function fetchJson<T>(url: string, init?: Parameters<HttpClient>[1]): Promise<T | null> {
284
+ const method = init?.method ?? 'GET';
267
285
  const res = await http(url, {
268
- method: init?.method ?? 'GET',
286
+ method,
269
287
  headers: {
270
288
  'content-type': 'application/json',
271
289
  ...authHeaders(),
@@ -273,6 +291,12 @@ export function usePlanVersions(options: UsePlanVersionsOptions): UsePlanVersion
273
291
  },
274
292
  body: init?.body,
275
293
  });
294
+ requireServerAnswer(
295
+ res.status,
296
+ method,
297
+ url,
298
+ (diagnostic) => new PlansApiError(res.status, null, diagnostic),
299
+ );
276
300
  if (res.status === 204) return null;
277
301
  const body = await res.json().catch(() => null);
278
302
  if (res.status >= 400) {
@@ -305,7 +329,8 @@ export function usePlanVersions(options: UsePlanVersionsOptions): UsePlanVersion
305
329
  method: 'POST',
306
330
  body: JSON.stringify(data),
307
331
  });
308
- if (!result) throw new PlansApiError(0, null, 'CreateDraft returned no body');
332
+ if (!result)
333
+ throw markEmptyResponse(new PlansApiError(0, null, 'CreateDraft returned no body'));
309
334
  versions.value = [...versions.value, result.planVersion];
310
335
  return result;
311
336
  }
@@ -318,7 +343,8 @@ export function usePlanVersions(options: UsePlanVersionsOptions): UsePlanVersion
318
343
  `${versionUrlBase}/${versionId}`,
319
344
  { method: 'PATCH', body: JSON.stringify(data) },
320
345
  );
321
- if (!result) throw new PlansApiError(0, null, 'UpdateDraft returned no body');
346
+ if (!result)
347
+ throw markEmptyResponse(new PlansApiError(0, null, 'UpdateDraft returned no body'));
322
348
  versions.value = versions.value.map((v) => (v.id === versionId ? result.planVersion : v));
323
349
  return result;
324
350
  }
@@ -336,7 +362,8 @@ export function usePlanVersions(options: UsePlanVersionsOptions): UsePlanVersion
336
362
  `${versionUrlBase}/${versionId}/publish`,
337
363
  { method: 'POST', body: JSON.stringify(opts) },
338
364
  );
339
- if (!result) throw new PlansApiError(0, null, 'Publish returned no body');
365
+ if (!result)
366
+ throw markEmptyResponse(new PlansApiError(0, null, 'Publish returned no body'));
340
367
  versions.value = versions.value.map((v) => (v.id === versionId ? result.planVersion : v));
341
368
  return result;
342
369
  }
@@ -351,7 +378,8 @@ export function usePlanVersions(options: UsePlanVersionsOptions): UsePlanVersion
351
378
  `${versionUrlBase}/${versionId}/terminate`,
352
379
  { method: 'POST', body: JSON.stringify({ endsAt }) },
353
380
  );
354
- if (!updated) throw new PlansApiError(0, null, 'Terminate returned no body');
381
+ if (!updated)
382
+ throw markEmptyResponse(new PlansApiError(0, null, 'Terminate returned no body'));
355
383
  versions.value = versions.value.map((v) => (v.id === versionId ? updated : v));
356
384
  return updated;
357
385
  }