@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.
Files changed (193) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +47 -0
  3. package/dist/index.cjs +3776 -0
  4. package/dist/index.d.cts +2395 -0
  5. package/dist/index.d.ts +2395 -0
  6. package/dist/index.js +3659 -0
  7. package/dist/testing-e2e/admin-pages-suite.cjs +134 -0
  8. package/dist/testing-e2e/admin-pages-suite.d.cts +37 -0
  9. package/dist/testing-e2e/admin-pages-suite.d.ts +37 -0
  10. package/dist/testing-e2e/admin-pages-suite.js +109 -0
  11. package/package.json +87 -0
  12. package/src/action-registry.ts +110 -0
  13. package/src/batch-column-fetcher.ts +152 -0
  14. package/src/boot-loader.ts +60 -0
  15. package/src/components/BundleVersionPublishDialog.vue +359 -0
  16. package/src/components/BusinessTypeVersionEditorDialog.vue +417 -0
  17. package/src/components/BusinessTypeVersionPublishDialog.vue +258 -0
  18. package/src/components/FeatureGate.vue +56 -0
  19. package/src/components/KpiCard.vue +53 -0
  20. package/src/components/KvBlock.vue +36 -0
  21. package/src/components/MarketingPromotionsTab.vue +861 -0
  22. package/src/components/MfaPromptDialog.vue +104 -0
  23. package/src/components/TenantActionConfirmDialog.vue +252 -0
  24. package/src/components/VersionDiffPreview.vue +150 -0
  25. package/src/components/bundle-editor/BundleCreatePanel.vue +681 -0
  26. package/src/components/bundle-editor/BundleFeaturesEditor.vue +215 -0
  27. package/src/components/bundle-editor/BundlePlanCompatPicker.vue +316 -0
  28. package/src/components/bundle-editor/BundleQuotasEditor.vue +219 -0
  29. package/src/components/bundle-editor/BundleStatusBanner.vue +189 -0
  30. package/src/components/bundle-editor/BundleVersionInlineEditor.vue +659 -0
  31. package/src/components/bundle-editor/BundleVersionStrip.vue +207 -0
  32. package/src/components/bundle-editor/bundle-version-status.ts +164 -0
  33. package/src/components/bundle-editor/catalog-i18n.ts +85 -0
  34. package/src/components/dialogs/PilotCreateDialog.vue +780 -0
  35. package/src/components/dialogs/PilotEditDialog.vue +548 -0
  36. package/src/components/dialogs/PromoCodeCreateDialog.vue +737 -0
  37. package/src/components/dialogs/PromoCodeEditDialog.vue +859 -0
  38. package/src/components/dialogs/types.ts +126 -0
  39. package/src/components/plan/PlanCycleToggle.vue +99 -0
  40. package/src/components/plan/PlanGrid.vue +211 -0
  41. package/src/components/plan/PriceSummary.vue +293 -0
  42. package/src/components/plan/PromoCodeInput.vue +192 -0
  43. package/src/components/plan/PublicBundleGrid.vue +287 -0
  44. package/src/components/plan-cockpit/PlanCockpit.vue +993 -0
  45. package/src/components/plan-cockpit/PlanCockpitAuditLog.vue +65 -0
  46. package/src/components/plan-cockpit/PlanCockpitDiffPanel.vue +91 -0
  47. package/src/components/plan-cockpit/PlanCockpitHeader.vue +98 -0
  48. package/src/components/plan-cockpit/PlanCockpitImpactPanel.vue +99 -0
  49. package/src/components/plan-cockpit/PlanCockpitKpis.vue +57 -0
  50. package/src/components/plan-cockpit/PlanCockpitVersions.vue +226 -0
  51. package/src/components/plan-cockpit/types.ts +72 -0
  52. package/src/components/plan-create-dialog/PlanCreateDialog.vue +508 -0
  53. package/src/components/plan-detail/PlanAuditLog.vue +59 -0
  54. package/src/components/plan-detail/PlanDetail.vue +1195 -0
  55. package/src/components/plan-detail/PlanDetailHeader.vue +185 -0
  56. package/src/components/plan-detail/PlanDetailKpis.vue +57 -0
  57. package/src/components/plan-detail/PlanTerminateDialog.vue +76 -0
  58. package/src/components/plan-detail/PlanVersionDiffPanel.vue +149 -0
  59. package/src/components/plan-detail/PlanVersionsPanel.vue +255 -0
  60. package/src/components/plan-detail/types.ts +59 -0
  61. package/src/components/plan-list/PlanList.vue +1067 -0
  62. package/src/components/plan-matrix/PlanMatrix.vue +1113 -0
  63. package/src/components/plan-review/PlanReview.vue +742 -0
  64. package/src/components/plan-version-editor/PlanCatalogPreview.vue +184 -0
  65. package/src/components/plan-version-editor/PlanComponentPool.vue +272 -0
  66. package/src/components/plan-version-editor/PlanVersionBasket.vue +281 -0
  67. package/src/components/plan-version-editor/PlanVersionDiffDialog.vue +84 -0
  68. package/src/components/plan-version-editor/PlanVersionEditor.vue +1728 -0
  69. package/src/components/plan-version-editor/PlanVersionEditorHeader.vue +94 -0
  70. package/src/components/plan-version-editor/types.ts +96 -0
  71. package/src/components/wizard-stepper/WizardStepper.vue +107 -0
  72. package/src/create-admin-routes.ts +68 -0
  73. package/src/create-super-admin-app.ts +345 -0
  74. package/src/entitlement-provider.ts +36 -0
  75. package/src/feature-router-guard.ts +77 -0
  76. package/src/http-json.ts +62 -0
  77. package/src/index.ts +98 -0
  78. package/src/manifest-loader.ts +139 -0
  79. package/src/manifest-store-factory.ts +114 -0
  80. package/src/nav-builder.ts +274 -0
  81. package/src/pages-standard/AdminLayout.vue +411 -0
  82. package/src/pages-standard/AdminManifestErrorPage.vue +79 -0
  83. package/src/pages-standard/AuditPage.vue +223 -0
  84. package/src/pages-standard/BundlesPage.vue +910 -0
  85. package/src/pages-standard/BusinessTypesPage.vue +662 -0
  86. package/src/pages-standard/DashboardPage.vue +595 -0
  87. package/src/pages-standard/DiscoveryPage.vue +663 -0
  88. package/src/pages-standard/EmailHistoryPage.vue +576 -0
  89. package/src/pages-standard/MarketingCatalogPage.vue +1771 -0
  90. package/src/pages-standard/PilotsPage.vue +577 -0
  91. package/src/pages-standard/PlanVersionsPage.vue +216 -0
  92. package/src/pages-standard/PlansPage.vue +1222 -0
  93. package/src/pages-standard/PlatformEmailPage.vue +434 -0
  94. package/src/pages-standard/PromoCodeDetailPage.vue +331 -0
  95. package/src/pages-standard/PromoCodesPage.vue +589 -0
  96. package/src/pages-standard/SubscriptionsPage.vue +159 -0
  97. package/src/pages-standard/SuperAdminLoginPage.vue +299 -0
  98. package/src/pages-standard/SuperAdminSetupWizard.vue +405 -0
  99. package/src/pages-standard/TenantDetailPage.vue +432 -0
  100. package/src/pages-standard/TenantsPage.vue +732 -0
  101. package/src/pages-standard/UsersPage.vue +504 -0
  102. package/src/pages-standard/bundles-page/BundleAccordionList.vue +116 -0
  103. package/src/pages-standard/bundles-page/BundleDetailPanel.vue +193 -0
  104. package/src/pages-standard/bundles-page/BundlesFilterBar.vue +48 -0
  105. package/src/pages-standard/bundles-page/BundlesHeader.vue +67 -0
  106. package/src/pages-standard/bundles-page/BundlesKpis.vue +39 -0
  107. package/src/pages-standard/bundles-page/types.ts +15 -0
  108. package/src/pages-standard/discovery-page/CatalogEntryTransPanel.vue +246 -0
  109. package/src/pages-standard/discovery-page/DiscoveryCapList.vue +171 -0
  110. package/src/pages-standard/discovery-page/DiscoveryFeatureCard.vue +410 -0
  111. package/src/pages-standard/discovery-page/DiscoveryHeader.vue +31 -0
  112. package/src/pages-standard/discovery-page/DiscoveryKpis.vue +47 -0
  113. package/src/pages-standard/discovery-page/DiscoveryMetaBanner.vue +26 -0
  114. package/src/pages-standard/discovery-page/DiscoveryQuotaCard.vue +247 -0
  115. package/src/pages-standard/discovery-page/DiscoveryStatusControl.vue +73 -0
  116. package/src/pages-standard/discovery-page/discovery-ui.ts +166 -0
  117. package/src/pages-standard/email-history.types.ts +48 -0
  118. package/src/pages-standard/marketing-catalog/MarketingCatalogAdmin.vue +448 -0
  119. package/src/pages-standard/marketing-catalog/MarketingCatalogHeader.vue +93 -0
  120. package/src/pages-standard/marketing-catalog/MarketingCatalogPreview.vue +138 -0
  121. package/src/pages-standard/marketing-catalog/MarketingCatalogToolbar.vue +58 -0
  122. package/src/pages-standard/marketing-catalog/types.ts +43 -0
  123. package/src/pages-standard/plan-versions/PlanDiffCard.vue +132 -0
  124. package/src/pages-standard/plan-versions/PlanVersionHeader.vue +293 -0
  125. package/src/pages-standard/plan-versions/PlanVersionStatusBadge.vue +48 -0
  126. package/src/pages-standard/plan-versions/PlanVersionsAudit.vue +307 -0
  127. package/src/pages-standard/plan-versions/PlanVersionsDiff.vue +269 -0
  128. package/src/pages-standard/plan-versions/PlanVersionsKpi.vue +65 -0
  129. package/src/pages-standard/plan-versions/PlanVersionsList.vue +292 -0
  130. package/src/pages-standard/plan-versions/PlanVersionsMatrix.vue +261 -0
  131. package/src/pages-standard/plan-versions/PlanVersionsTimeline.vue +300 -0
  132. package/src/pages-standard/plan-versions/VersionDiffPreview.vue +167 -0
  133. package/src/pages-standard/plan-versions/format.ts +53 -0
  134. package/src/pages-standard/plan-versions/types.ts +33 -0
  135. package/src/pages-standard/plans-page/PlanArchiveDialog.vue +50 -0
  136. package/src/pages-standard/plans-page/PlanBundleOverview.vue +147 -0
  137. package/src/pages-standard/plans-page/PlanDiscardDraftDialog.vue +46 -0
  138. package/src/pages-standard/plans-page/PlanPublishDialog.vue +106 -0
  139. package/src/pages-standard/plans-page/PlansPageToast.vue +69 -0
  140. package/src/pages-standard/plans-page/types.ts +18 -0
  141. package/src/pages-standard/platform-email.types.ts +41 -0
  142. package/src/pages-standard/sa-theme.css +223 -0
  143. package/src/pages-standard/tenants/StatusPill.vue +62 -0
  144. package/src/pages-standard/tenants/format.ts +38 -0
  145. package/src/pages-tenant/LimitsRow.vue +67 -0
  146. package/src/pages-tenant/MySubscriptionBundlesPage.vue +604 -0
  147. package/src/pages-tenant/OnboardingConfigurator.vue +417 -0
  148. package/src/pages-tenant/PackageSnapshotPanel.vue +241 -0
  149. package/src/pages-tenant/PendingVersionBanner.vue +126 -0
  150. package/src/pages-tenant/PlanChangeWizard.vue +630 -0
  151. package/src/pages-tenant/TenantPlanSection.vue +729 -0
  152. package/src/pages-tenant/UsageBar.vue +107 -0
  153. package/src/pages-tenant/default-i18n.ts +280 -0
  154. package/src/pages-tenant/tenant-plan-section/BundlePreviewDialog.vue +304 -0
  155. package/src/pages-tenant/tenant-plan-section/TenantBundleStore.vue +323 -0
  156. package/src/pages-tenant/tenant-plan-section/TenantFeatureMatrix.vue +122 -0
  157. package/src/pages-tenant/tenant-plan-section/TenantPlanCardHeader.vue +67 -0
  158. package/src/pages-tenant/tenant-plan-section/TenantUsageGrid.vue +31 -0
  159. package/src/plan-versions-catalog.ts +368 -0
  160. package/src/platform-loaders.ts +68 -0
  161. package/src/project-page-host.ts +108 -0
  162. package/src/testing-e2e/admin-pages-suite.ts +187 -0
  163. package/src/types.ts +58 -0
  164. package/src/use-actions.ts +25 -0
  165. package/src/use-api-list.ts +151 -0
  166. package/src/use-audit-entries.ts +48 -0
  167. package/src/use-batch-columns.ts +59 -0
  168. package/src/use-bulk-publish.ts +150 -0
  169. package/src/use-bundle-versions-map.ts +112 -0
  170. package/src/use-bundles.ts +297 -0
  171. package/src/use-business-types.ts +269 -0
  172. package/src/use-catalog-entries.ts +243 -0
  173. package/src/use-discovery.ts +158 -0
  174. package/src/use-entitlement.ts +87 -0
  175. package/src/use-live-plan-versions.ts +126 -0
  176. package/src/use-manifest.ts +57 -0
  177. package/src/use-marketing-projections.ts +160 -0
  178. package/src/use-nav.ts +33 -0
  179. package/src/use-plan-editor.ts +162 -0
  180. package/src/use-plan-versions.ts +83 -0
  181. package/src/use-plans.ts +375 -0
  182. package/src/use-platform-tenant-actions.ts +286 -0
  183. package/src/use-promotions.ts +126 -0
  184. package/src/use-public-boot.ts +40 -0
  185. package/src/use-subscription-draft.ts +355 -0
  186. package/src/use-super-admin-context.ts +93 -0
  187. package/src/use-tenant-action-flow.ts +238 -0
  188. package/src/use-tenant-billing-catalog.ts +160 -0
  189. package/src/use-tenant-billing.ts +453 -0
  190. package/src/use-tenant-manifest.ts +94 -0
  191. package/src/use-tenant-subscription-bundles.ts +148 -0
  192. package/src/use-tenants.ts +61 -0
  193. package/src/version.ts +10 -0
