@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.
- package/README.md +32 -8
- package/dist/{catalog-DJcfm5N2.d.cts → catalog-Dch1Ryw0.d.cts} +353 -69
- package/dist/{catalog-DJcfm5N2.d.ts → catalog-Dch1Ryw0.d.ts} +353 -69
- package/dist/{chunk-F7NRM6KI.js → chunk-BPF2BMCQ.js} +748 -24
- package/dist/{chunk-D74FFCCP.js → chunk-NEXTZZRQ.js} +95 -177
- package/dist/{chunk-TQQMKWN6.js → chunk-O3J3ITF2.js} +119 -7
- package/dist/client/index.cjs +758 -83
- package/dist/client/index.d.cts +414 -9
- package/dist/client/index.d.ts +414 -9
- package/dist/client/index.js +84 -12
- package/dist/index.cjs +1325 -294
- package/dist/index.d.cts +205 -15
- package/dist/index.d.ts +205 -15
- package/dist/index.js +566 -240
- package/dist/quasar/index.cjs +574 -62
- package/dist/quasar/index.d.cts +61 -6
- package/dist/quasar/index.d.ts +61 -6
- package/dist/quasar/index.js +83 -19
- package/dist/{use-sa-theme-OUBlaqgl.d.ts → resource-registry-D7Xjs-5f.d.cts} +157 -7
- package/dist/{use-sa-theme-B5t7oXph.d.cts → resource-registry-TaR7rtq0.d.ts} +157 -7
- package/package.json +4 -3
- package/src/client/admin-error.ts +552 -0
- package/src/client/admin-resource-client.ts +9 -12
- package/src/client/batch-column-fetcher.ts +2 -2
- package/src/client/boot-loader.ts +12 -1
- package/src/client/http/axios-http-client.ts +383 -0
- package/src/client/http/fetch-http-client.ts +89 -0
- package/src/client/http/index.ts +6 -0
- package/src/client/http-json.ts +82 -16
- package/src/client/i18n/messages/discovery.ts +0 -6
- package/src/client/i18n/messages/errors.ts +44 -0
- package/src/client/i18n/messages/marketing.ts +0 -6
- package/src/client/i18n/messages/promos.ts +0 -2
- package/src/client/i18n/messages/shell.ts +12 -0
- package/src/client/i18n/messages.ts +3 -0
- package/src/client/index.ts +6 -0
- package/src/client/manifest-loader.ts +65 -15
- package/src/client/resources/audit.resource.ts +25 -0
- package/src/client/resources/define-resource.ts +166 -0
- package/src/client/resources/index.ts +42 -0
- package/src/client/resources/list-resource.ts +176 -0
- package/src/client/resources/plans.resource.ts +169 -0
- package/src/client/resources/resource-request.ts +117 -0
- package/src/client/resources/tenants.resource.ts +27 -0
- package/src/client/types.ts +10 -3
- package/src/components/BundleVersionPublishDialog.vue +1 -1
- package/src/components/MarketingPromotionsTab.vue +263 -241
- package/src/components/ThemeSwitcher.vue +85 -0
- package/src/components/admin-page/AdminAccordion.vue +125 -0
- package/src/components/bundle-editor/BundleCreatePanel.vue +1 -1
- package/src/components/dialogs/PromoCodeDialogFields.vue +20 -20
- package/src/components/plan/PlanCycleToggle.vue +1 -1
- package/src/components/plan-detail/PlanVersionsPanel.vue +5 -2
- package/src/index.ts +5 -0
- package/src/pages-standard/AdminLayout.vue +57 -0
- package/src/pages-standard/AdminManifestErrorPage.vue +4 -3
- package/src/pages-standard/BundlesPage.vue +5 -35
- package/src/pages-standard/DiscoveryPage.vue +8 -1
- package/src/pages-standard/MarketingCatalogPage.vue +14 -3
- package/src/pages-standard/SuperAdminLoginPage.vue +23 -2
- package/src/pages-standard/SuperAdminSetupWizard.vue +37 -3
- package/src/pages-standard/bundles-page/BundleAccordionList.vue +30 -32
- package/src/pages-standard/discovery-page/DiscoveryFeatureCard.vue +80 -88
- package/src/pages-standard/discovery-page/DiscoveryQuotaCard.vue +73 -79
- package/src/pages-standard/marketing-catalog/MarketingCatalogAdmin.vue +44 -6
- package/src/pages-standard/marketing-catalog/MarketingCatalogPreview.vue +8 -2
- package/src/pages-tenant/PackageSnapshotPanel.vue +31 -2
- package/src/quasar/confirm.ts +83 -0
- package/src/quasar/create-super-admin-app.ts +84 -10
- package/src/quasar/dark-bridge.ts +47 -10
- package/src/quasar/index.ts +1 -0
- package/src/ui/theme/components/accordion.css +118 -0
- package/src/ui/theme/index.css +1 -0
- package/src/vue/create-admin-routes.ts +9 -1
- package/src/vue/platform-loaders.ts +12 -2
- package/src/vue/resource-registry.ts +211 -0
- package/src/vue/super-admin-context.ts +20 -5
- package/src/vue/ui-confirm.ts +76 -0
- package/src/vue/use-api-list.ts +50 -45
- package/src/vue/use-async-action.ts +170 -0
- package/src/vue/use-async-data.ts +83 -0
- package/src/vue/use-bundles.ts +34 -7
- package/src/vue/use-catalog-entries.ts +37 -12
- package/src/vue/use-discovery.ts +27 -6
- package/src/vue/use-marketing-projections.ts +29 -7
- package/src/vue/use-plans.ts +36 -8
- package/src/vue/use-promotions.ts +27 -7
- package/src/vue/use-resource-list.ts +272 -0
- package/src/vue/use-sa-theme.ts +34 -5
- 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
|
+
}
|
package/src/vue/use-bundles.ts
CHANGED
|
@@ -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
|
|
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)
|
|
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)
|
|
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
|
|
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)
|
|
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)
|
|
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)
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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)
|
|
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)
|
|
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)
|
|
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)
|
|
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)
|
|
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)
|
|
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)
|
|
246
|
+
if (!result)
|
|
247
|
+
throw markEmptyResponse(new CatalogEntriesApiError(0, null, 'Sync returned no body'));
|
|
223
248
|
await load();
|
|
224
249
|
return result;
|
|
225
250
|
}
|
package/src/vue/use-discovery.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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;
|
package/src/vue/use-plans.ts
CHANGED
|
@@ -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
|
|
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)
|
|
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)
|
|
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
|
|
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)
|
|
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)
|
|
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)
|
|
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)
|
|
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
|
}
|