@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,126 @@
|
|
|
1
|
+
// usePromotions — Vue 3 composable for SuperAdmin promotions management
|
|
2
|
+
// (backend: PromotionsController under /admin/catalog/promotions).
|
|
3
|
+
//
|
|
4
|
+
// **The admin endpoint prefix is mandatory** and is supplied by the consumer
|
|
5
|
+
// (e.g. `/api/admin` or `/api/v1/admin`).
|
|
6
|
+
|
|
7
|
+
import { ref, type Ref } from 'vue';
|
|
8
|
+
import type {
|
|
9
|
+
CreatePromotionData,
|
|
10
|
+
PromotionRow,
|
|
11
|
+
UpdatePromotionData,
|
|
12
|
+
} from '@saasicat/types';
|
|
13
|
+
import { defaultHttpClient, type HttpClient } from './types.js';
|
|
14
|
+
|
|
15
|
+
export interface UsePromotionsOptions {
|
|
16
|
+
adminEndpoint: string;
|
|
17
|
+
http?: HttpClient;
|
|
18
|
+
getAuthToken?: () => string | null;
|
|
19
|
+
projectKey: string;
|
|
20
|
+
autoLoad?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class PromotionsApiError extends Error {
|
|
24
|
+
constructor(
|
|
25
|
+
public readonly status: number,
|
|
26
|
+
public readonly body: unknown,
|
|
27
|
+
message: string,
|
|
28
|
+
) {
|
|
29
|
+
super(message);
|
|
30
|
+
this.name = 'PromotionsApiError';
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface UsePromotionsResult {
|
|
35
|
+
promotions: Ref<PromotionRow[]>;
|
|
36
|
+
loading: Ref<boolean>;
|
|
37
|
+
error: Ref<Error | null>;
|
|
38
|
+
load: () => Promise<void>;
|
|
39
|
+
create: (data: CreatePromotionData) => Promise<PromotionRow>;
|
|
40
|
+
update: (id: string, data: UpdatePromotionData) => Promise<PromotionRow>;
|
|
41
|
+
remove: (id: string) => Promise<void>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function usePromotions(options: UsePromotionsOptions): UsePromotionsResult {
|
|
45
|
+
if (!options?.adminEndpoint) {
|
|
46
|
+
throw new Error('usePromotions: `adminEndpoint` ist Pflicht.');
|
|
47
|
+
}
|
|
48
|
+
if (!options?.projectKey) {
|
|
49
|
+
throw new Error('usePromotions: `projectKey` ist Pflicht.');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const http = options.http ?? defaultHttpClient();
|
|
53
|
+
const promotions = ref<PromotionRow[]>([]);
|
|
54
|
+
const loading = ref(false);
|
|
55
|
+
const error = ref<Error | null>(null);
|
|
56
|
+
|
|
57
|
+
const baseUrl = `${options.adminEndpoint}/catalog/promotions`;
|
|
58
|
+
const pk = encodeURIComponent(options.projectKey);
|
|
59
|
+
|
|
60
|
+
function authHeaders(): Record<string, string> {
|
|
61
|
+
const token = options.getAuthToken?.();
|
|
62
|
+
return token ? { Authorization: `Bearer ${token}` } : {};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function fetchJson<T>(url: string, init?: Parameters<HttpClient>[1]): Promise<T | null> {
|
|
66
|
+
const res = await http(url, {
|
|
67
|
+
method: init?.method ?? 'GET',
|
|
68
|
+
headers: { 'content-type': 'application/json', ...authHeaders(), ...init?.headers },
|
|
69
|
+
body: init?.body,
|
|
70
|
+
});
|
|
71
|
+
if (res.status === 204) return null;
|
|
72
|
+
const body = await res.json().catch(() => null);
|
|
73
|
+
if (res.status >= 400) {
|
|
74
|
+
throw new PromotionsApiError(
|
|
75
|
+
res.status,
|
|
76
|
+
body,
|
|
77
|
+
`Promotions-API antwortete mit HTTP ${res.status}`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
return body as T;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function load(): Promise<void> {
|
|
84
|
+
loading.value = true;
|
|
85
|
+
error.value = null;
|
|
86
|
+
try {
|
|
87
|
+
const data = await fetchJson<PromotionRow[]>(`${baseUrl}?projectKey=${pk}`);
|
|
88
|
+
promotions.value = data ?? [];
|
|
89
|
+
} catch (err) {
|
|
90
|
+
error.value = err instanceof Error ? err : new Error(String(err));
|
|
91
|
+
} finally {
|
|
92
|
+
loading.value = false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function create(data: CreatePromotionData): Promise<PromotionRow> {
|
|
97
|
+
const created = await fetchJson<PromotionRow>(baseUrl, {
|
|
98
|
+
method: 'POST',
|
|
99
|
+
body: JSON.stringify(data),
|
|
100
|
+
});
|
|
101
|
+
if (!created) throw new PromotionsApiError(0, null, 'Create gab keinen Body zurück');
|
|
102
|
+
promotions.value = [...promotions.value, created];
|
|
103
|
+
return created;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function update(id: string, data: UpdatePromotionData): Promise<PromotionRow> {
|
|
107
|
+
const updated = await fetchJson<PromotionRow>(`${baseUrl}/${id}`, {
|
|
108
|
+
method: 'PATCH',
|
|
109
|
+
body: JSON.stringify(data),
|
|
110
|
+
});
|
|
111
|
+
if (!updated) throw new PromotionsApiError(0, null, 'Update gab keinen Body zurück');
|
|
112
|
+
promotions.value = promotions.value.map((p) => (p.id === id ? updated : p));
|
|
113
|
+
return updated;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function remove(id: string): Promise<void> {
|
|
117
|
+
await fetchJson<null>(`${baseUrl}/${id}`, { method: 'DELETE' });
|
|
118
|
+
promotions.value = promotions.value.filter((p) => p.id !== id);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (options.autoLoad) {
|
|
122
|
+
void load();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return { promotions, loading, error, load, create, update, remove };
|
|
126
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// usePublicBoot — Vue 3 composable over BootLoader.
|
|
2
|
+
//
|
|
3
|
+
// Usage in the login layout:
|
|
4
|
+
//
|
|
5
|
+
// const { boot, loading, error, load } = usePublicBoot();
|
|
6
|
+
// await load();
|
|
7
|
+
// // boot.value now contains project.{key,displayName,logoUrl}.
|
|
8
|
+
|
|
9
|
+
import { ref, type Ref } from 'vue';
|
|
10
|
+
import type { PublicBootResponse } from '@saasicat/types';
|
|
11
|
+
import { BootLoader, type BootLoaderOptions } from './boot-loader.js';
|
|
12
|
+
|
|
13
|
+
export interface UsePublicBootResult {
|
|
14
|
+
boot: Ref<PublicBootResponse | null>;
|
|
15
|
+
loading: Ref<boolean>;
|
|
16
|
+
error: Ref<Error | null>;
|
|
17
|
+
load: () => Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function usePublicBoot(options: BootLoaderOptions): UsePublicBootResult {
|
|
21
|
+
const loader = new BootLoader(options);
|
|
22
|
+
const boot = ref<PublicBootResponse | null>(null);
|
|
23
|
+
const loading = ref(false);
|
|
24
|
+
const error = ref<Error | null>(null);
|
|
25
|
+
|
|
26
|
+
async function load() {
|
|
27
|
+
loading.value = true;
|
|
28
|
+
error.value = null;
|
|
29
|
+
try {
|
|
30
|
+
boot.value = await loader.load();
|
|
31
|
+
} catch (err) {
|
|
32
|
+
error.value = err instanceof Error ? err : new Error(String(err));
|
|
33
|
+
boot.value = null;
|
|
34
|
+
} finally {
|
|
35
|
+
loading.value = false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return { boot, loading, error, load };
|
|
40
|
+
}
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
// useSubscriptionDraft — Vue composable for the reactive configuration state
|
|
2
|
+
// of the onboarding configurator (and the plan-change wizard).
|
|
3
|
+
//
|
|
4
|
+
// State: Plan + Cycle + selected catalog bundles + promo-code status.
|
|
5
|
+
// Output: derived prices per cycle, breakdown per block (Plan / Bundles),
|
|
6
|
+
// active feature set, API payload for
|
|
7
|
+
// `POST /billing/onboarding/initial-subscription`.
|
|
8
|
+
//
|
|
9
|
+
// Intentionally HTTP-free — the consumer wires in `previewPromo()` and
|
|
10
|
+
// `submit()` externally and feeds the result in via `setPromoState`.
|
|
11
|
+
// This keeps the composable testable without network mocks.
|
|
12
|
+
|
|
13
|
+
import { computed, ref, type ComputedRef, type Ref } from 'vue';
|
|
14
|
+
import type { CatalogPlan } from './use-tenant-billing-catalog.js';
|
|
15
|
+
import type { BillingCycleStr } from './use-tenant-billing.js';
|
|
16
|
+
import {
|
|
17
|
+
selectChargeableBundles,
|
|
18
|
+
type OnboardingSelectionRequest,
|
|
19
|
+
type PromoPreviewResponse,
|
|
20
|
+
type PromoPreviewValidResponse,
|
|
21
|
+
type PublicMarketingBundle,
|
|
22
|
+
} from '@saasicat/types';
|
|
23
|
+
|
|
24
|
+
// For yearly payment, the mockup applies 10x the monthly price (= 2 months free).
|
|
25
|
+
// If the catalog already provides a `yearlyNet`, that one is used;
|
|
26
|
+
// if it is missing, the composable falls back to `monthly * 10`.
|
|
27
|
+
export const DEFAULT_YEARLY_FACTOR = 10;
|
|
28
|
+
|
|
29
|
+
export type PromoStatus = 'idle' | 'checking' | 'valid' | 'invalid' | 'restricted';
|
|
30
|
+
|
|
31
|
+
export interface PromoState {
|
|
32
|
+
status: PromoStatus;
|
|
33
|
+
/** Backend response (only set when status === 'valid' or 'restricted'). */
|
|
34
|
+
preview: PromoPreviewResponse | null;
|
|
35
|
+
/** Display text for the UI (confirmation or error message). */
|
|
36
|
+
message: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface UseSubscriptionDraftOptions {
|
|
40
|
+
plans: Ref<CatalogPlan[] | null> | ComputedRef<CatalogPlan[] | null>;
|
|
41
|
+
subscriptionBundles?:
|
|
42
|
+
| Ref<PublicMarketingBundle[] | null>
|
|
43
|
+
| ComputedRef<PublicMarketingBundle[] | null>;
|
|
44
|
+
initialPlan?: string | null;
|
|
45
|
+
initialCycle?: BillingCycleStr;
|
|
46
|
+
initialBundleVersionIds?: ReadonlyArray<string>;
|
|
47
|
+
/** Override for the `yearlyNet` fallback if the catalog provides no yearlyNet. */
|
|
48
|
+
yearlyFactor?: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface PriceLineItem {
|
|
52
|
+
/** Stable key for UI `v-for`. */
|
|
53
|
+
key: string;
|
|
54
|
+
label: string;
|
|
55
|
+
/** Raw catalog value in the selected cycle unit. */
|
|
56
|
+
net: number;
|
|
57
|
+
/** Optional: additional text. */
|
|
58
|
+
sublabel?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface DraftPricing {
|
|
62
|
+
cycle: BillingCycleStr;
|
|
63
|
+
/** Plan base without bundles. */
|
|
64
|
+
planNet: number;
|
|
65
|
+
/** Sum of all selected catalog bundles. */
|
|
66
|
+
bundlesNet: number;
|
|
67
|
+
/** Plan + Bundles. */
|
|
68
|
+
subtotalNet: number;
|
|
69
|
+
/** Discount derived from the promo preview (on subtotalNet, not plan-only). */
|
|
70
|
+
discountNet: number;
|
|
71
|
+
/** subtotalNet - discountNet. */
|
|
72
|
+
totalNet: number;
|
|
73
|
+
/** Savings per year vs. monthly payment. */
|
|
74
|
+
yearSavings: number;
|
|
75
|
+
/** Structured breakdown for the sticky sidebar. */
|
|
76
|
+
breakdown: {
|
|
77
|
+
plan: PriceLineItem | null;
|
|
78
|
+
bundles: PriceLineItem[];
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface SubscriptionDraft {
|
|
83
|
+
// ─── State (writable) ─────────────────────────────────────────────
|
|
84
|
+
plan: Ref<string | null>;
|
|
85
|
+
cycle: Ref<BillingCycleStr>;
|
|
86
|
+
selectedBundleVersionIds: Ref<Set<string>>;
|
|
87
|
+
promoCode: Ref<string>;
|
|
88
|
+
promoState: Ref<PromoState>;
|
|
89
|
+
|
|
90
|
+
// ─── Derived ─────────────────────────────────────────────────────
|
|
91
|
+
selectedPlan: ComputedRef<CatalogPlan | null>;
|
|
92
|
+
/** Plan-included ∪ features of the selected catalog bundles. */
|
|
93
|
+
activeFeatures: ComputedRef<Set<string>>;
|
|
94
|
+
pricing: ComputedRef<DraftPricing>;
|
|
95
|
+
isDirty: ComputedRef<boolean>;
|
|
96
|
+
|
|
97
|
+
// ─── Mutators ────────────────────────────────────────────────────
|
|
98
|
+
setPlan(planId: string): void;
|
|
99
|
+
setCycle(c: BillingCycleStr): void;
|
|
100
|
+
toggleSubscriptionBundle(bundleVersionId: string): void;
|
|
101
|
+
setPromoCode(code: string): void;
|
|
102
|
+
setPromoState(state: PromoState): void;
|
|
103
|
+
clearPromo(): void;
|
|
104
|
+
|
|
105
|
+
// ─── Serialization ───────────────────────────────────────────────
|
|
106
|
+
toApiPayload(): OnboardingSelectionRequest;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function unwrap<T>(source: Ref<T> | ComputedRef<T> | T): T {
|
|
110
|
+
if (source && typeof source === 'object' && 'value' in source) {
|
|
111
|
+
return (source as Ref<T>).value;
|
|
112
|
+
}
|
|
113
|
+
return source as T;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function priceForCycle(
|
|
117
|
+
monthlyNet: number,
|
|
118
|
+
yearlyNet: number | null | undefined,
|
|
119
|
+
cycle: BillingCycleStr,
|
|
120
|
+
yearlyFactor: number,
|
|
121
|
+
): number {
|
|
122
|
+
if (cycle === 'YEARLY') {
|
|
123
|
+
return yearlyNet != null ? yearlyNet : monthlyNet * yearlyFactor;
|
|
124
|
+
}
|
|
125
|
+
return monthlyNet;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function useSubscriptionDraft(options: UseSubscriptionDraftOptions): SubscriptionDraft {
|
|
129
|
+
const yearlyFactor = options.yearlyFactor ?? DEFAULT_YEARLY_FACTOR;
|
|
130
|
+
|
|
131
|
+
const plansRef = computed<CatalogPlan[]>(() => unwrap(options.plans) ?? []);
|
|
132
|
+
const subscriptionBundlesRef = computed<PublicMarketingBundle[]>(
|
|
133
|
+
() => unwrap(options.subscriptionBundles ?? null) ?? [],
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
const plan = ref<string | null>(options.initialPlan ?? null);
|
|
137
|
+
const cycle = ref<BillingCycleStr>(options.initialCycle ?? 'MONTHLY');
|
|
138
|
+
const selectedBundleVersionIds = ref<Set<string>>(
|
|
139
|
+
new Set(options.initialBundleVersionIds ?? []),
|
|
140
|
+
);
|
|
141
|
+
const promoCode = ref('');
|
|
142
|
+
const promoState = ref<PromoState>({ status: 'idle', preview: null, message: '' });
|
|
143
|
+
|
|
144
|
+
const selectedPlan = computed(() => {
|
|
145
|
+
if (!plan.value) return null;
|
|
146
|
+
return plansRef.value.find((p) => p.id === plan.value) ?? null;
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const selectedBundles = computed<PublicMarketingBundle[]>(() =>
|
|
150
|
+
subscriptionBundlesRef.value.filter((b) =>
|
|
151
|
+
selectedBundleVersionIds.value.has(b.bundleVersionId),
|
|
152
|
+
),
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
// Selected bundles whose features are fully covered by Plan ∪ the other
|
|
156
|
+
// selected bundles would be sold twice. They flow into neither price nor
|
|
157
|
+
// payload — reactive, so that deselecting the covering bundle recomputes
|
|
158
|
+
// the redundant bundle again. Shared, iterative redundancy derivation
|
|
159
|
+
// (`selectChargeableBundles`): under mutual/cyclic coverage it deterministically
|
|
160
|
+
// keeps exactly one bundle instead of discarding all involved. Display
|
|
161
|
+
// order follows the catalog — the helper only determines WHICH bundles
|
|
162
|
+
// remain, not their sort order.
|
|
163
|
+
const chargeableBundles = computed<PublicMarketingBundle[]>(() => {
|
|
164
|
+
const planFeatures = selectedPlan.value?.features ?? [];
|
|
165
|
+
const selected = selectedBundles.value;
|
|
166
|
+
const keptIds = new Set(
|
|
167
|
+
selectChargeableBundles(planFeatures, selected).map((b) => b.bundleVersionId),
|
|
168
|
+
);
|
|
169
|
+
return selected.filter((b) => keptIds.has(b.bundleVersionId));
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const activeFeatures = computed<Set<string>>(() => {
|
|
173
|
+
const result = new Set<string>();
|
|
174
|
+
const planObj = selectedPlan.value;
|
|
175
|
+
if (planObj) {
|
|
176
|
+
for (const f of planObj.features) result.add(f);
|
|
177
|
+
}
|
|
178
|
+
for (const bundle of selectedBundles.value) {
|
|
179
|
+
for (const f of bundle.features) result.add(f);
|
|
180
|
+
}
|
|
181
|
+
return result;
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const pricing = computed<DraftPricing>(() => {
|
|
185
|
+
const cyc = cycle.value;
|
|
186
|
+
const planObj = selectedPlan.value;
|
|
187
|
+
|
|
188
|
+
// Plan
|
|
189
|
+
const planNet = planObj
|
|
190
|
+
? priceForCycle(planObj.monthlyNet ?? 0, planObj.yearlyNet, cyc, yearlyFactor)
|
|
191
|
+
: 0;
|
|
192
|
+
|
|
193
|
+
// Independently bookable catalog bundles from the public marketing catalog.
|
|
194
|
+
// Redundant (fully covered) bundles are excluded here.
|
|
195
|
+
const bundleItems: PriceLineItem[] = [];
|
|
196
|
+
let bundlesNet = 0;
|
|
197
|
+
for (const b of chargeableBundles.value) {
|
|
198
|
+
const net = priceForCycle(b.monthlyNet ?? 0, b.yearlyNet, cyc, yearlyFactor);
|
|
199
|
+
bundlesNet += net;
|
|
200
|
+
bundleItems.push({
|
|
201
|
+
key: `subscription-bundle:${b.bundleVersionId}`,
|
|
202
|
+
label: b.label,
|
|
203
|
+
net,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const subtotalNet = planNet + bundlesNet;
|
|
208
|
+
|
|
209
|
+
// Derive discount from the promo preview (PERCENT logic is mirrored on the
|
|
210
|
+
// frontend; ABSOLUTE is deducted absolutely). The backend recomputes the
|
|
211
|
+
// final truth at redeem time — the sidebar shows only the preview value.
|
|
212
|
+
let discountNet = 0;
|
|
213
|
+
const preview = promoState.value.preview;
|
|
214
|
+
if (preview && preview.valid && promoState.value.status === 'valid') {
|
|
215
|
+
const valid = preview as PromoPreviewValidResponse;
|
|
216
|
+
const value = Number(valid.discount.value);
|
|
217
|
+
if (Number.isFinite(value) && value > 0) {
|
|
218
|
+
if (valid.discount.valueType === 'PERCENT') {
|
|
219
|
+
discountNet = (subtotalNet * value) / 100;
|
|
220
|
+
} else {
|
|
221
|
+
discountNet = Math.min(value, subtotalNet);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const totalNet = Math.max(0, subtotalNet - discountNet);
|
|
227
|
+
|
|
228
|
+
// yearSavings: 12 × monthly — yearly. Only meaningful when the catalog
|
|
229
|
+
// knows both prices; otherwise 0.
|
|
230
|
+
let yearSavings = 0;
|
|
231
|
+
if (planObj) {
|
|
232
|
+
const monthlyTotal =
|
|
233
|
+
priceForCycle(planObj.monthlyNet ?? 0, planObj.yearlyNet, 'MONTHLY', yearlyFactor) *
|
|
234
|
+
12;
|
|
235
|
+
const yearlyTotal = priceForCycle(
|
|
236
|
+
planObj.monthlyNet ?? 0,
|
|
237
|
+
planObj.yearlyNet,
|
|
238
|
+
'YEARLY',
|
|
239
|
+
yearlyFactor,
|
|
240
|
+
);
|
|
241
|
+
yearSavings = Math.max(0, monthlyTotal - yearlyTotal);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
cycle: cyc,
|
|
246
|
+
planNet,
|
|
247
|
+
bundlesNet,
|
|
248
|
+
subtotalNet,
|
|
249
|
+
discountNet,
|
|
250
|
+
totalNet,
|
|
251
|
+
yearSavings,
|
|
252
|
+
breakdown: {
|
|
253
|
+
plan: planObj
|
|
254
|
+
? {
|
|
255
|
+
key: `plan:${planObj.id}`,
|
|
256
|
+
label: planObj.name,
|
|
257
|
+
net: planNet,
|
|
258
|
+
sublabel: `${planObj.features.length} Basis-Module`,
|
|
259
|
+
}
|
|
260
|
+
: null,
|
|
261
|
+
bundles: bundleItems,
|
|
262
|
+
},
|
|
263
|
+
};
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
const isDirty = computed(() => {
|
|
267
|
+
return selectedBundleVersionIds.value.size > 0 || promoState.value.status === 'valid';
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
function setPlan(planId: string): void {
|
|
271
|
+
if (plan.value === planId) return;
|
|
272
|
+
plan.value = planId;
|
|
273
|
+
// Reduce the bundle selection to those compatible with the new plan.
|
|
274
|
+
const compatibleBundleVersions = new Set<string>();
|
|
275
|
+
for (const bundle of subscriptionBundlesRef.value) {
|
|
276
|
+
if (
|
|
277
|
+
bundle.compatiblePlanKeys.length === 0 ||
|
|
278
|
+
bundle.compatiblePlanKeys.includes(planId)
|
|
279
|
+
) {
|
|
280
|
+
compatibleBundleVersions.add(bundle.bundleVersionId);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
selectedBundleVersionIds.value = new Set(
|
|
284
|
+
[...selectedBundleVersionIds.value].filter((id) => compatibleBundleVersions.has(id)),
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function setCycle(c: BillingCycleStr): void {
|
|
289
|
+
cycle.value = c;
|
|
290
|
+
// Promo restrictions may change — the caller re-runs preview if needed.
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function toggleSubscriptionBundle(bundleVersionId: string): void {
|
|
294
|
+
const next = new Set(selectedBundleVersionIds.value);
|
|
295
|
+
if (next.has(bundleVersionId)) next.delete(bundleVersionId);
|
|
296
|
+
else next.add(bundleVersionId);
|
|
297
|
+
selectedBundleVersionIds.value = next;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function setPromoCode(code: string): void {
|
|
301
|
+
promoCode.value = code.toUpperCase();
|
|
302
|
+
// Reset status — the caller re-validates via preview().
|
|
303
|
+
if (promoState.value.status !== 'idle') {
|
|
304
|
+
promoState.value = { status: 'idle', preview: null, message: '' };
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function setPromoState(state: PromoState): void {
|
|
309
|
+
promoState.value = state;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function clearPromo(): void {
|
|
313
|
+
promoCode.value = '';
|
|
314
|
+
promoState.value = { status: 'idle', preview: null, message: '' };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function toApiPayload(): OnboardingSelectionRequest {
|
|
318
|
+
if (!plan.value) {
|
|
319
|
+
throw new Error('useSubscriptionDraft: plan ist nicht gesetzt');
|
|
320
|
+
}
|
|
321
|
+
const payload: OnboardingSelectionRequest = {
|
|
322
|
+
plan: plan.value,
|
|
323
|
+
billingCycle: cycle.value,
|
|
324
|
+
};
|
|
325
|
+
if (promoState.value.status === 'valid' && promoCode.value) {
|
|
326
|
+
payload.promoCode = promoCode.value;
|
|
327
|
+
}
|
|
328
|
+
// Send only actually charged bundles — redundant (fully covered) ones
|
|
329
|
+
// would otherwise be booked twice.
|
|
330
|
+
const chargeableIds = chargeableBundles.value.map((b) => b.bundleVersionId);
|
|
331
|
+
if (chargeableIds.length > 0) {
|
|
332
|
+
payload.bundleVersionIds = chargeableIds;
|
|
333
|
+
}
|
|
334
|
+
return payload;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return {
|
|
338
|
+
plan,
|
|
339
|
+
cycle,
|
|
340
|
+
selectedBundleVersionIds,
|
|
341
|
+
promoCode,
|
|
342
|
+
promoState,
|
|
343
|
+
selectedPlan,
|
|
344
|
+
activeFeatures,
|
|
345
|
+
pricing,
|
|
346
|
+
isDirty,
|
|
347
|
+
setPlan,
|
|
348
|
+
setCycle,
|
|
349
|
+
toggleSubscriptionBundle,
|
|
350
|
+
setPromoCode,
|
|
351
|
+
setPromoState,
|
|
352
|
+
clearPromo,
|
|
353
|
+
toApiPayload,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Composables for the platform maps provided() via `createSuperAdminApp()`.
|
|
2
|
+
// Components inside the shell call these instead of direct inject() calls,
|
|
3
|
+
// so that type safety and default behavior stay centralized.
|
|
4
|
+
|
|
5
|
+
import { inject } from 'vue';
|
|
6
|
+
import type { AdminManifest } from '@saasicat/types';
|
|
7
|
+
import {
|
|
8
|
+
SUPER_ADMIN_ACTIONS_KEY,
|
|
9
|
+
SUPER_ADMIN_BRAND_KEY,
|
|
10
|
+
SUPER_ADMIN_ENDPOINTS_KEY,
|
|
11
|
+
SUPER_ADMIN_EXTENSIONS_KEY,
|
|
12
|
+
SUPER_ADMIN_HTTP_KEY,
|
|
13
|
+
SUPER_ADMIN_LOGIN_ADAPTER_KEY,
|
|
14
|
+
SUPER_ADMIN_MANIFEST_KEY,
|
|
15
|
+
type ActionsMap,
|
|
16
|
+
type ExtensionsMap,
|
|
17
|
+
type SuperAdminBrand,
|
|
18
|
+
type SuperAdminEndpoints,
|
|
19
|
+
type SuperAdminLoginAdapter,
|
|
20
|
+
} from './create-super-admin-app.js';
|
|
21
|
+
import { defaultHttpClient, type HttpClient } from './types.js';
|
|
22
|
+
|
|
23
|
+
/** Returns the `extensions:` map registered in `createSuperAdminApp()`. */
|
|
24
|
+
export function useSuperAdminExtensions(): ExtensionsMap {
|
|
25
|
+
return inject(SUPER_ADMIN_EXTENSIONS_KEY, {} as ExtensionsMap);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Returns the `actions:` map registered in `createSuperAdminApp()`. */
|
|
29
|
+
export function useSuperAdminActions(): ActionsMap {
|
|
30
|
+
return inject(SUPER_ADMIN_ACTIONS_KEY, {} as ActionsMap);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Returns the app branding. Throws when the component is rendered outside a
|
|
35
|
+
* `createSuperAdminApp()` shell — that is a setup bug, not a runtime
|
|
36
|
+
* problem.
|
|
37
|
+
*/
|
|
38
|
+
export function useSuperAdminBrand(): SuperAdminBrand {
|
|
39
|
+
const brand = inject(SUPER_ADMIN_BRAND_KEY);
|
|
40
|
+
if (!brand) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
'useSuperAdminBrand(): kein SuperAdmin-Branding im Inject-Scope. Wurde die Komponente innerhalb createSuperAdminApp() gemounted?',
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
return brand;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Returns the app endpoints (apiBase, publicBootEndpoint, manifestEndpoint). */
|
|
49
|
+
export function useSuperAdminEndpoints(): Required<SuperAdminEndpoints> {
|
|
50
|
+
const endpoints = inject(SUPER_ADMIN_ENDPOINTS_KEY);
|
|
51
|
+
if (!endpoints) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
'useSuperAdminEndpoints(): keine Endpoints im Inject-Scope. Wurde die Komponente innerhalb createSuperAdminApp() gemounted?',
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
return endpoints;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Returns the manifest accessor that was provided via
|
|
61
|
+
* `createSuperAdminApp({ manifestGuard: { getManifest } })`.
|
|
62
|
+
* Returns `null` if the app did not pass an accessor — components
|
|
63
|
+
* (e.g. `<ProjectPageHost>`) must handle the null case cleanly, because
|
|
64
|
+
* the accessor is optional.
|
|
65
|
+
*/
|
|
66
|
+
export function useSuperAdminManifest(): AdminManifest | null {
|
|
67
|
+
const accessor = inject(SUPER_ADMIN_MANIFEST_KEY, null);
|
|
68
|
+
return accessor ? accessor() : null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Returns the login adapter the app registered via
|
|
73
|
+
* `createSuperAdminApp({ loginAdapter })`. Throws when none was
|
|
74
|
+
* set — the shared `<SuperAdminLoginPage>` needs it.
|
|
75
|
+
*/
|
|
76
|
+
export function useSuperAdminLoginAdapter(): SuperAdminLoginAdapter {
|
|
77
|
+
const adapter = inject(SUPER_ADMIN_LOGIN_ADAPTER_KEY);
|
|
78
|
+
if (!adapter) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
'useSuperAdminLoginAdapter(): kein Login-Adapter registriert. Reiche ihn via createSuperAdminApp({ loginAdapter }) ein.',
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
return adapter;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Returns the pre-login `HttpClient` (boot, first-run setup) the app registered
|
|
88
|
+
* via `createSuperAdminApp({ http })`. Falls back to `defaultHttpClient()`
|
|
89
|
+
* if none was provided (e.g. in isolated tests).
|
|
90
|
+
*/
|
|
91
|
+
export function useSuperAdminHttp(): HttpClient {
|
|
92
|
+
return inject(SUPER_ADMIN_HTTP_KEY, defaultHttpClient());
|
|
93
|
+
}
|