@saasicat/ui-vue 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +47 -0
  3. package/dist/index.cjs +3776 -0
  4. package/dist/index.d.cts +2395 -0
  5. package/dist/index.d.ts +2395 -0
  6. package/dist/index.js +3659 -0
  7. package/dist/testing-e2e/admin-pages-suite.cjs +134 -0
  8. package/dist/testing-e2e/admin-pages-suite.d.cts +37 -0
  9. package/dist/testing-e2e/admin-pages-suite.d.ts +37 -0
  10. package/dist/testing-e2e/admin-pages-suite.js +109 -0
  11. package/package.json +87 -0
  12. package/src/action-registry.ts +110 -0
  13. package/src/batch-column-fetcher.ts +152 -0
  14. package/src/boot-loader.ts +60 -0
  15. package/src/components/BundleVersionPublishDialog.vue +359 -0
  16. package/src/components/BusinessTypeVersionEditorDialog.vue +417 -0
  17. package/src/components/BusinessTypeVersionPublishDialog.vue +258 -0
  18. package/src/components/FeatureGate.vue +56 -0
  19. package/src/components/KpiCard.vue +53 -0
  20. package/src/components/KvBlock.vue +36 -0
  21. package/src/components/MarketingPromotionsTab.vue +861 -0
  22. package/src/components/MfaPromptDialog.vue +104 -0
  23. package/src/components/TenantActionConfirmDialog.vue +252 -0
  24. package/src/components/VersionDiffPreview.vue +150 -0
  25. package/src/components/bundle-editor/BundleCreatePanel.vue +681 -0
  26. package/src/components/bundle-editor/BundleFeaturesEditor.vue +215 -0
  27. package/src/components/bundle-editor/BundlePlanCompatPicker.vue +316 -0
  28. package/src/components/bundle-editor/BundleQuotasEditor.vue +219 -0
  29. package/src/components/bundle-editor/BundleStatusBanner.vue +189 -0
  30. package/src/components/bundle-editor/BundleVersionInlineEditor.vue +659 -0
  31. package/src/components/bundle-editor/BundleVersionStrip.vue +207 -0
  32. package/src/components/bundle-editor/bundle-version-status.ts +164 -0
  33. package/src/components/bundle-editor/catalog-i18n.ts +85 -0
  34. package/src/components/dialogs/PilotCreateDialog.vue +780 -0
  35. package/src/components/dialogs/PilotEditDialog.vue +548 -0
  36. package/src/components/dialogs/PromoCodeCreateDialog.vue +737 -0
  37. package/src/components/dialogs/PromoCodeEditDialog.vue +859 -0
  38. package/src/components/dialogs/types.ts +126 -0
  39. package/src/components/plan/PlanCycleToggle.vue +99 -0
  40. package/src/components/plan/PlanGrid.vue +211 -0
  41. package/src/components/plan/PriceSummary.vue +293 -0
  42. package/src/components/plan/PromoCodeInput.vue +192 -0
  43. package/src/components/plan/PublicBundleGrid.vue +287 -0
  44. package/src/components/plan-cockpit/PlanCockpit.vue +993 -0
  45. package/src/components/plan-cockpit/PlanCockpitAuditLog.vue +65 -0
  46. package/src/components/plan-cockpit/PlanCockpitDiffPanel.vue +91 -0
  47. package/src/components/plan-cockpit/PlanCockpitHeader.vue +98 -0
  48. package/src/components/plan-cockpit/PlanCockpitImpactPanel.vue +99 -0
  49. package/src/components/plan-cockpit/PlanCockpitKpis.vue +57 -0
  50. package/src/components/plan-cockpit/PlanCockpitVersions.vue +226 -0
  51. package/src/components/plan-cockpit/types.ts +72 -0
  52. package/src/components/plan-create-dialog/PlanCreateDialog.vue +508 -0
  53. package/src/components/plan-detail/PlanAuditLog.vue +59 -0
  54. package/src/components/plan-detail/PlanDetail.vue +1195 -0
  55. package/src/components/plan-detail/PlanDetailHeader.vue +185 -0
  56. package/src/components/plan-detail/PlanDetailKpis.vue +57 -0
  57. package/src/components/plan-detail/PlanTerminateDialog.vue +76 -0
  58. package/src/components/plan-detail/PlanVersionDiffPanel.vue +149 -0
  59. package/src/components/plan-detail/PlanVersionsPanel.vue +255 -0
  60. package/src/components/plan-detail/types.ts +59 -0
  61. package/src/components/plan-list/PlanList.vue +1067 -0
  62. package/src/components/plan-matrix/PlanMatrix.vue +1113 -0
  63. package/src/components/plan-review/PlanReview.vue +742 -0
  64. package/src/components/plan-version-editor/PlanCatalogPreview.vue +184 -0
  65. package/src/components/plan-version-editor/PlanComponentPool.vue +272 -0
  66. package/src/components/plan-version-editor/PlanVersionBasket.vue +281 -0
  67. package/src/components/plan-version-editor/PlanVersionDiffDialog.vue +84 -0
  68. package/src/components/plan-version-editor/PlanVersionEditor.vue +1728 -0
  69. package/src/components/plan-version-editor/PlanVersionEditorHeader.vue +94 -0
  70. package/src/components/plan-version-editor/types.ts +96 -0
  71. package/src/components/wizard-stepper/WizardStepper.vue +107 -0
  72. package/src/create-admin-routes.ts +68 -0
  73. package/src/create-super-admin-app.ts +345 -0
  74. package/src/entitlement-provider.ts +36 -0
  75. package/src/feature-router-guard.ts +77 -0
  76. package/src/http-json.ts +62 -0
  77. package/src/index.ts +98 -0
  78. package/src/manifest-loader.ts +139 -0
  79. package/src/manifest-store-factory.ts +114 -0
  80. package/src/nav-builder.ts +274 -0
  81. package/src/pages-standard/AdminLayout.vue +411 -0
  82. package/src/pages-standard/AdminManifestErrorPage.vue +79 -0
  83. package/src/pages-standard/AuditPage.vue +223 -0
  84. package/src/pages-standard/BundlesPage.vue +910 -0
  85. package/src/pages-standard/BusinessTypesPage.vue +662 -0
  86. package/src/pages-standard/DashboardPage.vue +595 -0
  87. package/src/pages-standard/DiscoveryPage.vue +663 -0
  88. package/src/pages-standard/EmailHistoryPage.vue +576 -0
  89. package/src/pages-standard/MarketingCatalogPage.vue +1771 -0
  90. package/src/pages-standard/PilotsPage.vue +577 -0
  91. package/src/pages-standard/PlanVersionsPage.vue +216 -0
  92. package/src/pages-standard/PlansPage.vue +1222 -0
  93. package/src/pages-standard/PlatformEmailPage.vue +434 -0
  94. package/src/pages-standard/PromoCodeDetailPage.vue +331 -0
  95. package/src/pages-standard/PromoCodesPage.vue +589 -0
  96. package/src/pages-standard/SubscriptionsPage.vue +159 -0
  97. package/src/pages-standard/SuperAdminLoginPage.vue +299 -0
  98. package/src/pages-standard/SuperAdminSetupWizard.vue +405 -0
  99. package/src/pages-standard/TenantDetailPage.vue +432 -0
  100. package/src/pages-standard/TenantsPage.vue +732 -0
  101. package/src/pages-standard/UsersPage.vue +504 -0
  102. package/src/pages-standard/bundles-page/BundleAccordionList.vue +116 -0
  103. package/src/pages-standard/bundles-page/BundleDetailPanel.vue +193 -0
  104. package/src/pages-standard/bundles-page/BundlesFilterBar.vue +48 -0
  105. package/src/pages-standard/bundles-page/BundlesHeader.vue +67 -0
  106. package/src/pages-standard/bundles-page/BundlesKpis.vue +39 -0
  107. package/src/pages-standard/bundles-page/types.ts +15 -0
  108. package/src/pages-standard/discovery-page/CatalogEntryTransPanel.vue +246 -0
  109. package/src/pages-standard/discovery-page/DiscoveryCapList.vue +171 -0
  110. package/src/pages-standard/discovery-page/DiscoveryFeatureCard.vue +410 -0
  111. package/src/pages-standard/discovery-page/DiscoveryHeader.vue +31 -0
  112. package/src/pages-standard/discovery-page/DiscoveryKpis.vue +47 -0
  113. package/src/pages-standard/discovery-page/DiscoveryMetaBanner.vue +26 -0
  114. package/src/pages-standard/discovery-page/DiscoveryQuotaCard.vue +247 -0
  115. package/src/pages-standard/discovery-page/DiscoveryStatusControl.vue +73 -0
  116. package/src/pages-standard/discovery-page/discovery-ui.ts +166 -0
  117. package/src/pages-standard/email-history.types.ts +48 -0
  118. package/src/pages-standard/marketing-catalog/MarketingCatalogAdmin.vue +448 -0
  119. package/src/pages-standard/marketing-catalog/MarketingCatalogHeader.vue +93 -0
  120. package/src/pages-standard/marketing-catalog/MarketingCatalogPreview.vue +138 -0
  121. package/src/pages-standard/marketing-catalog/MarketingCatalogToolbar.vue +58 -0
  122. package/src/pages-standard/marketing-catalog/types.ts +43 -0
  123. package/src/pages-standard/plan-versions/PlanDiffCard.vue +132 -0
  124. package/src/pages-standard/plan-versions/PlanVersionHeader.vue +293 -0
  125. package/src/pages-standard/plan-versions/PlanVersionStatusBadge.vue +48 -0
  126. package/src/pages-standard/plan-versions/PlanVersionsAudit.vue +307 -0
  127. package/src/pages-standard/plan-versions/PlanVersionsDiff.vue +269 -0
  128. package/src/pages-standard/plan-versions/PlanVersionsKpi.vue +65 -0
  129. package/src/pages-standard/plan-versions/PlanVersionsList.vue +292 -0
  130. package/src/pages-standard/plan-versions/PlanVersionsMatrix.vue +261 -0
  131. package/src/pages-standard/plan-versions/PlanVersionsTimeline.vue +300 -0
  132. package/src/pages-standard/plan-versions/VersionDiffPreview.vue +167 -0
  133. package/src/pages-standard/plan-versions/format.ts +53 -0
  134. package/src/pages-standard/plan-versions/types.ts +33 -0
  135. package/src/pages-standard/plans-page/PlanArchiveDialog.vue +50 -0
  136. package/src/pages-standard/plans-page/PlanBundleOverview.vue +147 -0
  137. package/src/pages-standard/plans-page/PlanDiscardDraftDialog.vue +46 -0
  138. package/src/pages-standard/plans-page/PlanPublishDialog.vue +106 -0
  139. package/src/pages-standard/plans-page/PlansPageToast.vue +69 -0
  140. package/src/pages-standard/plans-page/types.ts +18 -0
  141. package/src/pages-standard/platform-email.types.ts +41 -0
  142. package/src/pages-standard/sa-theme.css +223 -0
  143. package/src/pages-standard/tenants/StatusPill.vue +62 -0
  144. package/src/pages-standard/tenants/format.ts +38 -0
  145. package/src/pages-tenant/LimitsRow.vue +67 -0
  146. package/src/pages-tenant/MySubscriptionBundlesPage.vue +604 -0
  147. package/src/pages-tenant/OnboardingConfigurator.vue +417 -0
  148. package/src/pages-tenant/PackageSnapshotPanel.vue +241 -0
  149. package/src/pages-tenant/PendingVersionBanner.vue +126 -0
  150. package/src/pages-tenant/PlanChangeWizard.vue +630 -0
  151. package/src/pages-tenant/TenantPlanSection.vue +729 -0
  152. package/src/pages-tenant/UsageBar.vue +107 -0
  153. package/src/pages-tenant/default-i18n.ts +280 -0
  154. package/src/pages-tenant/tenant-plan-section/BundlePreviewDialog.vue +304 -0
  155. package/src/pages-tenant/tenant-plan-section/TenantBundleStore.vue +323 -0
  156. package/src/pages-tenant/tenant-plan-section/TenantFeatureMatrix.vue +122 -0
  157. package/src/pages-tenant/tenant-plan-section/TenantPlanCardHeader.vue +67 -0
  158. package/src/pages-tenant/tenant-plan-section/TenantUsageGrid.vue +31 -0
  159. package/src/plan-versions-catalog.ts +368 -0
  160. package/src/platform-loaders.ts +68 -0
  161. package/src/project-page-host.ts +108 -0
  162. package/src/testing-e2e/admin-pages-suite.ts +187 -0
  163. package/src/types.ts +58 -0
  164. package/src/use-actions.ts +25 -0
  165. package/src/use-api-list.ts +151 -0
  166. package/src/use-audit-entries.ts +48 -0
  167. package/src/use-batch-columns.ts +59 -0
  168. package/src/use-bulk-publish.ts +150 -0
  169. package/src/use-bundle-versions-map.ts +112 -0
  170. package/src/use-bundles.ts +297 -0
  171. package/src/use-business-types.ts +269 -0
  172. package/src/use-catalog-entries.ts +243 -0
  173. package/src/use-discovery.ts +158 -0
  174. package/src/use-entitlement.ts +87 -0
  175. package/src/use-live-plan-versions.ts +126 -0
  176. package/src/use-manifest.ts +57 -0
  177. package/src/use-marketing-projections.ts +160 -0
  178. package/src/use-nav.ts +33 -0
  179. package/src/use-plan-editor.ts +162 -0
  180. package/src/use-plan-versions.ts +83 -0
  181. package/src/use-plans.ts +375 -0
  182. package/src/use-platform-tenant-actions.ts +286 -0
  183. package/src/use-promotions.ts +126 -0
  184. package/src/use-public-boot.ts +40 -0
  185. package/src/use-subscription-draft.ts +355 -0
  186. package/src/use-super-admin-context.ts +93 -0
  187. package/src/use-tenant-action-flow.ts +238 -0
  188. package/src/use-tenant-billing-catalog.ts +160 -0
  189. package/src/use-tenant-billing.ts +453 -0
  190. package/src/use-tenant-manifest.ts +94 -0
  191. package/src/use-tenant-subscription-bundles.ts +148 -0
  192. package/src/use-tenants.ts +61 -0
  193. package/src/version.ts +10 -0
@@ -0,0 +1,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>