@saasicat/ui-vue 0.2.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/LICENSE +202 -0
- package/README.md +47 -0
- package/dist/index.cjs +3776 -0
- package/dist/index.d.cts +2395 -0
- package/dist/index.d.ts +2395 -0
- package/dist/index.js +3659 -0
- package/dist/testing-e2e/admin-pages-suite.cjs +134 -0
- package/dist/testing-e2e/admin-pages-suite.d.cts +37 -0
- package/dist/testing-e2e/admin-pages-suite.d.ts +37 -0
- package/dist/testing-e2e/admin-pages-suite.js +109 -0
- package/package.json +87 -0
- package/src/action-registry.ts +110 -0
- package/src/batch-column-fetcher.ts +152 -0
- package/src/boot-loader.ts +60 -0
- package/src/components/BundleVersionPublishDialog.vue +359 -0
- package/src/components/BusinessTypeVersionEditorDialog.vue +417 -0
- package/src/components/BusinessTypeVersionPublishDialog.vue +258 -0
- package/src/components/FeatureGate.vue +56 -0
- package/src/components/KpiCard.vue +53 -0
- package/src/components/KvBlock.vue +36 -0
- package/src/components/MarketingPromotionsTab.vue +861 -0
- package/src/components/MfaPromptDialog.vue +104 -0
- package/src/components/TenantActionConfirmDialog.vue +252 -0
- package/src/components/VersionDiffPreview.vue +150 -0
- package/src/components/bundle-editor/BundleCreatePanel.vue +681 -0
- package/src/components/bundle-editor/BundleFeaturesEditor.vue +215 -0
- package/src/components/bundle-editor/BundlePlanCompatPicker.vue +316 -0
- package/src/components/bundle-editor/BundleQuotasEditor.vue +219 -0
- package/src/components/bundle-editor/BundleStatusBanner.vue +189 -0
- package/src/components/bundle-editor/BundleVersionInlineEditor.vue +659 -0
- package/src/components/bundle-editor/BundleVersionStrip.vue +207 -0
- package/src/components/bundle-editor/bundle-version-status.ts +164 -0
- package/src/components/bundle-editor/catalog-i18n.ts +85 -0
- package/src/components/dialogs/PilotCreateDialog.vue +780 -0
- package/src/components/dialogs/PilotEditDialog.vue +548 -0
- package/src/components/dialogs/PromoCodeCreateDialog.vue +737 -0
- package/src/components/dialogs/PromoCodeEditDialog.vue +859 -0
- package/src/components/dialogs/types.ts +126 -0
- package/src/components/plan/PlanCycleToggle.vue +99 -0
- package/src/components/plan/PlanGrid.vue +211 -0
- package/src/components/plan/PriceSummary.vue +293 -0
- package/src/components/plan/PromoCodeInput.vue +192 -0
- package/src/components/plan/PublicBundleGrid.vue +287 -0
- package/src/components/plan-cockpit/PlanCockpit.vue +993 -0
- package/src/components/plan-cockpit/PlanCockpitAuditLog.vue +65 -0
- package/src/components/plan-cockpit/PlanCockpitDiffPanel.vue +91 -0
- package/src/components/plan-cockpit/PlanCockpitHeader.vue +98 -0
- package/src/components/plan-cockpit/PlanCockpitImpactPanel.vue +99 -0
- package/src/components/plan-cockpit/PlanCockpitKpis.vue +57 -0
- package/src/components/plan-cockpit/PlanCockpitVersions.vue +226 -0
- package/src/components/plan-cockpit/types.ts +72 -0
- package/src/components/plan-create-dialog/PlanCreateDialog.vue +508 -0
- package/src/components/plan-detail/PlanAuditLog.vue +59 -0
- package/src/components/plan-detail/PlanDetail.vue +1195 -0
- package/src/components/plan-detail/PlanDetailHeader.vue +185 -0
- package/src/components/plan-detail/PlanDetailKpis.vue +57 -0
- package/src/components/plan-detail/PlanTerminateDialog.vue +76 -0
- package/src/components/plan-detail/PlanVersionDiffPanel.vue +149 -0
- package/src/components/plan-detail/PlanVersionsPanel.vue +255 -0
- package/src/components/plan-detail/types.ts +59 -0
- package/src/components/plan-list/PlanList.vue +1067 -0
- package/src/components/plan-matrix/PlanMatrix.vue +1113 -0
- package/src/components/plan-review/PlanReview.vue +742 -0
- package/src/components/plan-version-editor/PlanCatalogPreview.vue +184 -0
- package/src/components/plan-version-editor/PlanComponentPool.vue +272 -0
- package/src/components/plan-version-editor/PlanVersionBasket.vue +281 -0
- package/src/components/plan-version-editor/PlanVersionDiffDialog.vue +84 -0
- package/src/components/plan-version-editor/PlanVersionEditor.vue +1728 -0
- package/src/components/plan-version-editor/PlanVersionEditorHeader.vue +94 -0
- package/src/components/plan-version-editor/types.ts +96 -0
- package/src/components/wizard-stepper/WizardStepper.vue +107 -0
- package/src/create-admin-routes.ts +68 -0
- package/src/create-super-admin-app.ts +345 -0
- package/src/entitlement-provider.ts +36 -0
- package/src/feature-router-guard.ts +77 -0
- package/src/http-json.ts +62 -0
- package/src/index.ts +98 -0
- package/src/manifest-loader.ts +139 -0
- package/src/manifest-store-factory.ts +114 -0
- package/src/nav-builder.ts +274 -0
- package/src/pages-standard/AdminLayout.vue +411 -0
- package/src/pages-standard/AdminManifestErrorPage.vue +79 -0
- package/src/pages-standard/AuditPage.vue +223 -0
- package/src/pages-standard/BundlesPage.vue +910 -0
- package/src/pages-standard/BusinessTypesPage.vue +662 -0
- package/src/pages-standard/DashboardPage.vue +595 -0
- package/src/pages-standard/DiscoveryPage.vue +663 -0
- package/src/pages-standard/EmailHistoryPage.vue +576 -0
- package/src/pages-standard/MarketingCatalogPage.vue +1771 -0
- package/src/pages-standard/PilotsPage.vue +577 -0
- package/src/pages-standard/PlanVersionsPage.vue +216 -0
- package/src/pages-standard/PlansPage.vue +1222 -0
- package/src/pages-standard/PlatformEmailPage.vue +434 -0
- package/src/pages-standard/PromoCodeDetailPage.vue +331 -0
- package/src/pages-standard/PromoCodesPage.vue +589 -0
- package/src/pages-standard/SubscriptionsPage.vue +159 -0
- package/src/pages-standard/SuperAdminLoginPage.vue +299 -0
- package/src/pages-standard/SuperAdminSetupWizard.vue +405 -0
- package/src/pages-standard/TenantDetailPage.vue +432 -0
- package/src/pages-standard/TenantsPage.vue +732 -0
- package/src/pages-standard/UsersPage.vue +504 -0
- package/src/pages-standard/bundles-page/BundleAccordionList.vue +116 -0
- package/src/pages-standard/bundles-page/BundleDetailPanel.vue +193 -0
- package/src/pages-standard/bundles-page/BundlesFilterBar.vue +48 -0
- package/src/pages-standard/bundles-page/BundlesHeader.vue +67 -0
- package/src/pages-standard/bundles-page/BundlesKpis.vue +39 -0
- package/src/pages-standard/bundles-page/types.ts +15 -0
- package/src/pages-standard/discovery-page/CatalogEntryTransPanel.vue +246 -0
- package/src/pages-standard/discovery-page/DiscoveryCapList.vue +171 -0
- package/src/pages-standard/discovery-page/DiscoveryFeatureCard.vue +410 -0
- package/src/pages-standard/discovery-page/DiscoveryHeader.vue +31 -0
- package/src/pages-standard/discovery-page/DiscoveryKpis.vue +47 -0
- package/src/pages-standard/discovery-page/DiscoveryMetaBanner.vue +26 -0
- package/src/pages-standard/discovery-page/DiscoveryQuotaCard.vue +247 -0
- package/src/pages-standard/discovery-page/DiscoveryStatusControl.vue +73 -0
- package/src/pages-standard/discovery-page/discovery-ui.ts +166 -0
- package/src/pages-standard/email-history.types.ts +48 -0
- package/src/pages-standard/marketing-catalog/MarketingCatalogAdmin.vue +448 -0
- package/src/pages-standard/marketing-catalog/MarketingCatalogHeader.vue +93 -0
- package/src/pages-standard/marketing-catalog/MarketingCatalogPreview.vue +138 -0
- package/src/pages-standard/marketing-catalog/MarketingCatalogToolbar.vue +58 -0
- package/src/pages-standard/marketing-catalog/types.ts +43 -0
- package/src/pages-standard/plan-versions/PlanDiffCard.vue +132 -0
- package/src/pages-standard/plan-versions/PlanVersionHeader.vue +293 -0
- package/src/pages-standard/plan-versions/PlanVersionStatusBadge.vue +48 -0
- package/src/pages-standard/plan-versions/PlanVersionsAudit.vue +307 -0
- package/src/pages-standard/plan-versions/PlanVersionsDiff.vue +269 -0
- package/src/pages-standard/plan-versions/PlanVersionsKpi.vue +65 -0
- package/src/pages-standard/plan-versions/PlanVersionsList.vue +292 -0
- package/src/pages-standard/plan-versions/PlanVersionsMatrix.vue +261 -0
- package/src/pages-standard/plan-versions/PlanVersionsTimeline.vue +300 -0
- package/src/pages-standard/plan-versions/VersionDiffPreview.vue +167 -0
- package/src/pages-standard/plan-versions/format.ts +53 -0
- package/src/pages-standard/plan-versions/types.ts +33 -0
- package/src/pages-standard/plans-page/PlanArchiveDialog.vue +50 -0
- package/src/pages-standard/plans-page/PlanBundleOverview.vue +147 -0
- package/src/pages-standard/plans-page/PlanDiscardDraftDialog.vue +46 -0
- package/src/pages-standard/plans-page/PlanPublishDialog.vue +106 -0
- package/src/pages-standard/plans-page/PlansPageToast.vue +69 -0
- package/src/pages-standard/plans-page/types.ts +18 -0
- package/src/pages-standard/platform-email.types.ts +41 -0
- package/src/pages-standard/sa-theme.css +223 -0
- package/src/pages-standard/tenants/StatusPill.vue +62 -0
- package/src/pages-standard/tenants/format.ts +38 -0
- package/src/pages-tenant/LimitsRow.vue +67 -0
- package/src/pages-tenant/MySubscriptionBundlesPage.vue +604 -0
- package/src/pages-tenant/OnboardingConfigurator.vue +417 -0
- package/src/pages-tenant/PackageSnapshotPanel.vue +241 -0
- package/src/pages-tenant/PendingVersionBanner.vue +126 -0
- package/src/pages-tenant/PlanChangeWizard.vue +630 -0
- package/src/pages-tenant/TenantPlanSection.vue +729 -0
- package/src/pages-tenant/UsageBar.vue +107 -0
- package/src/pages-tenant/default-i18n.ts +280 -0
- package/src/pages-tenant/tenant-plan-section/BundlePreviewDialog.vue +304 -0
- package/src/pages-tenant/tenant-plan-section/TenantBundleStore.vue +323 -0
- package/src/pages-tenant/tenant-plan-section/TenantFeatureMatrix.vue +122 -0
- package/src/pages-tenant/tenant-plan-section/TenantPlanCardHeader.vue +67 -0
- package/src/pages-tenant/tenant-plan-section/TenantUsageGrid.vue +31 -0
- package/src/plan-versions-catalog.ts +368 -0
- package/src/platform-loaders.ts +68 -0
- package/src/project-page-host.ts +108 -0
- package/src/testing-e2e/admin-pages-suite.ts +187 -0
- package/src/types.ts +58 -0
- package/src/use-actions.ts +25 -0
- package/src/use-api-list.ts +151 -0
- package/src/use-audit-entries.ts +48 -0
- package/src/use-batch-columns.ts +59 -0
- package/src/use-bulk-publish.ts +150 -0
- package/src/use-bundle-versions-map.ts +112 -0
- package/src/use-bundles.ts +297 -0
- package/src/use-business-types.ts +269 -0
- package/src/use-catalog-entries.ts +243 -0
- package/src/use-discovery.ts +158 -0
- package/src/use-entitlement.ts +87 -0
- package/src/use-live-plan-versions.ts +126 -0
- package/src/use-manifest.ts +57 -0
- package/src/use-marketing-projections.ts +160 -0
- package/src/use-nav.ts +33 -0
- package/src/use-plan-editor.ts +162 -0
- package/src/use-plan-versions.ts +83 -0
- package/src/use-plans.ts +375 -0
- package/src/use-platform-tenant-actions.ts +286 -0
- package/src/use-promotions.ts +126 -0
- package/src/use-public-boot.ts +40 -0
- package/src/use-subscription-draft.ts +355 -0
- package/src/use-super-admin-context.ts +93 -0
- package/src/use-tenant-action-flow.ts +238 -0
- package/src/use-tenant-billing-catalog.ts +160 -0
- package/src/use-tenant-billing.ts +453 -0
- package/src/use-tenant-manifest.ts +94 -0
- package/src/use-tenant-subscription-bundles.ts +148 -0
- package/src/use-tenants.ts +61 -0
- package/src/version.ts +10 -0
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// useCatalogEntries — Vue 3 composable for the Discovery review
|
|
2
|
+
// (Backend: CatalogEntriesController under /admin/catalog/{capabilities,
|
|
3
|
+
// features,quotas} + /admin/catalog/discovery/sync).
|
|
4
|
+
//
|
|
5
|
+
// Discovery (`/admin/discovery`) is the code's actual state; this composable
|
|
6
|
+
// reads/writes the persisted review status + the translations.
|
|
7
|
+
//
|
|
8
|
+
// **Admin endpoint prefix is required** and is supplied by the consumer
|
|
9
|
+
// (e.g. `/api/admin` or `/api/v1/admin`).
|
|
10
|
+
|
|
11
|
+
import { ref, type Ref } from 'vue';
|
|
12
|
+
import type {
|
|
13
|
+
CapabilityCatalogEntryRow,
|
|
14
|
+
CatalogEntryI18n,
|
|
15
|
+
DiscoverySnapshot,
|
|
16
|
+
FeatureCatalogEntryRow,
|
|
17
|
+
QuotaCatalogEntryRow,
|
|
18
|
+
ReviewCatalogEntryData,
|
|
19
|
+
SyncDiscoveryResult,
|
|
20
|
+
UpdateCatalogEntryBaseData,
|
|
21
|
+
} from '@saasicat/types';
|
|
22
|
+
import { defaultHttpClient, type HttpClient } from './types.js';
|
|
23
|
+
|
|
24
|
+
export interface UseCatalogEntriesOptions {
|
|
25
|
+
/** Admin endpoint prefix incl. globalPrefix (`/api/admin`, `/api/v1/admin`). */
|
|
26
|
+
adminEndpoint: string;
|
|
27
|
+
http?: HttpClient;
|
|
28
|
+
getAuthToken?: () => string | null;
|
|
29
|
+
/** projectKey the catalog entries are filtered against. */
|
|
30
|
+
projectKey: string;
|
|
31
|
+
autoLoad?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class CatalogEntriesApiError extends Error {
|
|
35
|
+
constructor(
|
|
36
|
+
public readonly status: number,
|
|
37
|
+
public readonly body: unknown,
|
|
38
|
+
message: string,
|
|
39
|
+
) {
|
|
40
|
+
super(message);
|
|
41
|
+
this.name = 'CatalogEntriesApiError';
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface UseCatalogEntriesResult {
|
|
46
|
+
capabilities: Ref<CapabilityCatalogEntryRow[]>;
|
|
47
|
+
features: Ref<FeatureCatalogEntryRow[]>;
|
|
48
|
+
quotas: Ref<QuotaCatalogEntryRow[]>;
|
|
49
|
+
loading: Ref<boolean>;
|
|
50
|
+
error: Ref<Error | null>;
|
|
51
|
+
|
|
52
|
+
load: () => Promise<void>;
|
|
53
|
+
/** Approval transition of a feature (#20): PATCH …/features/:key/review. */
|
|
54
|
+
reviewFeature: (
|
|
55
|
+
featureKey: string,
|
|
56
|
+
data: ReviewCatalogEntryData,
|
|
57
|
+
) => Promise<FeatureCatalogEntryRow>;
|
|
58
|
+
/** Approval transition of a quota (#20): PATCH …/quotas/:key/review. */
|
|
59
|
+
reviewQuota: (quotaKey: string, data: ReviewCatalogEntryData) => Promise<QuotaCatalogEntryRow>;
|
|
60
|
+
setFeatureI18n: (featureKey: string, i18n: CatalogEntryI18n) => Promise<FeatureCatalogEntryRow>;
|
|
61
|
+
setQuotaI18n: (quotaKey: string, i18n: CatalogEntryI18n) => Promise<QuotaCatalogEntryRow>;
|
|
62
|
+
/** Sets the editable default-locale label/description of a feature. */
|
|
63
|
+
setFeatureBase: (
|
|
64
|
+
featureKey: string,
|
|
65
|
+
data: UpdateCatalogEntryBaseData,
|
|
66
|
+
) => Promise<FeatureCatalogEntryRow>;
|
|
67
|
+
/** Sets the editable default-locale label/description of a quota. */
|
|
68
|
+
setQuotaBase: (
|
|
69
|
+
quotaKey: string,
|
|
70
|
+
data: UpdateCatalogEntryBaseData,
|
|
71
|
+
) => Promise<QuotaCatalogEntryRow>;
|
|
72
|
+
/** Upserts the catalog entries from a discovery snapshot and reloads. */
|
|
73
|
+
syncDiscovery: (snapshot: DiscoverySnapshot) => Promise<SyncDiscoveryResult>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function useCatalogEntries(options: UseCatalogEntriesOptions): UseCatalogEntriesResult {
|
|
77
|
+
if (!options?.adminEndpoint) {
|
|
78
|
+
throw new Error('useCatalogEntries: `adminEndpoint` ist Pflicht.');
|
|
79
|
+
}
|
|
80
|
+
if (!options?.projectKey) {
|
|
81
|
+
throw new Error('useCatalogEntries: `projectKey` ist Pflicht.');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const http = options.http ?? defaultHttpClient();
|
|
85
|
+
const capabilities = ref<CapabilityCatalogEntryRow[]>([]);
|
|
86
|
+
const features = ref<FeatureCatalogEntryRow[]>([]);
|
|
87
|
+
const quotas = ref<QuotaCatalogEntryRow[]>([]);
|
|
88
|
+
const loading = ref(false);
|
|
89
|
+
const error = ref<Error | null>(null);
|
|
90
|
+
|
|
91
|
+
const base = `${options.adminEndpoint}/catalog`;
|
|
92
|
+
const pk = encodeURIComponent(options.projectKey);
|
|
93
|
+
|
|
94
|
+
function authHeaders(): Record<string, string> {
|
|
95
|
+
const token = options.getAuthToken?.();
|
|
96
|
+
return token ? { Authorization: `Bearer ${token}` } : {};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function fetchJson<T>(url: string, init?: Parameters<HttpClient>[1]): Promise<T | null> {
|
|
100
|
+
const res = await http(url, {
|
|
101
|
+
method: init?.method ?? 'GET',
|
|
102
|
+
headers: { 'content-type': 'application/json', ...authHeaders(), ...init?.headers },
|
|
103
|
+
body: init?.body,
|
|
104
|
+
});
|
|
105
|
+
if (res.status === 204) return null;
|
|
106
|
+
const body = await res.json().catch(() => null);
|
|
107
|
+
if (res.status >= 400) {
|
|
108
|
+
throw new CatalogEntriesApiError(
|
|
109
|
+
res.status,
|
|
110
|
+
body,
|
|
111
|
+
`Catalog-Entries-API antwortete mit HTTP ${res.status}`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
return body as T;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function load(): Promise<void> {
|
|
118
|
+
loading.value = true;
|
|
119
|
+
error.value = null;
|
|
120
|
+
try {
|
|
121
|
+
const [caps, feats, qs] = await Promise.all([
|
|
122
|
+
fetchJson<CapabilityCatalogEntryRow[]>(`${base}/capabilities?projectKey=${pk}`),
|
|
123
|
+
fetchJson<FeatureCatalogEntryRow[]>(`${base}/features?projectKey=${pk}`),
|
|
124
|
+
fetchJson<QuotaCatalogEntryRow[]>(`${base}/quotas?projectKey=${pk}`),
|
|
125
|
+
]);
|
|
126
|
+
capabilities.value = caps ?? [];
|
|
127
|
+
features.value = feats ?? [];
|
|
128
|
+
quotas.value = qs ?? [];
|
|
129
|
+
} catch (err) {
|
|
130
|
+
error.value = err instanceof Error ? err : new Error(String(err));
|
|
131
|
+
} finally {
|
|
132
|
+
loading.value = false;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function reviewFeature(
|
|
137
|
+
featureKey: string,
|
|
138
|
+
data: ReviewCatalogEntryData,
|
|
139
|
+
): Promise<FeatureCatalogEntryRow> {
|
|
140
|
+
const updated = await fetchJson<FeatureCatalogEntryRow>(
|
|
141
|
+
`${base}/features/${encodeURIComponent(featureKey)}/review?projectKey=${pk}`,
|
|
142
|
+
{ method: 'PATCH', body: JSON.stringify(data) },
|
|
143
|
+
);
|
|
144
|
+
if (!updated) throw new CatalogEntriesApiError(0, null, 'Review gab keinen Body zurück');
|
|
145
|
+
features.value = features.value.map((f) => (f.featureKey === featureKey ? updated : f));
|
|
146
|
+
return updated;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function reviewQuota(
|
|
150
|
+
quotaKey: string,
|
|
151
|
+
data: ReviewCatalogEntryData,
|
|
152
|
+
): Promise<QuotaCatalogEntryRow> {
|
|
153
|
+
const updated = await fetchJson<QuotaCatalogEntryRow>(
|
|
154
|
+
`${base}/quotas/${encodeURIComponent(quotaKey)}/review?projectKey=${pk}`,
|
|
155
|
+
{ method: 'PATCH', body: JSON.stringify(data) },
|
|
156
|
+
);
|
|
157
|
+
if (!updated) throw new CatalogEntriesApiError(0, null, 'Review gab keinen Body zurück');
|
|
158
|
+
quotas.value = quotas.value.map((q) => (q.quotaKey === quotaKey ? updated : q));
|
|
159
|
+
return updated;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function setFeatureI18n(
|
|
163
|
+
featureKey: string,
|
|
164
|
+
i18n: CatalogEntryI18n,
|
|
165
|
+
): Promise<FeatureCatalogEntryRow> {
|
|
166
|
+
const updated = await fetchJson<FeatureCatalogEntryRow>(
|
|
167
|
+
`${base}/features/${encodeURIComponent(featureKey)}/i18n?projectKey=${pk}`,
|
|
168
|
+
{ method: 'PATCH', body: JSON.stringify({ i18n }) },
|
|
169
|
+
);
|
|
170
|
+
if (!updated) throw new CatalogEntriesApiError(0, null, 'i18n gab keinen Body zurück');
|
|
171
|
+
features.value = features.value.map((f) => (f.featureKey === featureKey ? updated : f));
|
|
172
|
+
return updated;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function setQuotaI18n(
|
|
176
|
+
quotaKey: string,
|
|
177
|
+
i18n: CatalogEntryI18n,
|
|
178
|
+
): Promise<QuotaCatalogEntryRow> {
|
|
179
|
+
const updated = await fetchJson<QuotaCatalogEntryRow>(
|
|
180
|
+
`${base}/quotas/${encodeURIComponent(quotaKey)}/i18n?projectKey=${pk}`,
|
|
181
|
+
{ method: 'PATCH', body: JSON.stringify({ i18n }) },
|
|
182
|
+
);
|
|
183
|
+
if (!updated) throw new CatalogEntriesApiError(0, null, 'i18n gab keinen Body zurück');
|
|
184
|
+
quotas.value = quotas.value.map((q) => (q.quotaKey === quotaKey ? updated : q));
|
|
185
|
+
return updated;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function setFeatureBase(
|
|
189
|
+
featureKey: string,
|
|
190
|
+
data: UpdateCatalogEntryBaseData,
|
|
191
|
+
): Promise<FeatureCatalogEntryRow> {
|
|
192
|
+
const updated = await fetchJson<FeatureCatalogEntryRow>(
|
|
193
|
+
`${base}/features/${encodeURIComponent(featureKey)}?projectKey=${pk}`,
|
|
194
|
+
{ method: 'PATCH', body: JSON.stringify(data) },
|
|
195
|
+
);
|
|
196
|
+
if (!updated) throw new CatalogEntriesApiError(0, null, 'Base gab keinen Body zurück');
|
|
197
|
+
features.value = features.value.map((f) => (f.featureKey === featureKey ? updated : f));
|
|
198
|
+
return updated;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function setQuotaBase(
|
|
202
|
+
quotaKey: string,
|
|
203
|
+
data: UpdateCatalogEntryBaseData,
|
|
204
|
+
): Promise<QuotaCatalogEntryRow> {
|
|
205
|
+
const updated = await fetchJson<QuotaCatalogEntryRow>(
|
|
206
|
+
`${base}/quotas/${encodeURIComponent(quotaKey)}?projectKey=${pk}`,
|
|
207
|
+
{ method: 'PATCH', body: JSON.stringify(data) },
|
|
208
|
+
);
|
|
209
|
+
if (!updated) throw new CatalogEntriesApiError(0, null, 'Base gab keinen Body zurück');
|
|
210
|
+
quotas.value = quotas.value.map((q) => (q.quotaKey === quotaKey ? updated : q));
|
|
211
|
+
return updated;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function syncDiscovery(snapshot: DiscoverySnapshot): Promise<SyncDiscoveryResult> {
|
|
215
|
+
const result = await fetchJson<SyncDiscoveryResult>(`${base}/discovery/sync`, {
|
|
216
|
+
method: 'POST',
|
|
217
|
+
body: JSON.stringify({ snapshot }),
|
|
218
|
+
});
|
|
219
|
+
if (!result) throw new CatalogEntriesApiError(0, null, 'Sync gab keinen Body zurück');
|
|
220
|
+
await load();
|
|
221
|
+
return result;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (options.autoLoad) {
|
|
225
|
+
void load();
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return {
|
|
229
|
+
capabilities,
|
|
230
|
+
features,
|
|
231
|
+
quotas,
|
|
232
|
+
loading,
|
|
233
|
+
error,
|
|
234
|
+
load,
|
|
235
|
+
reviewFeature,
|
|
236
|
+
reviewQuota,
|
|
237
|
+
setFeatureI18n,
|
|
238
|
+
setQuotaI18n,
|
|
239
|
+
setFeatureBase,
|
|
240
|
+
setQuotaBase,
|
|
241
|
+
syncDiscovery,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// useDiscovery — Vue 3 composable over the discovery endpoint
|
|
2
|
+
// (`GET /admin/discovery`).
|
|
3
|
+
//
|
|
4
|
+
// Returns the boot-time snapshot of the running backend — capabilities,
|
|
5
|
+
// features, bundles and quotas that are annotated in code via
|
|
6
|
+
// @ImplementsCapability, @DefinesQuota etc. ETag caching: on follow-up
|
|
7
|
+
// requests `If-None-Match: <hash>` is sent along; on 304 the cached
|
|
8
|
+
// snapshot stays in place, no re-parse needed.
|
|
9
|
+
//
|
|
10
|
+
// **The endpoint is mandatory** and is supplied by the consumer
|
|
11
|
+
// (e.g. `/api/admin/discovery` or `/api/v1/admin/discovery`).
|
|
12
|
+
|
|
13
|
+
import { ref, type Ref } from 'vue';
|
|
14
|
+
import type { DiscoverySnapshot } from '@saasicat/types';
|
|
15
|
+
import { defaultHttpClient, type HttpClient } from './types.js';
|
|
16
|
+
|
|
17
|
+
export interface UseDiscoveryOptions {
|
|
18
|
+
/**
|
|
19
|
+
* Fully qualified discovery endpoint including the app globalPrefix
|
|
20
|
+
* (`/api/admin/discovery`, `/api/v1/admin/discovery`, …). Mandatory.
|
|
21
|
+
*/
|
|
22
|
+
endpoint: string;
|
|
23
|
+
http?: HttpClient;
|
|
24
|
+
/**
|
|
25
|
+
* Auth header for `Authorization: Bearer <token>`. Sent along with every
|
|
26
|
+
* request. The consumer supplies a function that pulls the current token
|
|
27
|
+
* from the auth store.
|
|
28
|
+
*/
|
|
29
|
+
getAuthToken?: () => string | null;
|
|
30
|
+
/**
|
|
31
|
+
* When `true`, loads automatically on composable init. Default
|
|
32
|
+
* `false` — the consumer triggers `load()` itself (e.g. after page mount).
|
|
33
|
+
*/
|
|
34
|
+
autoLoad?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class DiscoveryLoadError extends Error {
|
|
38
|
+
constructor(
|
|
39
|
+
public readonly status: number,
|
|
40
|
+
message: string,
|
|
41
|
+
) {
|
|
42
|
+
super(message);
|
|
43
|
+
this.name = 'DiscoveryLoadError';
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface UseDiscoveryResult {
|
|
48
|
+
snapshot: Ref<DiscoverySnapshot | null>;
|
|
49
|
+
/** ETag header of the last 200 response, or null. */
|
|
50
|
+
etag: Ref<string | null>;
|
|
51
|
+
loading: Ref<boolean>;
|
|
52
|
+
error: Ref<Error | null>;
|
|
53
|
+
/**
|
|
54
|
+
* Loads fresh. On a cache hit (HTTP 304) `snapshot` stays unchanged,
|
|
55
|
+
* as does `etag`.
|
|
56
|
+
*/
|
|
57
|
+
load: () => Promise<void>;
|
|
58
|
+
/**
|
|
59
|
+
* Discards the cache (etag = null) and loads without If-None-Match. Useful
|
|
60
|
+
* after code deploys, when a new snapshot is expected.
|
|
61
|
+
*/
|
|
62
|
+
reload: () => Promise<void>;
|
|
63
|
+
/**
|
|
64
|
+
* `POST <endpoint>/rescan` — forces a fresh code scan in the backend
|
|
65
|
+
* (new `scannedAt`) and adopts the snapshot.
|
|
66
|
+
*/
|
|
67
|
+
rescan: () => Promise<void>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function useDiscovery(options: UseDiscoveryOptions): UseDiscoveryResult {
|
|
71
|
+
if (!options?.endpoint) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
'useDiscovery: `endpoint` ist Pflicht (z. B. "/api/admin/discovery" ' +
|
|
74
|
+
'oder "/api/v1/admin/discovery"). Plattform hat keinen Default, ' +
|
|
75
|
+
'weil Apps unterschiedliche globalPrefix-Konventionen haben.',
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const http = options.http ?? defaultHttpClient();
|
|
80
|
+
const snapshot = ref<DiscoverySnapshot | null>(null);
|
|
81
|
+
const etag = ref<string | null>(null);
|
|
82
|
+
const loading = ref(false);
|
|
83
|
+
const error = ref<Error | null>(null);
|
|
84
|
+
|
|
85
|
+
async function fetchSnapshot(includeEtag: boolean): Promise<void> {
|
|
86
|
+
loading.value = true;
|
|
87
|
+
error.value = null;
|
|
88
|
+
try {
|
|
89
|
+
const headers: Record<string, string> = {};
|
|
90
|
+
const token = options.getAuthToken?.();
|
|
91
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
92
|
+
if (includeEtag && etag.value) {
|
|
93
|
+
headers['If-None-Match'] = etag.value;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const res = await http(options.endpoint, { method: 'GET', headers });
|
|
97
|
+
|
|
98
|
+
if (res.status === 304) {
|
|
99
|
+
// Cache hit: snapshot stays unchanged, no re-parse.
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (res.status !== 200) {
|
|
103
|
+
throw new DiscoveryLoadError(
|
|
104
|
+
res.status,
|
|
105
|
+
`Discovery-Endpoint antwortete mit HTTP ${res.status}`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const body = (await res.json()) as DiscoverySnapshot;
|
|
110
|
+
snapshot.value = body;
|
|
111
|
+
etag.value = res.headers.get('ETag');
|
|
112
|
+
} catch (err) {
|
|
113
|
+
error.value = err instanceof Error ? err : new Error(String(err));
|
|
114
|
+
} finally {
|
|
115
|
+
loading.value = false;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function load(): Promise<void> {
|
|
120
|
+
await fetchSnapshot(true);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function reload(): Promise<void> {
|
|
124
|
+
etag.value = null;
|
|
125
|
+
await fetchSnapshot(false);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function rescan(): Promise<void> {
|
|
129
|
+
loading.value = true;
|
|
130
|
+
error.value = null;
|
|
131
|
+
try {
|
|
132
|
+
const headers: Record<string, string> = { 'content-type': 'application/json' };
|
|
133
|
+
const token = options.getAuthToken?.();
|
|
134
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
135
|
+
|
|
136
|
+
const res = await http(`${options.endpoint}/rescan`, { method: 'POST', headers });
|
|
137
|
+
if (res.status !== 200 && res.status !== 201) {
|
|
138
|
+
throw new DiscoveryLoadError(
|
|
139
|
+
res.status,
|
|
140
|
+
`Discovery-Rescan antwortete mit HTTP ${res.status}`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
const body = (await res.json()) as DiscoverySnapshot;
|
|
144
|
+
snapshot.value = body;
|
|
145
|
+
etag.value = res.headers.get('ETag');
|
|
146
|
+
} catch (err) {
|
|
147
|
+
error.value = err instanceof Error ? err : new Error(String(err));
|
|
148
|
+
} finally {
|
|
149
|
+
loading.value = false;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (options.autoLoad) {
|
|
154
|
+
void load();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return { snapshot, etag, loading, error, load, reload, rescan };
|
|
158
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// useEntitlement — Vue composable over the tenant entitlement endpoint.
|
|
2
|
+
//
|
|
3
|
+
// **Endpoint is mandatory** and is supplied by the app consumer
|
|
4
|
+
// (e.g. `/api/billing/entitlement` or `/api/v1/billing/entitlement`).
|
|
5
|
+
//
|
|
6
|
+
// Returns a tenant's effective limits as a reactive Ref. The consumer
|
|
7
|
+
// backend exposes the endpoint via the platform `EntitlementService.computeLimits`
|
|
8
|
+
// (see @saasicat/nest).
|
|
9
|
+
//
|
|
10
|
+
// Output is the `EffectiveLimitsSnapshot` shape from saas-platform-nest —
|
|
11
|
+
// mapped here as a generic shape because the platform types package does not
|
|
12
|
+
// expose the snapshot (it lives in saas-platform-nest/entitlement).
|
|
13
|
+
|
|
14
|
+
import { ref, type Ref } from 'vue';
|
|
15
|
+
import { defaultHttpClient, type HttpClient } from './types.js';
|
|
16
|
+
|
|
17
|
+
export interface EntitlementSnapshotShape {
|
|
18
|
+
plan: string;
|
|
19
|
+
quotas: Record<string, number>;
|
|
20
|
+
features: string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface UseEntitlementOptions {
|
|
24
|
+
/**
|
|
25
|
+
* Fully-qualified entitlement endpoint including the app globalPrefix
|
|
26
|
+
* (`/api/billing/entitlement`, `/api/v1/billing/entitlement`, …). Mandatory.
|
|
27
|
+
*/
|
|
28
|
+
endpoint: string;
|
|
29
|
+
http?: HttpClient;
|
|
30
|
+
getAuthToken?: () => string | null;
|
|
31
|
+
/** Default `true`. */
|
|
32
|
+
autoLoad?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface UseEntitlementResult {
|
|
36
|
+
entitlement: Ref<EntitlementSnapshotShape | null>;
|
|
37
|
+
loading: Ref<boolean>;
|
|
38
|
+
error: Ref<Error | null>;
|
|
39
|
+
load: () => Promise<void>;
|
|
40
|
+
/** Convenience: checks whether a FeatureKey is in the set. */
|
|
41
|
+
hasFeature: (key: string) => boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function useEntitlement(options: UseEntitlementOptions): UseEntitlementResult {
|
|
45
|
+
if (!options?.endpoint) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
'useEntitlement: `endpoint` ist Pflicht (z. B. "/api/billing/entitlement" ' +
|
|
48
|
+
'oder "/api/v1/billing/entitlement"). Plattform hat keinen Default, weil ' +
|
|
49
|
+
'Apps unterschiedliche globalPrefix-Konventionen haben.',
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
const endpoint = options.endpoint;
|
|
53
|
+
const http = options.http ?? defaultHttpClient();
|
|
54
|
+
const entitlement = ref<EntitlementSnapshotShape | null>(null);
|
|
55
|
+
const loading = ref(false);
|
|
56
|
+
const error = ref<Error | null>(null);
|
|
57
|
+
|
|
58
|
+
async function load() {
|
|
59
|
+
loading.value = true;
|
|
60
|
+
error.value = null;
|
|
61
|
+
try {
|
|
62
|
+
const headers: Record<string, string> = {};
|
|
63
|
+
const token = options.getAuthToken?.();
|
|
64
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
65
|
+
const res = await http(endpoint, { method: 'GET', headers });
|
|
66
|
+
if (res.status !== 200) {
|
|
67
|
+
throw new Error(`Entitlement-Endpoint → HTTP ${res.status}`);
|
|
68
|
+
}
|
|
69
|
+
entitlement.value = (await res.json()) as EntitlementSnapshotShape;
|
|
70
|
+
} catch (err) {
|
|
71
|
+
error.value = err instanceof Error ? err : new Error(String(err));
|
|
72
|
+
entitlement.value = null;
|
|
73
|
+
} finally {
|
|
74
|
+
loading.value = false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function hasFeature(key: string): boolean {
|
|
79
|
+
return entitlement.value?.features.includes(key) ?? false;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (options.autoLoad !== false) {
|
|
83
|
+
Promise.resolve().then(() => void load());
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return { entitlement, loading, error, load, hasFeature };
|
|
87
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// useLivePlanVersions — loads, per plan stem, the currently "live" plan version
|
|
2
|
+
// (publishedAt != null && supersededAt == null && latest by validFrom) and
|
|
3
|
+
// builds a mapping `planKey → PlanVersionRow | null`. Consumed by the
|
|
4
|
+
// BundlesPage (Plan↔Bundle overlap check in the compat picker).
|
|
5
|
+
//
|
|
6
|
+
// Implementation: 1 + N HTTP requests (plan list + versions/ per plan).
|
|
7
|
+
// Acceptable for SuperAdmin setups with few plans (3-10); a backend
|
|
8
|
+
// optimization via `GET /admin/catalog/plans?include=liveVersion`
|
|
9
|
+
// can be added later, additively.
|
|
10
|
+
|
|
11
|
+
import { ref, watch, type Ref } from 'vue';
|
|
12
|
+
import type { PlanRow, PlanVersionRow } from '@saasicat/types';
|
|
13
|
+
import { defaultHttpClient, type HttpClient } from './types.js';
|
|
14
|
+
|
|
15
|
+
export interface UseLivePlanVersionsOptions {
|
|
16
|
+
adminEndpoint: string;
|
|
17
|
+
/** Reactive list of plan stems — the watcher reloads when the list changes. */
|
|
18
|
+
plans: Ref<PlanRow[]>;
|
|
19
|
+
http?: HttpClient;
|
|
20
|
+
getAuthToken?: () => string | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface UseLivePlanVersionsResult {
|
|
24
|
+
/** `planKey → live PlanVersion` (or null when the plan has no live version). */
|
|
25
|
+
livePlanVersions: Ref<Record<string, PlanVersionRow | null>>;
|
|
26
|
+
loading: Ref<boolean>;
|
|
27
|
+
error: Ref<Error | null>;
|
|
28
|
+
/** Forces a reload of all live versions. */
|
|
29
|
+
refresh: () => Promise<void>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function useLivePlanVersions(
|
|
33
|
+
options: UseLivePlanVersionsOptions,
|
|
34
|
+
): UseLivePlanVersionsResult {
|
|
35
|
+
if (!options?.adminEndpoint) {
|
|
36
|
+
throw new Error('useLivePlanVersions: `adminEndpoint` ist Pflicht.');
|
|
37
|
+
}
|
|
38
|
+
if (!options?.plans) {
|
|
39
|
+
throw new Error('useLivePlanVersions: `plans` ist Pflicht.');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const http = options.http ?? defaultHttpClient();
|
|
43
|
+
const livePlanVersions = ref<Record<string, PlanVersionRow | null>>({});
|
|
44
|
+
const loading = ref(false);
|
|
45
|
+
const error = ref<Error | null>(null);
|
|
46
|
+
|
|
47
|
+
function authHeaders(): Record<string, string> {
|
|
48
|
+
const token = options.getAuthToken?.();
|
|
49
|
+
return token ? { Authorization: `Bearer ${token}` } : {};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function loadForPlan(planId: string): Promise<PlanVersionRow | null> {
|
|
53
|
+
const res = await http(`${options.adminEndpoint}/catalog/plans/${planId}/versions`, {
|
|
54
|
+
method: 'GET',
|
|
55
|
+
headers: { 'content-type': 'application/json', ...authHeaders() },
|
|
56
|
+
});
|
|
57
|
+
if (res.status >= 400) {
|
|
58
|
+
throw new Error(`HTTP ${res.status} beim Laden der Versionen für Plan '${planId}'`);
|
|
59
|
+
}
|
|
60
|
+
const versions = (await res.json().catch(() => null)) as PlanVersionRow[] | null;
|
|
61
|
+
if (!versions || versions.length === 0) return null;
|
|
62
|
+
return findLatestLive(versions);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function refresh(): Promise<void> {
|
|
66
|
+
loading.value = true;
|
|
67
|
+
error.value = null;
|
|
68
|
+
try {
|
|
69
|
+
const entries = await Promise.all(
|
|
70
|
+
options.plans.value.map(async (plan) => {
|
|
71
|
+
try {
|
|
72
|
+
const live = await loadForPlan(plan.id);
|
|
73
|
+
return [plan.planKey, live] as const;
|
|
74
|
+
} catch (err) {
|
|
75
|
+
// Do not aggregate per-plan errors — the other plans
|
|
76
|
+
// should keep working; just set the mapping entry to null.
|
|
77
|
+
console.warn(
|
|
78
|
+
`useLivePlanVersions: Plan '${plan.planKey}' Versions-Load failed`,
|
|
79
|
+
err,
|
|
80
|
+
);
|
|
81
|
+
return [plan.planKey, null] as const;
|
|
82
|
+
}
|
|
83
|
+
}),
|
|
84
|
+
);
|
|
85
|
+
livePlanVersions.value = Object.fromEntries(entries);
|
|
86
|
+
} catch (err) {
|
|
87
|
+
error.value = err instanceof Error ? err : new Error(String(err));
|
|
88
|
+
} finally {
|
|
89
|
+
loading.value = false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Auto-refresh when the plan list changes (e.g. after creating a
|
|
94
|
+
// new plan). The first load happens on the first plan entry.
|
|
95
|
+
watch(
|
|
96
|
+
() => options.plans.value.map((p) => p.id).join(','),
|
|
97
|
+
() => {
|
|
98
|
+
if (options.plans.value.length > 0) void refresh();
|
|
99
|
+
else livePlanVersions.value = {};
|
|
100
|
+
},
|
|
101
|
+
{ immediate: true },
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
return { livePlanVersions, loading, error, refresh };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Returns the currently "live" PlanVersion from a versions list:
|
|
109
|
+
* - publishedAt != null
|
|
110
|
+
* - supersededAt == null
|
|
111
|
+
* - highest validFrom (= most recently activated)
|
|
112
|
+
*
|
|
113
|
+
* If no published version exists or all are superseded: null.
|
|
114
|
+
* Intentionally ignores validUntil — here the UI needs the "newest
|
|
115
|
+
* contract content" for overlap calculations, not the asOf-now check.
|
|
116
|
+
*/
|
|
117
|
+
function findLatestLive(versions: PlanVersionRow[]): PlanVersionRow | null {
|
|
118
|
+
const candidates = versions.filter((v) => v.publishedAt !== null && v.supersededAt === null);
|
|
119
|
+
if (candidates.length === 0) return null;
|
|
120
|
+
return [...candidates].sort((a, b) => {
|
|
121
|
+
const av = a.validFrom ?? '';
|
|
122
|
+
const bv = b.validFrom ?? '';
|
|
123
|
+
if (av === bv) return b.version - a.version;
|
|
124
|
+
return bv.localeCompare(av);
|
|
125
|
+
})[0];
|
|
126
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// useManifest — Vue 3 composable over ManifestLoader with ETag cache.
|
|
2
|
+
//
|
|
3
|
+
// Usage in the shell app after login:
|
|
4
|
+
//
|
|
5
|
+
// const { manifest, loading, error, load, reload } = useManifest({
|
|
6
|
+
// getAuthToken: () => authStore.token,
|
|
7
|
+
// });
|
|
8
|
+
// await load();
|
|
9
|
+
|
|
10
|
+
import { ref, type Ref } from 'vue';
|
|
11
|
+
import type { AdminManifest } from '@saasicat/types';
|
|
12
|
+
import { ManifestLoader, type ManifestLoaderOptions } from './manifest-loader.js';
|
|
13
|
+
|
|
14
|
+
export interface UseManifestResult {
|
|
15
|
+
manifest: Ref<AdminManifest | null>;
|
|
16
|
+
loading: Ref<boolean>;
|
|
17
|
+
error: Ref<Error | null>;
|
|
18
|
+
/** Loads once (or returns the cache); does not abort on re-call. */
|
|
19
|
+
load: () => Promise<void>;
|
|
20
|
+
/** Discards the cache and loads fresh (e.g. after manifest/reload). */
|
|
21
|
+
reload: () => Promise<void>;
|
|
22
|
+
/** Clears the cache — e.g. on logout. */
|
|
23
|
+
clearCache: () => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function useManifest(options: ManifestLoaderOptions): UseManifestResult {
|
|
27
|
+
const loader = new ManifestLoader(options);
|
|
28
|
+
const manifest = ref<AdminManifest | null>(null);
|
|
29
|
+
const loading = ref(false);
|
|
30
|
+
const error = ref<Error | null>(null);
|
|
31
|
+
|
|
32
|
+
async function load() {
|
|
33
|
+
loading.value = true;
|
|
34
|
+
error.value = null;
|
|
35
|
+
try {
|
|
36
|
+
manifest.value = await loader.load();
|
|
37
|
+
} catch (err) {
|
|
38
|
+
error.value = err instanceof Error ? err : new Error(String(err));
|
|
39
|
+
manifest.value = null;
|
|
40
|
+
} finally {
|
|
41
|
+
loading.value = false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function reload() {
|
|
46
|
+
loader.clearCache();
|
|
47
|
+
manifest.value = null;
|
|
48
|
+
await load();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function clearCache() {
|
|
52
|
+
loader.clearCache();
|
|
53
|
+
manifest.value = null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return { manifest, loading, error, load, reload, clearCache };
|
|
57
|
+
}
|