@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,238 @@
1
+ // useTenantActionFlow — turns `manifest.tenants.actions[]` into an
2
+ // invoke-capable list that orchestrates the confirm flow + MFA flow +
3
+ // handler dispatch in a single call.
4
+ //
5
+ // Before: every app duplicated the flow locally in its `TenantsPage.vue`
6
+ // (confirm dialog → MFA dialog → fetch). Drift between the manifest spec
7
+ // (`requiresMfa: true`) and the actual UI flow was unavoidable.
8
+ //
9
+ // Now: the manifest declares the action with `requiresMfa` and `confirmType`,
10
+ // the composable orchestrates the order, and the app only supplies the
11
+ // providers (Quasar dialog) and the handler (HTTP call) — both via
12
+ // `createSuperAdminApp({ actions: { [actionKey]: handler } })`.
13
+
14
+ import { computed, type ComputedRef, type Ref } from 'vue';
15
+ import type { AdminManifest, TenantActionDef, TenantDto } from '@saasicat/types';
16
+ import { ActionRegistry } from './action-registry.js';
17
+ import { useSuperAdminActions } from './use-super-admin-context.js';
18
+
19
+ /**
20
+ * Input the app-side handler receives. `mfaCode` is populated when
21
+ * `def.requiresMfa === true` AND the provider supplies a code.
22
+ * `reason` comes from the confirm provider (typed-slug / typed-production
23
+ * typically confirm a slug entry as a safety check).
24
+ */
25
+ export interface TenantActionInput<TRow extends TenantDto = TenantDto> {
26
+ row: TRow;
27
+ mfaCode: string | null;
28
+ reason: string | null;
29
+ /**
30
+ * App-specific extra inputs that the confirm provider captures and
31
+ * passes through to the handler (e.g. `until` for `pilots.extend` with
32
+ * `confirmType: 'date'`). If the confirm provider supplies no `extras`,
33
+ * this is an empty object.
34
+ */
35
+ extras: Record<string, unknown>;
36
+ }
37
+
38
+ /** Provider contract for UI dialogs — apps supply the Quasar/headless implementation. */
39
+ export interface TenantActionFlowProviders<TRow extends TenantDto = TenantDto> {
40
+ /**
41
+ * App-specific confirm UI. Invoked when `def.confirmType !== 'none'`.
42
+ * Returns `{ ok: false }` when the user cancels. Optionally `extras`
43
+ * may be returned — that ends up in the handler's
44
+ * `TenantActionInput.extras` (e.g. `{ until: ISO }` for
45
+ * `confirmType: 'date'`).
46
+ */
47
+ confirm?: (
48
+ def: TenantActionDef,
49
+ ctx: { row: TRow },
50
+ ) => Promise<{
51
+ ok: boolean;
52
+ reason?: string | null;
53
+ extras?: Record<string, unknown>;
54
+ }>;
55
+ /**
56
+ * App-specific MFA UI. Invoked when `def.requiresMfa`. Returns `null`
57
+ * when the user cancels. Otherwise the TOTP code that must be attached
58
+ * to the backend via the `X-Mfa-Code` header.
59
+ */
60
+ mfa?: (def: TenantActionDef, ctx: { row: TRow }) => Promise<string | null>;
61
+ /** Optional: success/error notify (toast/snackbar). */
62
+ notify?: (kind: 'positive' | 'negative', message: string) => void;
63
+ /** Optional: hook after a successful dispatch (e.g. reload the list). */
64
+ onSuccess?: (def: TenantActionDef, ctx: { row: TRow }) => void;
65
+ /**
66
+ * Optional: row-specific visibility. Evaluated after the capability and
67
+ * handler checks — `false` hides the action for this row (e.g.
68
+ * `tenants.suspend` only for active tenants).
69
+ * Default: `true` (do not hide).
70
+ */
71
+ visibleForRow?: (def: TenantActionDef, row: TRow) => boolean;
72
+ }
73
+
74
+ export interface ResolvedTenantAction<TRow extends TenantDto = TenantDto> {
75
+ def: TenantActionDef;
76
+ /**
77
+ * Starts the flow: confirm → MFA → handler. Returns the handler result
78
+ * or `undefined` when the user cancelled the flow.
79
+ */
80
+ invoke: (row: TRow) => Promise<unknown>;
81
+ }
82
+
83
+ export interface UseTenantActionFlowResult<TRow extends TenantDto = TenantDto> {
84
+ registry: ComputedRef<ActionRegistry | null>;
85
+ /**
86
+ * Row-independent action list, filtered by **static** criteria:
87
+ * 1. Capability gate: `requiredCapability` must not be explicitly
88
+ * `false` in the manifest.
89
+ * 2. Handler gate: for every `actionKey` a handler must be registered
90
+ * in the `actions:` map (otherwise a ghost button).
91
+ *
92
+ * `visibleForRow` is NOT evaluated here — the list is the basis from
93
+ * which pages derive their action descriptors. Row-specific visibility
94
+ * only comes in via `actionsForRow(row)` or via the `condition()` of the
95
+ * platform page buttons.
96
+ *
97
+ * This makes sample-row-based UI constructions work again: a page can
98
+ * walk `availableActions.value` and prepare buttons without
99
+ * `tenants.reactivate` disappearing for `isActive: true` sample rows.
100
+ */
101
+ availableActions: ComputedRef<TenantActionDef[]>;
102
+ /**
103
+ * Returns the actions visible for `row`. In addition to the
104
+ * capability/handler checks (see `availableActions`), it runs the
105
+ * `visibleForRow` provider — i.e. everything that depends on the row
106
+ * (e.g. `tenants.suspend` only for active tenants).
107
+ */
108
+ actionsForRow: (row: TRow) => ResolvedTenantAction<TRow>[];
109
+ /** Drift diagnostics: declared actions without a registered handler. */
110
+ orphanedDefs: ComputedRef<string[]>;
111
+ }
112
+
113
+ /**
114
+ * Vue composable. Uses:
115
+ * 1. `useSuperAdminActions()` as the source of the handler map (the app
116
+ * supplies it via `createSuperAdminApp({ actions: { [actionKey]: handler } })`).
117
+ * 2. The provided manifest ref, to react to manifest reloads.
118
+ *
119
+ * Returns an `actionsForRow(row)` function that is rendered directly in the
120
+ * page inside `<button v-for="...">`. The `invoke` call orchestrates
121
+ * confirm + MFA + dispatch automatically.
122
+ */
123
+ export function useTenantActionFlow<TRow extends TenantDto = TenantDto>(
124
+ manifest: Ref<AdminManifest | null>,
125
+ providers: TenantActionFlowProviders<TRow> = {},
126
+ ): UseTenantActionFlowResult<TRow> {
127
+ const handlers = useSuperAdminActions();
128
+ const registry = computed<ActionRegistry | null>(() => {
129
+ if (!manifest.value) return null;
130
+ return new ActionRegistry(manifest.value, handlers);
131
+ });
132
+
133
+ const orphanedDefs = computed<string[]>(() =>
134
+ registry.value ? registry.value.listOrphanedDefs() : [],
135
+ );
136
+
137
+ const availableActions = computed<TenantActionDef[]>(() => {
138
+ const m = manifest.value;
139
+ const reg = registry.value;
140
+ if (!m || !reg) return [];
141
+ const defs = m.tenants?.actions ?? [];
142
+ const caps = m.capabilities ?? {};
143
+ const orphans = new Set(reg.listOrphanedDefs());
144
+ return defs.filter((def) => passesStaticGates(def, caps, orphans));
145
+ });
146
+
147
+ function actionsForRow(row: TRow): ResolvedTenantAction<TRow>[] {
148
+ const reg = registry.value;
149
+ if (!reg) return [];
150
+ return availableActions.value
151
+ .filter((def) => !providers.visibleForRow || providers.visibleForRow(def, row))
152
+ .map((def) => ({
153
+ def,
154
+ invoke: (r: TRow) => runFlow(def, r ?? row, reg, providers),
155
+ }));
156
+ }
157
+
158
+ return { registry, availableActions, actionsForRow, orphanedDefs };
159
+ }
160
+
161
+ function passesStaticGates(
162
+ def: TenantActionDef,
163
+ capabilities: Record<string, boolean>,
164
+ orphanedKeys: Set<string>,
165
+ ): boolean {
166
+ // Capability gate: apps can disable actions by setting the
167
+ // requiredCapability explicitly to `false` in the manifest. If the entry
168
+ // is missing (undefined), the action counts as available — the platform
169
+ // does not require every capability to be declared.
170
+ if (def.requiredCapability && capabilities[def.requiredCapability] === false) {
171
+ return false;
172
+ }
173
+ // Handler gate: without a registered handler a ghost button appears that
174
+ // only fails after confirm/MFA. We prefer to hide it immediately;
175
+ // `orphanedDefs` keeps the drift diagnosable.
176
+ if (orphanedKeys.has(def.actionKey)) {
177
+ return false;
178
+ }
179
+ return true;
180
+ }
181
+
182
+ async function runFlow<TRow extends TenantDto>(
183
+ def: TenantActionDef,
184
+ row: TRow,
185
+ registry: ActionRegistry,
186
+ providers: TenantActionFlowProviders<TRow>,
187
+ ): Promise<unknown> {
188
+ let reason: string | null = null;
189
+ let extras: Record<string, unknown> = {};
190
+
191
+ if (def.confirmType && def.confirmType !== 'none') {
192
+ if (!providers.confirm) {
193
+ throw new Error(
194
+ `useTenantActionFlow: Action "${def.id}" verlangt confirmType="${def.confirmType}", ` +
195
+ `aber kein \`confirm\`-Provider übergeben.`,
196
+ );
197
+ }
198
+ const c = await providers.confirm(def, { row });
199
+ if (!c.ok) return undefined;
200
+ reason = c.reason ?? null;
201
+ if (c.extras) extras = c.extras;
202
+ }
203
+
204
+ let mfaCode: string | null = null;
205
+ if (def.requiresMfa) {
206
+ if (!providers.mfa) {
207
+ throw new Error(
208
+ `useTenantActionFlow: Action "${def.id}" verlangt MFA, aber kein ` +
209
+ `\`mfa\`-Provider übergeben.`,
210
+ );
211
+ }
212
+ mfaCode = await providers.mfa(def, { row });
213
+ if (mfaCode === null) return undefined;
214
+ }
215
+
216
+ let resolved;
217
+ try {
218
+ resolved = registry.get(def.actionKey);
219
+ } catch (err) {
220
+ // MissingHandlerError or ActionDefNotInManifestError — app setup error.
221
+ providers.notify?.('negative', err instanceof Error ? err.message : String(err));
222
+ throw err;
223
+ }
224
+
225
+ try {
226
+ const input: TenantActionInput<TRow> = { row, mfaCode, reason, extras };
227
+ const result = await resolved.handler(input);
228
+ providers.notify?.('positive', `${def.label}: erfolgreich.`);
229
+ providers.onSuccess?.(def, { row });
230
+ return result;
231
+ } catch (err) {
232
+ const message =
233
+ (err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
234
+ (err instanceof Error ? err.message : String(err));
235
+ providers.notify?.('negative', message);
236
+ throw err;
237
+ }
238
+ }
@@ -0,0 +1,160 @@
1
+ // useTenantBillingCatalog — Vue composable that loads the public catalog
2
+ // endpoints from `@saasicat/nest/billing/PublicCatalogModule`:
3
+ // GET /billing/plans, /billing/feature-registry, /billing/bundles.
4
+ //
5
+ // Output shapes are frontend mirrors of the `PublicCatalogController` responses.
6
+ // They are explicitly duplicated here (no import from saas-platform-nest) so
7
+ // that the frontend bundle does not pull in the Nest server code.
8
+
9
+ import { ref, type Ref } from 'vue';
10
+ import type { FeatureUiRegistry } from '@saasicat/types';
11
+ import { defaultHttpClient, type HttpClient } from './types.js';
12
+ import { trimTrailingSlashes } from './http-json.js';
13
+
14
+ export interface CatalogPlan {
15
+ id: string;
16
+ name: string;
17
+ tagline: string;
18
+ monthlyNet: number | null;
19
+ yearlyNet: number | null;
20
+ popular: boolean;
21
+ quotas: Record<string, number>;
22
+ features: string[];
23
+ }
24
+
25
+ /**
26
+ * Bookable catalog bundle (wire form of `PublicBundleEntry` from
27
+ * `GET /billing/bundles`) — standalone catalog bundles
28
+ * (`bundle_versions`) with their own purchase flow `/billing/subscription-bundles`.
29
+ * Prices arrive as a decimal string on the wire and become `number` here.
30
+ */
31
+ export interface CatalogBundle {
32
+ bundleVersionId: string;
33
+ bundleKey: string;
34
+ label: string;
35
+ description: string | null;
36
+ features: string[];
37
+ quotas: Record<string, number>;
38
+ monthlyNet: number | null;
39
+ yearlyNet: number | null;
40
+ /**
41
+ * Uncovered feature dependencies (#35): union of the `requires` of the
42
+ * contained features minus those contained in the bundle itself. The UI
43
+ * grays out the bundle if these keys are neither in the plan nor in the
44
+ * active bundles. Empty without requires data (graceful).
45
+ */
46
+ requiresFeatures: string[];
47
+ /** Marketing price label (e.g. "from 19 €/month") — null = auto-format. */
48
+ priceTag: string | null;
49
+ }
50
+
51
+ interface PublicBundleWire {
52
+ bundleVersionId: string;
53
+ bundleKey: string;
54
+ label: string;
55
+ description: string | null;
56
+ features: string[];
57
+ quotas: Record<string, number>;
58
+ monthlyNet: string | null;
59
+ yearlyNet: string | null;
60
+ requiresFeatures?: string[];
61
+ marketing?: { priceTag?: string | null };
62
+ }
63
+
64
+ export interface UseTenantBillingCatalogOptions {
65
+ /**
66
+ * URL prefix before `/plans`, `/feature-registry`, `/bundles`.
67
+ * Default `'/billing'`. **Convention**: `apiPrefix` is the sub-path
68
+ * UNDER the app API base URL that the HTTP adapter itself holds.
69
+ * Example: HTTP adapter baseURL `/api` + apiPrefix `/billing` →
70
+ * `/api/billing/...`. Do NOT set `'/api/billing'` if the HTTP adapter
71
+ * already has `/api` as baseURL (→ `/api/api/...` 404).
72
+ */
73
+ apiPrefix?: string;
74
+ http?: HttpClient;
75
+ /**
76
+ * Default `true`. Set to `false` if the consumer wants to trigger
77
+ * `load()` itself (e.g. after login).
78
+ */
79
+ autoLoad?: boolean;
80
+ }
81
+
82
+ export interface UseTenantBillingCatalogResult {
83
+ plans: Ref<CatalogPlan[] | null>;
84
+ featureRegistry: Ref<FeatureUiRegistry | null>;
85
+ /**
86
+ * Bookable catalog bundles (`/billing/bundles`). `null` while not yet
87
+ * loaded; `[]` if the endpoint is missing/empty (non-fatal).
88
+ */
89
+ bundles: Ref<CatalogBundle[] | null>;
90
+ loading: Ref<boolean>;
91
+ error: Ref<Error | null>;
92
+ /** Loads plans/feature-registry in parallel + bundles (non-fatal). */
93
+ load: () => Promise<void>;
94
+ }
95
+
96
+ export function useTenantBillingCatalog(
97
+ options: UseTenantBillingCatalogOptions = {},
98
+ ): UseTenantBillingCatalogResult {
99
+ const apiPrefix = trimTrailingSlashes(options.apiPrefix ?? '/billing');
100
+ const http = options.http ?? defaultHttpClient();
101
+
102
+ const plans = ref<CatalogPlan[] | null>(null);
103
+ const featureRegistry = ref<FeatureUiRegistry | null>(null);
104
+ const bundles = ref<CatalogBundle[] | null>(null);
105
+ const loading = ref(false);
106
+ const error = ref<Error | null>(null);
107
+
108
+ async function fetchJson<T>(path: string): Promise<T> {
109
+ const res = await http(`${apiPrefix}${path}`, { method: 'GET' });
110
+ if (res.status !== 200) {
111
+ throw new Error(`${apiPrefix}${path} → HTTP ${res.status}`);
112
+ }
113
+ return (await res.json()) as T;
114
+ }
115
+
116
+ async function load() {
117
+ loading.value = true;
118
+ error.value = null;
119
+ try {
120
+ const [plansResult, registryResult] = await Promise.all([
121
+ fetchJson<CatalogPlan[]>('/plans'),
122
+ fetchJson<FeatureUiRegistry>('/feature-registry'),
123
+ ]);
124
+ plans.value = plansResult;
125
+ featureRegistry.value = registryResult;
126
+ } catch (err) {
127
+ error.value = err instanceof Error ? err : new Error(String(err));
128
+ plans.value = null;
129
+ featureRegistry.value = null;
130
+ } finally {
131
+ loading.value = false;
132
+ }
133
+ // Load the bundle catalog non-fatally afterwards — a missing `/bundles`
134
+ // endpoint (consumer without PublicCatalog bundle wiring) must not tip
135
+ // the plan page into an error state.
136
+ try {
137
+ const raw = await fetchJson<PublicBundleWire[]>('/bundles');
138
+ bundles.value = (raw ?? []).map((b) => ({
139
+ bundleVersionId: b.bundleVersionId,
140
+ bundleKey: b.bundleKey,
141
+ label: b.label,
142
+ description: b.description,
143
+ features: b.features ?? [],
144
+ quotas: b.quotas ?? {},
145
+ monthlyNet: b.monthlyNet !== null ? parseFloat(b.monthlyNet) : null,
146
+ yearlyNet: b.yearlyNet !== null ? parseFloat(b.yearlyNet) : null,
147
+ requiresFeatures: b.requiresFeatures ?? [],
148
+ priceTag: b.marketing?.priceTag ?? null,
149
+ }));
150
+ } catch {
151
+ bundles.value = [];
152
+ }
153
+ }
154
+
155
+ if (options.autoLoad !== false) {
156
+ Promise.resolve().then(() => void load());
157
+ }
158
+
159
+ return { plans, featureRegistry, bundles, loading, error, load };
160
+ }