@@ -0,0 +1,139 @@
1
+ // ManifestLoader — fetches the admin manifest endpoint with an ETag cache.
2
+ //
3
+ // **The endpoint is mandatory** and is supplied by the app consumer, because
4
+ // apps have different `globalPrefix` conventions (e.g. `api`
5
+ // or `api/v1`).
6
+ //
7
+ // Cache behavior:
8
+ // - First call: GET without If-None-Match → read body + ETag header,
9
+ // persist both in `KvStore` (key suffix `manifest:body` and
10
+ // `manifest:etag`).
11
+ // - Subsequent call: GET with `If-None-Match: <etag>` → on 304 the
12
+ // cached body is returned, no re-parse needed. On 200 the
13
+ // cache is overwritten.
14
+ //
15
+ // Spec: admin-api.openapi.yaml `GET /admin/manifest`.
16
+
17
+ import type { AdminManifest } from '@saasicat/types';
18
+ import { defaultHttpClient, defaultKvStore, type HttpClient, type KvStore } from './types.js';
19
+
20
+ export interface ManifestLoaderOptions {
21
+ /**
22
+ * Fully-qualified manifest endpoint incl. app globalPrefix
23
+ * (`/api/admin/manifest`, `/api/v1/admin/manifest`, …). Mandatory.
24
+ */
25
+ endpoint: string;
26
+ http?: HttpClient;
27
+ storage?: KvStore;
28
+ /**
29
+ * Storage key prefix — consumers with multiple apps under one domain
30
+ * set this to e.g. `'ma:'` or `'da:'`, so that the caches
31
+ * are separated.
32
+ */
33
+ storageKeyPrefix?: string;
34
+ /**
35
+ * Auth header for `Authorization: Bearer <token>`. Sent with every
36
+ * request. The consumer supplies a function that pulls the
37
+ * current token from the auth store.
38
+ */
39
+ getAuthToken?: () => string | null;
40
+ }
41
+
42
+ export class ManifestLoadError extends Error {
43
+ constructor(
44
+ public readonly status: number,
45
+ message: string,
46
+ ) {
47
+ super(message);
48
+ this.name = 'ManifestLoadError';
49
+ }
50
+ }
51
+
52
+ export interface CachedManifestEntry {
53
+ etag: string;
54
+ body: AdminManifest;
55
+ }
56
+
57
+ export class ManifestLoader {
58
+ private readonly endpoint: string;
59
+ private readonly http: HttpClient;
60
+ private readonly storage: KvStore;
61
+ private readonly bodyKey: string;
62
+ private readonly etagKey: string;
63
+ private readonly getAuthToken?: () => string | null;
64
+
65
+ constructor(options: ManifestLoaderOptions) {
66
+ if (!options?.endpoint) {
67
+ throw new Error(
68
+ 'ManifestLoader: `endpoint` ist Pflicht (z. B. "/api/admin/manifest" ' +
69
+ 'oder "/api/v1/admin/manifest"). Plattform hat keinen Default, ' +
70
+ 'weil Apps unterschiedliche globalPrefix-Konventionen haben.',
71
+ );
72
+ }
73
+ this.endpoint = options.endpoint;
74
+ this.http = options.http ?? defaultHttpClient();
75
+ this.storage = options.storage ?? defaultKvStore();
76
+ const prefix = options.storageKeyPrefix ?? '';
77
+ this.bodyKey = `${prefix}manifest:body`;
78
+ this.etagKey = `${prefix}manifest:etag`;
79
+ this.getAuthToken = options.getAuthToken;
80
+ }
81
+
82
+ /**
83
+ * Loads the current manifest. On a cache hit (304) the cached
84
+ * body is returned — otherwise the fresh server body.
85
+ */
86
+ async load(): Promise<AdminManifest> {
87
+ const cachedEtag = this.storage.get(this.etagKey);
88
+ const headers: Record<string, string> = {};
89
+ const token = this.getAuthToken?.();
90
+ if (token) headers.Authorization = `Bearer ${token}`;
91
+ if (cachedEtag) headers['If-None-Match'] = cachedEtag;
92
+
93
+ const res = await this.http(this.endpoint, { method: 'GET', headers });
94
+
95
+ if (res.status === 304) {
96
+ const cachedBody = this.readCachedBody();
97
+ if (!cachedBody) {
98
+ throw new ManifestLoadError(
99
+ 304,
100
+ 'Server lieferte 304, aber Cache-Body fehlt — bitte clearCache() + reload',
101
+ );
102
+ }
103
+ return cachedBody.body;
104
+ }
105
+
106
+ if (res.status !== 200) {
107
+ throw new ManifestLoadError(
108
+ res.status,
109
+ `Manifest-Endpunkt antwortete HTTP ${res.status}`,
110
+ );
111
+ }
112
+
113
+ const body = (await res.json()) as AdminManifest;
114
+ const etag = res.headers.get('ETag') ?? res.headers.get('etag');
115
+ if (etag) {
116
+ this.storage.set(this.etagKey, etag);
117
+ this.storage.set(this.bodyKey, JSON.stringify(body));
118
+ }
119
+ return body;
120
+ }
121
+
122
+ /** Reads the cached manifest body from storage; null if absent. */
123
+ readCachedBody(): CachedManifestEntry | null {
124
+ const etag = this.storage.get(this.etagKey);
125
+ const bodyStr = this.storage.get(this.bodyKey);
126
+ if (!etag || !bodyStr) return null;
127
+ try {
128
+ return { etag, body: JSON.parse(bodyStr) as AdminManifest };
129
+ } catch {
130
+ return null;
131
+ }
132
+ }
133
+
134
+ /** Clears the cache — e.g. on logout or after `manifest reload`. */
135
+ clearCache(): void {
136
+ this.storage.remove(this.etagKey);
137
+ this.storage.remove(this.bodyKey);
138
+ }
139
+ }
@@ -0,0 +1,114 @@
1
+ // createManifestStore — Pinia store factory that standardizes the
2
+ // `loaded`/`inflight`/`ensureLoaded()`/`reload()`/`clearCache()` boilerplate.
3
+ //
4
+ // Apps define their manifest store from this factory:
5
+ //
6
+ // import { createManifestStore } from '@saasicat/ui-vue';
7
+ // import { manifestLoader } from '@/services/platform-loaders';
8
+ // export const useManifestStore = createManifestStore({ loader: manifestLoader });
9
+ //
10
+ // This keeps the cache/inflight logic in a single place, and apps no
11
+ // longer duplicate identical Pinia setups.
12
+
13
+ import { defineStore, type StoreDefinition } from 'pinia';
14
+ import { ref, type Ref } from 'vue';
15
+ import type { AdminManifest } from '@saasicat/types';
16
+ import type { ManifestLoader } from './manifest-loader.js';
17
+
18
+ export interface ManifestStoreState {
19
+ manifest: Ref<AdminManifest | null>;
20
+ loading: Ref<boolean>;
21
+ error: Ref<Error | null>;
22
+ loaded: Ref<boolean>;
23
+ }
24
+
25
+ export interface ManifestStoreActions {
26
+ /**
27
+ * Loads once per session. Concurrent calls share the promise.
28
+ *
29
+ * **Behavior on loader error:** the store caches the error in
30
+ * `error.value` and resets `loaded = false`/`manifest = null`, and the
31
+ * promise REJECTS with the original error. Callers must either
32
+ * call `.catch(...)` (defensive behavior) or let the rejection
33
+ * propagate. The platform router guard
34
+ * (`createSuperAdminApp({ manifestGuard.errorRoute })`) provides a
35
+ * fail-closed path with a redirect to a dedicated error route.
36
+ */
37
+ ensureLoaded: () => Promise<void>;
38
+ /** Discards cache + state (logout path). */
39
+ clearCache: () => void;
40
+ /** Forces a server refresh (e.g. after `manifest reload`). */
41
+ reload: () => Promise<void>;
42
+ }
43
+
44
+ export interface CreateManifestStoreOptions {
45
+ /** Platform `ManifestLoader` instance (typically via `createPlatformLoaders()`). */
46
+ loader: ManifestLoader;
47
+ /** Pinia store ID. Default `admin-manifest`. */
48
+ id?: string;
49
+ }
50
+
51
+ export type ManifestStoreDefinition = StoreDefinition<
52
+ string,
53
+ ManifestStoreState,
54
+ Record<string, never>,
55
+ ManifestStoreActions
56
+ >;
57
+
58
+ /**
59
+ * Returns a `useStore` function for the manifest store. Apps call the
60
+ * factory once at module top level and export the result as
61
+ * `useManifestStore`.
62
+ */
63
+ export function createManifestStore(options: CreateManifestStoreOptions): ManifestStoreDefinition {
64
+ return defineStore(options.id ?? 'admin-manifest', () => {
65
+ const manifest = ref<AdminManifest | null>(null);
66
+ const loading = ref(false);
67
+ const error = ref<Error | null>(null);
68
+ const loaded = ref(false);
69
+ let inflight: Promise<void> | null = null;
70
+
71
+ async function load(): Promise<void> {
72
+ loading.value = true;
73
+ error.value = null;
74
+ try {
75
+ manifest.value = await options.loader.load();
76
+ loaded.value = true;
77
+ } catch (err) {
78
+ const wrapped = err instanceof Error ? err : new Error(String(err));
79
+ error.value = wrapped;
80
+ manifest.value = null;
81
+ loaded.value = false;
82
+ // Re-throw so callers (router guard, page setups) see the
83
+ // error and can decide (fallback route, notification,
84
+ // etc.). The state (`error.value`) is retained and can be
85
+ // displayed reactively by components.
86
+ throw wrapped;
87
+ } finally {
88
+ loading.value = false;
89
+ inflight = null;
90
+ }
91
+ }
92
+
93
+ function ensureLoaded(): Promise<void> {
94
+ if (loaded.value) return Promise.resolve();
95
+ if (inflight) return inflight;
96
+ inflight = load();
97
+ return inflight;
98
+ }
99
+
100
+ function clearCache(): void {
101
+ options.loader.clearCache();
102
+ manifest.value = null;
103
+ loaded.value = false;
104
+ inflight = null;
105
+ }
106
+
107
+ async function reload(): Promise<void> {
108
+ clearCache();
109
+ await ensureLoaded();
110
+ }
111
+
112
+ return { manifest, loading, error, loaded, ensureLoaded, clearCache, reload };
113
+ }) as ManifestStoreDefinition;
114
+ }
@@ -0,0 +1,274 @@
1
+ // NavBuilder — derives the sidebar structure and the router mount points from
2
+ // a loaded `AdminManifest`. Platform standard pages have known
3
+ // routes (`/admin/tenants`, `/admin/subscriptions`, …); consumer
4
+ // `projectPages` are resolved to Vue components via the static
5
+ // `extensions:` map.
6
+ //
7
+ // Capability filter: sidebar items are only delivered when the
8
+ // respective `requiredCapability` in the manifest is set to `true` (or is
9
+ // not required at all). The manifest is Discovery, not security — the server
10
+ // enforces the routes independently.
11
+
12
+ import type {
13
+ AdminManifest,
14
+ StandardPageDef,
15
+ StandardPageKey,
16
+ } from '@saasicat/types';
17
+
18
+ /**
19
+ * Default routes for the platform standard pages. Consumers may override this
20
+ * via the `standardPageRoutes` option (e.g. `/admin/users` →
21
+ * `/admin/team`).
22
+ */
23
+ export const DEFAULT_STANDARD_PAGE_ROUTES: Record<StandardPageKey, string> = {
24
+ dashboard: '/admin/dashboard',
25
+ tenants: '/admin/tenants',
26
+ subscriptions: '/admin/subscriptions',
27
+ promoCodes: '/admin/promo-codes',
28
+ plans: '/admin/plans',
29
+ planVersions: '/admin/plan-versions',
30
+ audit: '/admin/audit',
31
+ users: '/admin/users',
32
+ pilots: '/admin/pilots',
33
+ discovery: '/admin/discovery',
34
+ bundles: '/admin/bundles',
35
+ businessTypes: '/admin/business-types',
36
+ marketingCatalog: '/admin/marketing-catalog',
37
+ platformEmail: '/admin/platform-email',
38
+ platformEmailHistory: '/admin/platform-email-history',
39
+ };
40
+
41
+ export interface BuildRouteEntry {
42
+ /** Stable identifier — the key for standard pages, the `id` for project pages. */
43
+ id: string;
44
+ /** Route path (`/admin/...`). */
45
+ path: string;
46
+ /** Visible for the UI. */
47
+ label: string;
48
+ icon?: string;
49
+ /** Sort key for drawer groups. */
50
+ navSection?: string;
51
+ /** Lookup key in the `extensions:` map (project) or the platform standard map. */
52
+ componentKey: string;
53
+ /** Required capability or null. */
54
+ requiredCapability: string | null;
55
+ /** Platform standard page (`true`) or project page (`false`). */
56
+ isStandard: boolean;
57
+ /** Hint for anticipatory lazy loading. */
58
+ prefetchOnIdle?: boolean;
59
+ }
60
+
61
+ export interface NavBuilderOptions {
62
+ /**
63
+ * Optional: overrides the default routes for certain standard pages.
64
+ * Consumers set this if they want an alternative URL structure.
65
+ */
66
+ standardPageRoutes?: Partial<Record<StandardPageKey, string>>;
67
+ /**
68
+ * Default labels for standard pages — consumers may localize.
69
+ */
70
+ standardPageLabels?: Partial<Record<StandardPageKey, string>>;
71
+ /** Default icons for standard pages. */
72
+ standardPageIcons?: Partial<Record<StandardPageKey, string>>;
73
+ /** Default `navSection` for standard pages. */
74
+ standardPageNavSection?: Partial<Record<StandardPageKey, string>>;
75
+ /**
76
+ * Optional: set of known `componentKey`s from the shell's `extensions:`
77
+ * map. ProjectPages whose `componentKey` is not contained here
78
+ * are filtered out — they would otherwise appear in the sidebar but on
79
+ * click be redirected by the catch-all (silent dead link).
80
+ * Consumers without an `extensions:` map omit the field → as before.
81
+ */
82
+ availableExtensions?: Set<string>;
83
+ }
84
+
85
+ const DEFAULT_LABELS: Record<StandardPageKey, string> = {
86
+ dashboard: 'Dashboard',
87
+ tenants: 'Mandanten',
88
+ subscriptions: 'Abonnements',
89
+ promoCodes: 'Promo-Codes',
90
+ plans: 'Pläne & Versionen',
91
+ planVersions: 'Plan-Versionen',
92
+ audit: 'Audit-Log',
93
+ users: 'Benutzer',
94
+ pilots: 'Piloten',
95
+ discovery: 'Discovery',
96
+ bundles: 'Bundles',
97
+ businessTypes: 'Geschäftstypen',
98
+ marketingCatalog: 'Marketing-Catalog',
99
+ platformEmail: 'Plattform-E-Mail',
100
+ platformEmailHistory: 'E-Mail-Verlauf',
101
+ };
102
+
103
+ const DEFAULT_ICONS: Record<StandardPageKey, string> = {
104
+ dashboard: 'dashboard',
105
+ tenants: 'business',
106
+ subscriptions: 'card_membership',
107
+ promoCodes: 'local_activity',
108
+ plans: 'workspace_premium',
109
+ planVersions: 'history_edu',
110
+ audit: 'history',
111
+ users: 'people',
112
+ pilots: 'flight_takeoff',
113
+ discovery: 'travel_explore',
114
+ bundles: 'inventory_2',
115
+ businessTypes: 'category',
116
+ marketingCatalog: 'campaign',
117
+ platformEmail: 'mail',
118
+ platformEmailHistory: 'mark_email_read',
119
+ };
120
+
121
+ const DEFAULT_NAV_SECTIONS: Partial<Record<StandardPageKey, string>> = {
122
+ dashboard: 'Übersicht',
123
+ discovery: 'Produktkatalog',
124
+ businessTypes: 'Produktkatalog',
125
+ bundles: 'Produktkatalog',
126
+ plans: 'Produktkatalog',
127
+ planVersions: 'Produktkatalog',
128
+ marketingCatalog: 'Produktkatalog',
129
+ promoCodes: 'Produktkatalog',
130
+ tenants: 'Kunden',
131
+ subscriptions: 'Kunden',
132
+ users: 'Kunden',
133
+ pilots: 'Kunden',
134
+ audit: 'System',
135
+ platformEmail: 'System',
136
+ platformEmailHistory: 'System',
137
+ };
138
+
139
+ export const DEFAULT_SECTION_ORDER: readonly string[] = [
140
+ 'Übersicht',
141
+ 'Produktkatalog',
142
+ 'Kunden',
143
+ 'System',
144
+ ];
145
+
146
+ /**
147
+ * Returns the list of all routes defined by the current manifest —
148
+ * filtered to the capabilities that the logged-in user has.
149
+ *
150
+ * The consumer shell then builds its Vue router configuration and its
151
+ * sidebar drawer from it.
152
+ */
153
+ export function buildRoutes(
154
+ manifest: AdminManifest,
155
+ options: NavBuilderOptions = {},
156
+ ): BuildRouteEntry[] {
157
+ const routes: BuildRouteEntry[] = [];
158
+ const capabilities = manifest.capabilities ?? {};
159
+
160
+ // StandardPages
161
+ const standard = manifest.navigation?.standardPages ?? {};
162
+ const overrideRoutes = { ...DEFAULT_STANDARD_PAGE_ROUTES, ...options.standardPageRoutes };
163
+ const overrideLabels = { ...DEFAULT_LABELS, ...options.standardPageLabels };
164
+ const overrideIcons = { ...DEFAULT_ICONS, ...options.standardPageIcons };
165
+ const overrideNavSection = { ...DEFAULT_NAV_SECTIONS, ...options.standardPageNavSection };
166
+
167
+ for (const key of Object.keys(standard) as StandardPageKey[]) {
168
+ const def = standard[key] as StandardPageDef | undefined;
169
+ if (!def?.enabled) continue;
170
+ const cap = def.requiredCapability ?? null;
171
+ if (cap && capabilities[cap] !== true) continue;
172
+ routes.push({
173
+ id: key,
174
+ path: overrideRoutes[key],
175
+ label: overrideLabels[key],
176
+ icon: overrideIcons[key],
177
+ navSection: overrideNavSection[key],
178
+ componentKey: `platform-${key}`,
179
+ requiredCapability: cap,
180
+ isStandard: true,
181
+ });
182
+ }
183
+
184
+ // ProjectPages
185
+ const projectPages = manifest.navigation?.projectPages ?? [];
186
+ const exts = options.availableExtensions;
187
+ for (const p of projectPages) {
188
+ const cap = p.requiredCapability ?? null;
189
+ if (cap && capabilities[cap] !== true) continue;
190
+ if (exts && !exts.has(p.componentKey)) continue;
191
+ routes.push({
192
+ id: p.id,
193
+ path: p.route,
194
+ label: p.label,
195
+ icon: p.icon,
196
+ navSection: p.navSection,
197
+ componentKey: p.componentKey,
198
+ requiredCapability: cap,
199
+ isStandard: false,
200
+ prefetchOnIdle: p.prefetchOnIdle,
201
+ });
202
+ }
203
+
204
+ return routes;
205
+ }
206
+
207
+ export interface SidebarItem {
208
+ id: string;
209
+ path: string;
210
+ label: string;
211
+ icon?: string;
212
+ }
213
+
214
+ export interface SidebarSection {
215
+ /** `null` for the default section (items without `navSection`). */
216
+ section: string | null;
217
+ items: SidebarItem[];
218
+ }
219
+
220
+ /**
221
+ * Groups the routes by `navSection` for the drawer.
222
+ *
223
+ * Section order:
224
+ * 1. Default section (`null`) — items without `navSection`.
225
+ * 2. Sections from `sectionOrder` in exactly this order.
226
+ * 3. Remaining sections alphabetically.
227
+ *
228
+ * The default order matches the plan simulation layout (Übersicht →
229
+ * Produktkatalog → Kunden → System); consumers can override it.
230
+ */
231
+ export function buildSidebar(
232
+ routes: BuildRouteEntry[],
233
+ sectionOrder: readonly string[] = DEFAULT_SECTION_ORDER,
234
+ ): SidebarSection[] {
235
+ const bySection = new Map<string | null, SidebarItem[]>();
236
+ for (const r of routes) {
237
+ const section = r.navSection ?? null;
238
+ const list = bySection.get(section) ?? [];
239
+ list.push({ id: r.id, path: r.path, label: r.label, icon: r.icon });
240
+ bySection.set(section, list);
241
+ }
242
+ const sections: SidebarSection[] = [];
243
+ if (bySection.has(null)) {
244
+ sections.push({ section: null, items: bySection.get(null)! });
245
+ }
246
+ const ordered = new Set<string>();
247
+ for (const name of sectionOrder) {
248
+ if (bySection.has(name)) {
249
+ sections.push({ section: name, items: bySection.get(name)! });
250
+ ordered.add(name);
251
+ }
252
+ }
253
+ const remaining = [...bySection.entries()]
254
+ .filter(([s]) => s !== null && !ordered.has(s as string))
255
+ .sort((a, b) => (a[0] as string).localeCompare(b[0] as string));
256
+ for (const [section, items] of remaining) {
257
+ sections.push({ section: section as string, items });
258
+ }
259
+ return sections;
260
+ }
261
+
262
+ /**
263
+ * Returns the Vue component registered for the given `componentKey`.
264
+ * The consumer shell calls this function with its own `extensions:` map.
265
+ * For unknown keys → `null` (the UI then renders a fallback
266
+ * component, e.g. "Component not found in shell build").
267
+ *
268
+ * `extensions` is `Record<string, T>` — `T` is typically a
269
+ * Vue component (either imported directly or as a
270
+ * `defineAsyncComponent` wrapper).
271
+ */
272
+ export function resolveExtension<T>(componentKey: string, extensions: Record<string, T>): T | null {
273
+ return extensions[componentKey] ?? null;
274
+ }