@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,729 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="sp-plan-section">
|
|
3
|
+
<!-- Spinner ONLY on the initial load (#19): on refresh reloads (e.g. after
|
|
4
|
+
a plan change) the content stays mounted including the PlanChangeWizard,
|
|
5
|
+
otherwise the wizard would be unmounted mid-submit and would not
|
|
6
|
+
close. -->
|
|
7
|
+
<div v-if="loading && !usage" class="sp-plan-section__loading">
|
|
8
|
+
<q-spinner size="32px" />
|
|
9
|
+
<span>{{ effectiveI18n.loading }}</span>
|
|
10
|
+
</div>
|
|
11
|
+
|
|
12
|
+
<div v-else-if="error" class="sp-plan-section__error">
|
|
13
|
+
{{ error.message }}
|
|
14
|
+
</div>
|
|
15
|
+
|
|
16
|
+
<div v-else-if="!usage" class="sp-plan-section__empty">
|
|
17
|
+
{{ effectiveI18n.noSubscription }}
|
|
18
|
+
</div>
|
|
19
|
+
|
|
20
|
+
<template v-else>
|
|
21
|
+
<!-- Pending plan version banner -->
|
|
22
|
+
<PendingVersionBanner
|
|
23
|
+
v-if="usage.pendingPlanVersion"
|
|
24
|
+
:pending="usage.pendingPlanVersion"
|
|
25
|
+
:effective-at="usage.pendingPlanVersionEffectiveAt"
|
|
26
|
+
:accepted="usage.pendingPlanVersionAccepted"
|
|
27
|
+
:accepted-at="usage.pendingPlanVersionAcceptedAt"
|
|
28
|
+
:busy="acceptingPending"
|
|
29
|
+
:format-date="formatDate"
|
|
30
|
+
:i18n="{
|
|
31
|
+
title: effectiveI18n.pendingVersionTitle,
|
|
32
|
+
chipNonRegressive: effectiveI18n.pendingVersionChipNonRegressive,
|
|
33
|
+
chipRegressive: effectiveI18n.pendingVersionChipRegressive,
|
|
34
|
+
effectiveAt: effectiveI18n.pendingVersionEffectiveAt,
|
|
35
|
+
acceptAction: effectiveI18n.pendingVersionAcceptAction,
|
|
36
|
+
acceptInProgress: effectiveI18n.pendingVersionAcceptInProgress,
|
|
37
|
+
acceptedAt: effectiveI18n.pendingVersionAcceptedAt,
|
|
38
|
+
}"
|
|
39
|
+
class="q-mb-md"
|
|
40
|
+
@accept="onAcceptPending"
|
|
41
|
+
/>
|
|
42
|
+
|
|
43
|
+
<!-- Current plan card + actions -->
|
|
44
|
+
<q-card flat bordered class="sp-plan-section__card">
|
|
45
|
+
<TenantPlanCardHeader
|
|
46
|
+
:usage="usage"
|
|
47
|
+
:i18n="effectiveI18n"
|
|
48
|
+
:current-plan-name="currentPlanName"
|
|
49
|
+
:status-color="statusColor"
|
|
50
|
+
:status-label="statusLabel"
|
|
51
|
+
:cycle-label="cycleLabel"
|
|
52
|
+
:current-price-eur="currentPriceEur"
|
|
53
|
+
:current-price-unit="currentPriceUnit"
|
|
54
|
+
:next-billing-date="nextBillingDate"
|
|
55
|
+
:format-currency="formatCurrency"
|
|
56
|
+
:format-date="formatDate"
|
|
57
|
+
@change-plan="showWizard = true"
|
|
58
|
+
/>
|
|
59
|
+
|
|
60
|
+
<q-separator />
|
|
61
|
+
|
|
62
|
+
<!-- Usage -->
|
|
63
|
+
<TenantUsageGrid
|
|
64
|
+
:usage="usage"
|
|
65
|
+
:i18n="effectiveI18n"
|
|
66
|
+
:catalog-quota-keys="catalogQuotaKeys"
|
|
67
|
+
:quota-label="quotaLabel"
|
|
68
|
+
:is-fractional-quota="isFractionalQuota"
|
|
69
|
+
:usage-bar-formatter="usageBarFormatter"
|
|
70
|
+
/>
|
|
71
|
+
|
|
72
|
+
<template v-if="showFeatureMatrix && hasFeatureOverview">
|
|
73
|
+
<q-separator />
|
|
74
|
+
|
|
75
|
+
<!-- Feature scope (#18): all features included + locked -->
|
|
76
|
+
<TenantFeatureMatrix
|
|
77
|
+
:feature-registry="featureRegistry"
|
|
78
|
+
:active-features="activeFeatures"
|
|
79
|
+
:feature-label="featureLabelResolved"
|
|
80
|
+
:i18n="effectiveI18n"
|
|
81
|
+
/>
|
|
82
|
+
</template>
|
|
83
|
+
|
|
84
|
+
<q-separator v-if="showBundleStore && hasBundleStore" />
|
|
85
|
+
|
|
86
|
+
<!-- Bundle store (#15): booked + available bundles -->
|
|
87
|
+
<TenantBundleStore
|
|
88
|
+
v-if="showBundleStore && hasBundleStore"
|
|
89
|
+
:booked="bookedBundles"
|
|
90
|
+
:available="availableBundles"
|
|
91
|
+
:plan-features="activeFeatures"
|
|
92
|
+
:i18n="effectiveI18n"
|
|
93
|
+
:format-currency="formatCurrency"
|
|
94
|
+
:format-date="formatDate"
|
|
95
|
+
:feature-label="featureLabelResolved"
|
|
96
|
+
:buying-id="buyingBundleId"
|
|
97
|
+
:canceling-id="cancelingBundleId"
|
|
98
|
+
:reactivating-id="reactivatingBundleId"
|
|
99
|
+
:error="bundleError"
|
|
100
|
+
@buy="onBuyBundle"
|
|
101
|
+
@cancel="onCancelBundle"
|
|
102
|
+
@reactivate="onReactivateBundle"
|
|
103
|
+
/>
|
|
104
|
+
</q-card>
|
|
105
|
+
|
|
106
|
+
<!-- Bundle add/cancel preview (#37/#61): proration, redundancy,
|
|
107
|
+
requires blockers, minimum term — mutation only after confirm. -->
|
|
108
|
+
<BundlePreviewDialog
|
|
109
|
+
v-model="bundlePreviewOpen"
|
|
110
|
+
:preview="bundlePreview"
|
|
111
|
+
:loading="bundlePreviewLoading"
|
|
112
|
+
:error="bundlePreviewError"
|
|
113
|
+
:submitting="bundlePreviewSubmitting"
|
|
114
|
+
:subscription-status="usage.status"
|
|
115
|
+
:i18n="effectiveI18n"
|
|
116
|
+
:format-currency="formatCurrency"
|
|
117
|
+
:format-date="formatDate"
|
|
118
|
+
:feature-label="featureLabelResolved"
|
|
119
|
+
@confirm="onConfirmBundlePreview"
|
|
120
|
+
/>
|
|
121
|
+
|
|
122
|
+
<!-- Reactivate confirmation (analogous to cancellation): deliberate action -->
|
|
123
|
+
<q-dialog
|
|
124
|
+
:model-value="reactivateConfirmId !== null"
|
|
125
|
+
@update:model-value="(v) => { if (!v) closeReactivateConfirm(); }"
|
|
126
|
+
>
|
|
127
|
+
<q-card style="min-width: 360px; max-width: 480px">
|
|
128
|
+
<q-card-section>
|
|
129
|
+
<div class="text-h6">{{ effectiveI18n.bundleReactivateConfirmTitle }}</div>
|
|
130
|
+
</q-card-section>
|
|
131
|
+
<q-card-section class="q-pt-none">
|
|
132
|
+
{{ effectiveI18n.bundleReactivateConfirmBody }}
|
|
133
|
+
</q-card-section>
|
|
134
|
+
<q-card-actions align="right">
|
|
135
|
+
<q-btn
|
|
136
|
+
flat
|
|
137
|
+
:label="effectiveI18n.bundlePreviewClose"
|
|
138
|
+
:disable="reactivatingBundleId !== null"
|
|
139
|
+
@click="closeReactivateConfirm"
|
|
140
|
+
/>
|
|
141
|
+
<q-btn
|
|
142
|
+
color="primary"
|
|
143
|
+
unelevated
|
|
144
|
+
:label="effectiveI18n.bundleReactivateAction"
|
|
145
|
+
:loading="reactivatingBundleId !== null"
|
|
146
|
+
:disable="reactivatingBundleId !== null"
|
|
147
|
+
@click="confirmReactivateBundle"
|
|
148
|
+
/>
|
|
149
|
+
</q-card-actions>
|
|
150
|
+
</q-card>
|
|
151
|
+
</q-dialog>
|
|
152
|
+
|
|
153
|
+
<!-- P11.4: Frozen CheckoutOffer snapshot (read-only) — only
|
|
154
|
+
when the subscription originates from a website offer (#20).
|
|
155
|
+
Without a snapshot the "not via website offer" empty text
|
|
156
|
+
was just confusing; the current booking is shown above anyway. -->
|
|
157
|
+
<PackageSnapshotPanel
|
|
158
|
+
v-if="usage.packageSnapshot"
|
|
159
|
+
:snapshot="usage.packageSnapshot"
|
|
160
|
+
:checkout-offer-id="usage.checkoutOfferId"
|
|
161
|
+
:i18n="effectiveI18n"
|
|
162
|
+
:format-date="formatDate"
|
|
163
|
+
:format-currency="formatCurrency"
|
|
164
|
+
/>
|
|
165
|
+
|
|
166
|
+
<!-- Wizard -->
|
|
167
|
+
<PlanChangeWizard
|
|
168
|
+
v-model="showWizard"
|
|
169
|
+
:plans="bookablePlans"
|
|
170
|
+
:current-plan-id="usage.plan"
|
|
171
|
+
:current-plan-name="currentPlanName"
|
|
172
|
+
:current-cycle="usage.billingCycle"
|
|
173
|
+
:current-status="usage.status"
|
|
174
|
+
:trial-ends-at="usage.trialEndsAt"
|
|
175
|
+
:catalog-quota-keys="catalogQuotaKeys"
|
|
176
|
+
:format-currency="formatCurrency"
|
|
177
|
+
:format-date="formatDate"
|
|
178
|
+
:format-quota-label="formatQuotaLabel"
|
|
179
|
+
:format-quota-value="formatQuotaValue"
|
|
180
|
+
:quota-label="quotaLabel"
|
|
181
|
+
:feature-label="featureLabelResolved"
|
|
182
|
+
:is-fractional-quota="isFractionalQuota"
|
|
183
|
+
:preview-plan-change="previewPlanChange"
|
|
184
|
+
:change-plan="changePlan"
|
|
185
|
+
:i18n="wizardI18n"
|
|
186
|
+
@submitted="onWizardSubmitted"
|
|
187
|
+
/>
|
|
188
|
+
</template>
|
|
189
|
+
</div>
|
|
190
|
+
</template>
|
|
191
|
+
|
|
192
|
+
<script setup lang="ts">
|
|
193
|
+
import { computed, ref } from 'vue';
|
|
194
|
+
import PackageSnapshotPanel from './PackageSnapshotPanel.vue';
|
|
195
|
+
import PendingVersionBanner from './PendingVersionBanner.vue';
|
|
196
|
+
import PlanChangeWizard from './PlanChangeWizard.vue';
|
|
197
|
+
import { DEFAULT_I18N_DE, type TenantPlanSectionI18n } from './default-i18n.js';
|
|
198
|
+
import BundlePreviewDialog from './tenant-plan-section/BundlePreviewDialog.vue';
|
|
199
|
+
import TenantBundleStore from './tenant-plan-section/TenantBundleStore.vue';
|
|
200
|
+
import TenantFeatureMatrix from './tenant-plan-section/TenantFeatureMatrix.vue';
|
|
201
|
+
import TenantPlanCardHeader from './tenant-plan-section/TenantPlanCardHeader.vue';
|
|
202
|
+
import TenantUsageGrid from './tenant-plan-section/TenantUsageGrid.vue';
|
|
203
|
+
import {
|
|
204
|
+
useTenantBilling,
|
|
205
|
+
type BundlePreviewShape,
|
|
206
|
+
type SubscriptionBundleShape,
|
|
207
|
+
} from '../use-tenant-billing.js';
|
|
208
|
+
import {
|
|
209
|
+
useTenantBillingCatalog,
|
|
210
|
+
type CatalogBundle,
|
|
211
|
+
type CatalogPlan,
|
|
212
|
+
} from '../use-tenant-billing-catalog.js';
|
|
213
|
+
import type { HttpClient } from '../types.js';
|
|
214
|
+
|
|
215
|
+
// TenantPlanSection — main component for the tenant plan/bundle self-service
|
|
216
|
+
// UI. The consumer (app) embeds it in its settings page and passes through
|
|
217
|
+
// HTTP adapter + format hooks + i18n override.
|
|
218
|
+
|
|
219
|
+
interface Props {
|
|
220
|
+
/** App-specific HTTP adapter (axios with auth header etc.). */
|
|
221
|
+
http?: HttpClient;
|
|
222
|
+
/** App auth-token getter, in case the adapter does not inject it itself. */
|
|
223
|
+
getAuthToken?: () => string | null;
|
|
224
|
+
/** API prefix before `/billing/*`. Default `/api/billing`. */
|
|
225
|
+
apiPrefix?: string;
|
|
226
|
+
|
|
227
|
+
/** EUR/CHF/USD formatter. */
|
|
228
|
+
formatCurrency: (n: number) => string;
|
|
229
|
+
/** ISO date string → localized date. */
|
|
230
|
+
formatDate: (iso: string | Date) => string;
|
|
231
|
+
/** Freely selectable format for quota cards in the wizard. */
|
|
232
|
+
formatQuotaLabel?: (key: string, value: number) => string;
|
|
233
|
+
/**
|
|
234
|
+
* Plain value per quota key (e.g. "200" or "10 GB") — separate from the
|
|
235
|
+
* label so the wizard's `<PlanGrid>` can render value + label separately.
|
|
236
|
+
* Optional; without an override the wizard default applies
|
|
237
|
+
* (`value.toLocaleString('de-DE')` + `GB` for storage keys, ∞ for -1).
|
|
238
|
+
*/
|
|
239
|
+
formatQuotaValue?: (key: string, value: number) => string;
|
|
240
|
+
|
|
241
|
+
/** Localized label for a QuotaKey (e.g. 'users' → 'Benutzer'). */
|
|
242
|
+
quotaLabel?: (key: string) => string;
|
|
243
|
+
/** Localized label for a FeatureKey. */
|
|
244
|
+
featureLabel?: (key: string) => string;
|
|
245
|
+
/** Default: storage quotas (float), all others integer. */
|
|
246
|
+
isFractionalQuota?: (key: string) => boolean;
|
|
247
|
+
|
|
248
|
+
/** i18n overrides — missing keys fall back to DEFAULT_I18N_DE. */
|
|
249
|
+
i18n?: Partial<TenantPlanSectionI18n>;
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* #15 — Show the catalog bundle store (booked + available bundles) on
|
|
253
|
+
* this page. Default `false` (opt-in), so a consumer with its own
|
|
254
|
+
* bundle page does not get a duplicate bundle UI. Existing consumers
|
|
255
|
+
* set `true` (#16 adoption).
|
|
256
|
+
*/
|
|
257
|
+
showBundleStore?: boolean;
|
|
258
|
+
/**
|
|
259
|
+
* #18 — Show the full feature-scope matrix (all features included +
|
|
260
|
+
* locked). Default `false` (additive, opt-in per consumer).
|
|
261
|
+
*/
|
|
262
|
+
showFeatureMatrix?: boolean;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const props = defineProps<Props>();
|
|
266
|
+
|
|
267
|
+
const billing = useTenantBilling({
|
|
268
|
+
http: props.http,
|
|
269
|
+
getAuthToken: props.getAuthToken,
|
|
270
|
+
apiPrefix: props.apiPrefix,
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
const catalog = useTenantBillingCatalog({
|
|
274
|
+
http: props.http,
|
|
275
|
+
apiPrefix: props.apiPrefix,
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
const showWizard = ref(false);
|
|
279
|
+
const acceptingPending = ref(false);
|
|
280
|
+
|
|
281
|
+
// Bundle store state (#15)
|
|
282
|
+
const buyingBundleId = ref<string | null>(null);
|
|
283
|
+
const cancelingBundleId = ref<string | null>(null);
|
|
284
|
+
const reactivatingBundleId = ref<string | null>(null);
|
|
285
|
+
const reactivateConfirmId = ref<string | null>(null);
|
|
286
|
+
const bundleError = ref<string | null>(null);
|
|
287
|
+
|
|
288
|
+
// Bundle preview dialog state (#37/#61)
|
|
289
|
+
type PendingBundleAction =
|
|
290
|
+
| { kind: 'add'; bundleVersionId: string }
|
|
291
|
+
| { kind: 'cancel'; subscriptionBundleId: string };
|
|
292
|
+
const bundlePreviewOpen = ref(false);
|
|
293
|
+
const bundlePreview = ref<BundlePreviewShape | null>(null);
|
|
294
|
+
const bundlePreviewLoading = ref(false);
|
|
295
|
+
const bundlePreviewError = ref<string | null>(null);
|
|
296
|
+
const bundlePreviewSubmitting = ref(false);
|
|
297
|
+
const pendingBundleAction = ref<PendingBundleAction | null>(null);
|
|
298
|
+
|
|
299
|
+
const usage = computed(() => billing.usage.value);
|
|
300
|
+
const loading = computed(() => billing.loading.value || catalog.loading.value);
|
|
301
|
+
const error = computed(() => billing.error.value ?? catalog.error.value);
|
|
302
|
+
|
|
303
|
+
const effectiveI18n = computed<TenantPlanSectionI18n>(() => ({
|
|
304
|
+
...DEFAULT_I18N_DE,
|
|
305
|
+
...(props.i18n ?? {}),
|
|
306
|
+
}));
|
|
307
|
+
|
|
308
|
+
const catalogQuotaKeys = computed(() => {
|
|
309
|
+
// Ordered union over all plans + effective limits: higher tiers
|
|
310
|
+
// can carry quotas that the entry plan lacks — those must not
|
|
311
|
+
// disappear in PlanGrid/TenantUsageGrid.
|
|
312
|
+
const keys: string[] = [];
|
|
313
|
+
const push = (key: string) => {
|
|
314
|
+
if (!keys.includes(key)) keys.push(key);
|
|
315
|
+
};
|
|
316
|
+
for (const plan of catalog.plans.value ?? []) {
|
|
317
|
+
Object.keys(plan.quotas).forEach(push);
|
|
318
|
+
}
|
|
319
|
+
if (usage.value) Object.keys(usage.value.limits.quotas).forEach(push);
|
|
320
|
+
return keys;
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
const bookablePlans = computed<CatalogPlan[]>(() => catalog.plans.value ?? []);
|
|
324
|
+
|
|
325
|
+
// Bundle store (#15): available catalog bundles + booked bundles.
|
|
326
|
+
const availableBundles = computed<CatalogBundle[]>(() => catalog.bundles.value ?? []);
|
|
327
|
+
// A canceled bundle stays active until the end of the already-paid period
|
|
328
|
+
// (canceledEffectiveAt lies in the future) and is still shown under
|
|
329
|
+
// "Gebuchte Bundles" — the feature is paid for the period. Only once
|
|
330
|
+
// the cancellation takes effect (canceledEffectiveAt <= now) is the bundle
|
|
331
|
+
// no longer active and disappears from the list.
|
|
332
|
+
function isSubscriptionBundleActive(b: SubscriptionBundleShape): boolean {
|
|
333
|
+
if (b.canceledAt === null) return true;
|
|
334
|
+
return (
|
|
335
|
+
b.canceledEffectiveAt !== null &&
|
|
336
|
+
new Date(b.canceledEffectiveAt).getTime() > Date.now()
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const bookedBundles = computed<SubscriptionBundleShape[]>(() =>
|
|
341
|
+
billing.subscriptionBundles.value.filter(isSubscriptionBundleActive),
|
|
342
|
+
);
|
|
343
|
+
const hasBundleStore = computed(
|
|
344
|
+
() => availableBundles.value.length > 0 || bookedBundles.value.length > 0,
|
|
345
|
+
);
|
|
346
|
+
|
|
347
|
+
// Feature-scope matrix (#18): all features with registry translation.
|
|
348
|
+
const featureRegistry = computed(() => catalog.featureRegistry.value);
|
|
349
|
+
const activeFeatures = computed<string[]>(() => usage.value?.limits.features ?? []);
|
|
350
|
+
const hasFeatureOverview = computed(
|
|
351
|
+
() =>
|
|
352
|
+
Object.keys(featureRegistry.value ?? {}).length > 0 || activeFeatures.value.length > 0,
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
const currentPlanName = computed(() => {
|
|
356
|
+
if (!usage.value) return '';
|
|
357
|
+
const plan = catalog.plans.value?.find((p) => p.id === usage.value!.plan);
|
|
358
|
+
return plan?.name ?? usage.value.plan;
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
// Amount actually due per billing cycle: yearly price for YEARLY, monthly
|
|
362
|
+
// price for MONTHLY — NO /12 conversion (otherwise a yearly contract would
|
|
363
|
+
// show a monthly amount in the card).
|
|
364
|
+
const currentPriceEur = computed(() => {
|
|
365
|
+
if (!usage.value) return null;
|
|
366
|
+
const plan = catalog.plans.value?.find((p) => p.id === usage.value!.plan);
|
|
367
|
+
if (!plan) return null;
|
|
368
|
+
return usage.value.billingCycle === 'YEARLY' ? plan.yearlyNet : plan.monthlyNet;
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
const currentPriceUnit = computed(() =>
|
|
372
|
+
usage.value?.billingCycle === 'YEARLY'
|
|
373
|
+
? effectiveI18n.value.wizardPriceUnitYearly
|
|
374
|
+
: effectiveI18n.value.wizardPriceUnitMonthly,
|
|
375
|
+
);
|
|
376
|
+
|
|
377
|
+
// Next billing date = end of the current period ONLY for an actively
|
|
378
|
+
// renewing subscription (status ACTIVE). For PAST_DUE the period has
|
|
379
|
+
// already expired (no future billing date); for TRIAL (trial end is shown
|
|
380
|
+
// separately), CANCELED and PENDING_SALES there is no regular renewal →
|
|
381
|
+
// hide it, instead of wrongly presenting the period end as the next
|
|
382
|
+
// billing date.
|
|
383
|
+
const nextBillingDate = computed(() => {
|
|
384
|
+
const u = usage.value;
|
|
385
|
+
if (!u || !u.currentPeriodEnd) return null;
|
|
386
|
+
if (u.status !== 'ACTIVE') return null;
|
|
387
|
+
return u.currentPeriodEnd;
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
const cycleLabel = computed(() => {
|
|
391
|
+
if (!usage.value) return '';
|
|
392
|
+
return usage.value.billingCycle === 'YEARLY'
|
|
393
|
+
? effectiveI18n.value.cycleYearly
|
|
394
|
+
: effectiveI18n.value.cycleMonthly;
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
const statusLabel = computed(() => {
|
|
398
|
+
if (!usage.value) return '';
|
|
399
|
+
switch (usage.value.status) {
|
|
400
|
+
case 'TRIAL':
|
|
401
|
+
return effectiveI18n.value.statusTrial;
|
|
402
|
+
case 'PAST_DUE':
|
|
403
|
+
return effectiveI18n.value.statusPastDue;
|
|
404
|
+
case 'CANCELED':
|
|
405
|
+
return effectiveI18n.value.statusCanceled;
|
|
406
|
+
case 'PENDING_SALES':
|
|
407
|
+
return effectiveI18n.value.statusPendingSales;
|
|
408
|
+
default:
|
|
409
|
+
return effectiveI18n.value.statusActive;
|
|
410
|
+
}
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
const statusColor = computed(() => {
|
|
414
|
+
if (!usage.value) return 'grey';
|
|
415
|
+
switch (usage.value.status) {
|
|
416
|
+
case 'TRIAL':
|
|
417
|
+
return 'info';
|
|
418
|
+
case 'PAST_DUE':
|
|
419
|
+
return 'warning';
|
|
420
|
+
case 'CANCELED':
|
|
421
|
+
return 'negative';
|
|
422
|
+
case 'PENDING_SALES':
|
|
423
|
+
return 'amber';
|
|
424
|
+
default:
|
|
425
|
+
return 'positive';
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
const wizardI18n = computed(() => ({
|
|
430
|
+
title: effectiveI18n.value.wizardTitle,
|
|
431
|
+
close: effectiveI18n.value.wizardClose,
|
|
432
|
+
currentLabel: effectiveI18n.value.wizardCurrent,
|
|
433
|
+
cycleMonthly: effectiveI18n.value.cycleMonthly,
|
|
434
|
+
cycleYearly: effectiveI18n.value.cycleYearly,
|
|
435
|
+
badgeCurrent: effectiveI18n.value.wizardBadgeCurrent,
|
|
436
|
+
badgePopular: effectiveI18n.value.wizardBadgePopular,
|
|
437
|
+
priceUnitMonthly: effectiveI18n.value.wizardPriceUnitMonthly,
|
|
438
|
+
priceOnRequest: effectiveI18n.value.wizardPriceOnRequest,
|
|
439
|
+
stepChoose: effectiveI18n.value.wizardStepChoose,
|
|
440
|
+
stepChooseIntro: effectiveI18n.value.wizardStepChooseIntro,
|
|
441
|
+
stepPreview: effectiveI18n.value.wizardStepPreview,
|
|
442
|
+
stepConfirm: effectiveI18n.value.wizardStepConfirm,
|
|
443
|
+
next: effectiveI18n.value.wizardNext,
|
|
444
|
+
back: effectiveI18n.value.wizardBack,
|
|
445
|
+
previewLoading: effectiveI18n.value.wizardPreviewLoading,
|
|
446
|
+
effectiveAtLabel: effectiveI18n.value.wizardEffectiveAtLabel,
|
|
447
|
+
effectiveImmediate: effectiveI18n.value.wizardEffectiveImmediate,
|
|
448
|
+
prorationTitle: effectiveI18n.value.wizardProrationTitle,
|
|
449
|
+
prorationLine: effectiveI18n.value.wizardProrationLine,
|
|
450
|
+
prorationDays: effectiveI18n.value.wizardProrationDays,
|
|
451
|
+
limitsTitle: effectiveI18n.value.wizardLimitsTitle,
|
|
452
|
+
limitsUsed: effectiveI18n.value.wizardLimitsUsed,
|
|
453
|
+
limitsCurrent: effectiveI18n.value.wizardLimitsCurrent,
|
|
454
|
+
limitsTarget: effectiveI18n.value.wizardLimitsTarget,
|
|
455
|
+
featuresGained: effectiveI18n.value.wizardFeaturesGained,
|
|
456
|
+
featuresLost: effectiveI18n.value.wizardFeaturesLost,
|
|
457
|
+
blockersTitle: effectiveI18n.value.wizardBlockersTitle,
|
|
458
|
+
confirmImmediate: effectiveI18n.value.wizardConfirmImmediate,
|
|
459
|
+
confirmScheduled: effectiveI18n.value.wizardConfirmScheduled,
|
|
460
|
+
confirmAction: effectiveI18n.value.wizardConfirmAction,
|
|
461
|
+
confirmInProgress: effectiveI18n.value.wizardConfirmInProgress,
|
|
462
|
+
confirmPriceTitle: effectiveI18n.value.wizardConfirmPriceTitle,
|
|
463
|
+
confirmProratedNow: effectiveI18n.value.wizardConfirmProratedNow,
|
|
464
|
+
confirmRecurringNext: effectiveI18n.value.wizardConfirmRecurringNext,
|
|
465
|
+
confirmRecurringFrom: effectiveI18n.value.wizardConfirmRecurringFrom,
|
|
466
|
+
perCycleMonthly: effectiveI18n.value.wizardConfirmPerCycleMonthly,
|
|
467
|
+
perCycleYearly: effectiveI18n.value.wizardConfirmPerCycleYearly,
|
|
468
|
+
confirmTrialNote: effectiveI18n.value.wizardConfirmTrialNote,
|
|
469
|
+
confirmRecurringTrialEnd: effectiveI18n.value.wizardConfirmRecurringTrialEnd,
|
|
470
|
+
changeTypeUpgrade: effectiveI18n.value.wizardChangeTypeUpgrade,
|
|
471
|
+
changeTypeDowngrade: effectiveI18n.value.wizardChangeTypeDowngrade,
|
|
472
|
+
changeTypeCycle: effectiveI18n.value.wizardChangeTypeCycle,
|
|
473
|
+
changeTypeNoop: effectiveI18n.value.wizardChangeTypeNoop,
|
|
474
|
+
}));
|
|
475
|
+
|
|
476
|
+
// Helper hooks with defaults
|
|
477
|
+
function quotaLabel(key: string): string {
|
|
478
|
+
return props.quotaLabel?.(key) ?? key;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function featureLabel(key: string): string {
|
|
482
|
+
return props.featureLabel?.(key) ?? key;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function isFractionalQuota(key: string): boolean {
|
|
486
|
+
return props.isFractionalQuota?.(key) ?? key.toLowerCase().includes('storage');
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function formatDate(input: string | Date): string {
|
|
490
|
+
return props.formatDate(input);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function formatCurrency(n: number): string {
|
|
494
|
+
return props.formatCurrency(n);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function formatQuotaLabel(key: string, value: number): string {
|
|
498
|
+
if (props.formatQuotaLabel) return props.formatQuotaLabel(key, value);
|
|
499
|
+
if (value === -1) return `${quotaLabel(key)}: ∞`;
|
|
500
|
+
return `${value} ${quotaLabel(key)}`;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function usageBarFormatter(key: string): ((value: number) => string) | undefined {
|
|
504
|
+
if (!props.formatQuotaValue) return undefined;
|
|
505
|
+
const fn = props.formatQuotaValue;
|
|
506
|
+
return (value) => fn(key, value);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Bundle store actions (#15/#37): preview dialog first, mutation after confirm.
|
|
510
|
+
async function onBuyBundle(bundleVersionId: string) {
|
|
511
|
+
pendingBundleAction.value = { kind: 'add', bundleVersionId };
|
|
512
|
+
await openBundlePreview(() => billing.previewAddBundle(bundleVersionId));
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
async function onCancelBundle(subscriptionBundleId: string) {
|
|
516
|
+
pendingBundleAction.value = { kind: 'cancel', subscriptionBundleId };
|
|
517
|
+
await openBundlePreview(() => billing.previewCancelBundle(subscriptionBundleId));
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Reactivate = "undo cancellation" (un-cancel). No money flow/proration,
|
|
521
|
+
// but a deliberate action → confirmation (analogous to cancellation) before
|
|
522
|
+
// the mutation.
|
|
523
|
+
function onReactivateBundle(subscriptionBundleId: string) {
|
|
524
|
+
bundleError.value = null;
|
|
525
|
+
reactivateConfirmId.value = subscriptionBundleId;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function closeReactivateConfirm() {
|
|
529
|
+
if (reactivatingBundleId.value) return; // do not close while the mutation is running
|
|
530
|
+
reactivateConfirmId.value = null;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
async function confirmReactivateBundle() {
|
|
534
|
+
const subscriptionBundleId = reactivateConfirmId.value;
|
|
535
|
+
if (!subscriptionBundleId) return;
|
|
536
|
+
reactivatingBundleId.value = subscriptionBundleId;
|
|
537
|
+
bundleError.value = null;
|
|
538
|
+
try {
|
|
539
|
+
await billing.reactivateBundle(subscriptionBundleId);
|
|
540
|
+
// Re-freeze server-side → reload features/quotas, not just the list.
|
|
541
|
+
await billing.reload();
|
|
542
|
+
} catch (err) {
|
|
543
|
+
bundleError.value = err instanceof Error ? err.message : String(err);
|
|
544
|
+
} finally {
|
|
545
|
+
reactivatingBundleId.value = null;
|
|
546
|
+
reactivateConfirmId.value = null;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
async function openBundlePreview(load: () => Promise<BundlePreviewShape>) {
|
|
551
|
+
bundleError.value = null;
|
|
552
|
+
bundlePreview.value = null;
|
|
553
|
+
bundlePreviewError.value = null;
|
|
554
|
+
bundlePreviewOpen.value = true;
|
|
555
|
+
bundlePreviewLoading.value = true;
|
|
556
|
+
try {
|
|
557
|
+
bundlePreview.value = await load();
|
|
558
|
+
} catch (err) {
|
|
559
|
+
bundlePreviewError.value = err instanceof Error ? err.message : String(err);
|
|
560
|
+
} finally {
|
|
561
|
+
bundlePreviewLoading.value = false;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
async function onConfirmBundlePreview() {
|
|
566
|
+
const action = pendingBundleAction.value;
|
|
567
|
+
if (!action) return;
|
|
568
|
+
bundlePreviewSubmitting.value = true;
|
|
569
|
+
bundlePreviewError.value = null;
|
|
570
|
+
if (action.kind === 'add') buyingBundleId.value = action.bundleVersionId;
|
|
571
|
+
else cancelingBundleId.value = action.subscriptionBundleId;
|
|
572
|
+
try {
|
|
573
|
+
if (action.kind === 'add') {
|
|
574
|
+
await billing.addBundle(action.bundleVersionId);
|
|
575
|
+
} else {
|
|
576
|
+
await billing.cancelBundle(action.subscriptionBundleId);
|
|
577
|
+
}
|
|
578
|
+
bundlePreviewOpen.value = false;
|
|
579
|
+
pendingBundleAction.value = null;
|
|
580
|
+
// Reload usage — after add/cancel the features/quotas change
|
|
581
|
+
// (re-freeze server-side), not just the bundle list.
|
|
582
|
+
await billing.reload();
|
|
583
|
+
} catch (err) {
|
|
584
|
+
bundlePreviewError.value = err instanceof Error ? err.message : String(err);
|
|
585
|
+
} finally {
|
|
586
|
+
bundlePreviewSubmitting.value = false;
|
|
587
|
+
buyingBundleId.value = null;
|
|
588
|
+
cancelingBundleId.value = null;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// Feature label with registry translation (#18): the registry label takes
|
|
593
|
+
// precedence, then the consumer hook, and finally the raw key.
|
|
594
|
+
function featureLabelResolved(key: string): string {
|
|
595
|
+
return catalog.featureRegistry.value?.[key]?.label ?? featureLabel(key);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// Mutation handlers
|
|
599
|
+
async function onAcceptPending() {
|
|
600
|
+
acceptingPending.value = true;
|
|
601
|
+
try {
|
|
602
|
+
await billing.acceptPendingPlanVersion();
|
|
603
|
+
} finally {
|
|
604
|
+
acceptingPending.value = false;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function onWizardSubmitted() {
|
|
609
|
+
// After a successful plan change the composable's `reload()` is enough —
|
|
610
|
+
// the wizard closes itself (internal reset logic).
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
async function previewPlanChange(plan: string, cycle: 'MONTHLY' | 'YEARLY') {
|
|
614
|
+
return billing.previewPlanChange(plan, cycle);
|
|
615
|
+
}
|
|
616
|
+
async function changePlan(plan: string, cycle: 'MONTHLY' | 'YEARLY', immediate: boolean) {
|
|
617
|
+
return billing.changePlan(plan, cycle, immediate);
|
|
618
|
+
}
|
|
619
|
+
</script>
|
|
620
|
+
|
|
621
|
+
<style>
|
|
622
|
+
.sp-plan-section {
|
|
623
|
+
--sp-text-secondary: rgba(0, 0, 0, 0.6);
|
|
624
|
+
--sp-text-muted: rgba(0, 0, 0, 0.55);
|
|
625
|
+
--sp-text-disabled: rgba(0, 0, 0, 0.5);
|
|
626
|
+
--sp-text-strong: rgba(0, 0, 0, 0.7);
|
|
627
|
+
--sp-border: rgba(0, 0, 0, 0.08);
|
|
628
|
+
--sp-summary-bg: rgba(25, 118, 210, 0.06);
|
|
629
|
+
|
|
630
|
+
display: flex;
|
|
631
|
+
flex-direction: column;
|
|
632
|
+
gap: 16px;
|
|
633
|
+
}
|
|
634
|
+
body.body--dark .sp-plan-section {
|
|
635
|
+
--sp-text-secondary: rgba(255, 255, 255, 0.72);
|
|
636
|
+
--sp-text-muted: rgba(255, 255, 255, 0.62);
|
|
637
|
+
--sp-text-disabled: rgba(255, 255, 255, 0.5);
|
|
638
|
+
--sp-text-strong: rgba(255, 255, 255, 0.85);
|
|
639
|
+
--sp-border: rgba(255, 255, 255, 0.16);
|
|
640
|
+
--sp-summary-bg: rgba(25, 118, 210, 0.18);
|
|
641
|
+
}
|
|
642
|
+
.sp-plan-section__loading,
|
|
643
|
+
.sp-plan-section__empty {
|
|
644
|
+
display: flex;
|
|
645
|
+
gap: 12px;
|
|
646
|
+
align-items: center;
|
|
647
|
+
padding: 24px;
|
|
648
|
+
color: var(--sp-text-secondary);
|
|
649
|
+
}
|
|
650
|
+
.sp-plan-section__error {
|
|
651
|
+
color: var(--q-negative, #c10015);
|
|
652
|
+
background: rgba(193, 0, 21, 0.08);
|
|
653
|
+
padding: 12px 16px;
|
|
654
|
+
border-radius: 4px;
|
|
655
|
+
}
|
|
656
|
+
.sp-plan-section__card-head {
|
|
657
|
+
display: flex;
|
|
658
|
+
justify-content: space-between;
|
|
659
|
+
align-items: flex-start;
|
|
660
|
+
gap: 16px;
|
|
661
|
+
flex-wrap: wrap;
|
|
662
|
+
}
|
|
663
|
+
.sp-plan-section__eyebrow {
|
|
664
|
+
font-size: 12px;
|
|
665
|
+
text-transform: uppercase;
|
|
666
|
+
letter-spacing: 0.05em;
|
|
667
|
+
color: var(--sp-text-muted);
|
|
668
|
+
margin-bottom: 4px;
|
|
669
|
+
}
|
|
670
|
+
.sp-plan-section__plan-name {
|
|
671
|
+
margin: 0 0 8px;
|
|
672
|
+
font-size: 22px;
|
|
673
|
+
font-weight: 600;
|
|
674
|
+
}
|
|
675
|
+
.sp-plan-section__meta {
|
|
676
|
+
display: flex;
|
|
677
|
+
gap: 12px;
|
|
678
|
+
align-items: center;
|
|
679
|
+
flex-wrap: wrap;
|
|
680
|
+
}
|
|
681
|
+
.sp-plan-section__cycle {
|
|
682
|
+
color: var(--sp-text-secondary);
|
|
683
|
+
}
|
|
684
|
+
.sp-plan-section__price {
|
|
685
|
+
font-weight: 600;
|
|
686
|
+
}
|
|
687
|
+
.sp-plan-section__sub {
|
|
688
|
+
margin: 6px 0 0;
|
|
689
|
+
color: var(--sp-text-muted);
|
|
690
|
+
font-size: 13px;
|
|
691
|
+
}
|
|
692
|
+
.sp-plan-section__usage-title {
|
|
693
|
+
font-weight: 600;
|
|
694
|
+
margin-bottom: 12px;
|
|
695
|
+
}
|
|
696
|
+
.sp-plan-section__usage-grid {
|
|
697
|
+
display: grid;
|
|
698
|
+
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
|
699
|
+
gap: 16px;
|
|
700
|
+
}
|
|
701
|
+
.sp-plan-section__item-list {
|
|
702
|
+
list-style: none;
|
|
703
|
+
padding: 0;
|
|
704
|
+
margin: 0;
|
|
705
|
+
display: flex;
|
|
706
|
+
flex-direction: column;
|
|
707
|
+
gap: 8px;
|
|
708
|
+
}
|
|
709
|
+
.sp-plan-section__item {
|
|
710
|
+
display: flex;
|
|
711
|
+
justify-content: space-between;
|
|
712
|
+
align-items: center;
|
|
713
|
+
border: 1px solid var(--sp-border);
|
|
714
|
+
padding: 10px 14px;
|
|
715
|
+
border-radius: 4px;
|
|
716
|
+
}
|
|
717
|
+
.sp-plan-section__item-label {
|
|
718
|
+
font-weight: 500;
|
|
719
|
+
margin-right: 12px;
|
|
720
|
+
}
|
|
721
|
+
.sp-plan-section__item-price {
|
|
722
|
+
color: var(--sp-text-secondary);
|
|
723
|
+
font-size: 13px;
|
|
724
|
+
}
|
|
725
|
+
.sp-plan-section__item-canceled {
|
|
726
|
+
color: var(--sp-text-disabled);
|
|
727
|
+
font-size: 13px;
|
|
728
|
+
}
|
|
729
|
+
</style>
|