@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,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
|
+
}
|