@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,604 @@
1
+ <template>
2
+ <div class="msb">
3
+ <header class="msb-head">
4
+ <div>
5
+ <h2 class="msb-title">Meine Bundles</h2>
6
+ <p class="msb-sub">
7
+ Eigenständig gebuchte Add-On-Pakete zu deinem Plan. Mindestlaufzeit +
8
+ Kündigungs- Termin pro Bundle.
9
+ </p>
10
+ </div>
11
+ <button class="msb-btn msb-btn--primary" type="button" @click="openAddDialog">
12
+ <svg
13
+ width="14"
14
+ height="14"
15
+ viewBox="0 0 24 24"
16
+ fill="none"
17
+ stroke="currentColor"
18
+ stroke-width="2"
19
+ >
20
+ <path d="M12 5v14M5 12h14" />
21
+ </svg>
22
+ <span>Bundle buchen</span>
23
+ </button>
24
+ </header>
25
+
26
+ <div v-if="error" class="msb-error" role="alert">
27
+ <strong>Fehler:</strong> {{ error.message }}
28
+ </div>
29
+
30
+ <div v-if="loading && bundles.length === 0" class="msb-loading">Lade …</div>
31
+
32
+ <div v-else-if="bundles.length === 0" class="msb-empty">
33
+ Du hast noch kein Bundle gebucht. Über
34
+ <button type="button" class="msb-empty-link" @click="openAddDialog">
35
+ Bundle buchen
36
+ </button>
37
+ kannst du dein Paket um zusätzliche Features &amp; Quotas erweitern.
38
+ </div>
39
+
40
+ <div v-else class="msb-list">
41
+ <article v-for="b in bundles" :key="b.id" class="msb-card" :class="cardStatusClass(b)">
42
+ <header class="msb-card-head">
43
+ <div>
44
+ <div class="msb-card-title">
45
+ <span class="msb-card-key">{{ resolveBundleKey(b) }}</span>
46
+ <span class="msb-chip" :class="`msb-chip--${statusOf(b)}`">
47
+ {{ statusLabel(b) }}
48
+ </span>
49
+ </div>
50
+ <div class="msb-card-meta">
51
+ Gebucht seit {{ formatDate(b.startedAt) }}
52
+ <template v-if="b.minimumTermEndsAt">
53
+ · Mindestlaufzeit bis {{ formatDate(b.minimumTermEndsAt) }}
54
+ </template>
55
+ </div>
56
+ </div>
57
+ <button
58
+ v-if="b.canceledAt === null"
59
+ class="msb-btn msb-btn--ghost"
60
+ type="button"
61
+ :disabled="cancellingId === b.id"
62
+ @click="onCancel(b)"
63
+ >
64
+ {{ cancellingId === b.id ? 'Kündige …' : 'Kündigen' }}
65
+ </button>
66
+ </header>
67
+ <div v-if="b.canceledAt !== null" class="msb-cancel-info">
68
+ Gekündigt am {{ formatDate(b.canceledAt) }} · läuft bis
69
+ <strong>{{ formatDate(b.canceledEffectiveAt) }}</strong>
70
+ </div>
71
+ </article>
72
+ </div>
73
+
74
+ <!-- Add dialog: uses public catalog bundles when the wrapper provides them. -->
75
+ <div v-if="addOpen" class="msb-modal-bg" @click="closeAddDialog">
76
+ <div class="msb-modal" @click.stop>
77
+ <div class="msb-modal-head">
78
+ <div class="msb-modal-title">Bundle buchen</div>
79
+ <button
80
+ class="msb-modal-x"
81
+ type="button"
82
+ aria-label="Schließen"
83
+ @click="closeAddDialog"
84
+ >
85
+ <svg
86
+ width="14"
87
+ height="14"
88
+ viewBox="0 0 24 24"
89
+ fill="none"
90
+ stroke="currentColor"
91
+ stroke-width="2"
92
+ >
93
+ <path d="M18 6L6 18M6 6l12 12" />
94
+ </svg>
95
+ </button>
96
+ </div>
97
+ <div class="msb-modal-body">
98
+ <!-- Selection list via public catalog, otherwise fallback to direct UUID input. -->
99
+ <template v-if="bookable.length > 0">
100
+ <label class="msb-field">
101
+ <span class="msb-field-label">Bundle</span>
102
+ <select v-model="addForm.bundleVersionId" class="msb-input">
103
+ <option value="">— bitte wählen —</option>
104
+ <option
105
+ v-for="b in bookable"
106
+ :key="b.bundleVersionId"
107
+ :value="b.bundleVersionId"
108
+ >
109
+ {{ b.label }} ({{ b.bundleKey }})
110
+ <template v-if="b.monthlyNet !== null">
111
+ — {{ b.monthlyNet }} € / Mo
112
+ </template>
113
+ </option>
114
+ </select>
115
+ <span v-if="hiddenBecauseIncompatible > 0" class="msb-field-hint">
116
+ {{ hiddenBecauseIncompatible }} weitere Bundle(s) ausgeblendet —
117
+ nicht kompatibel mit Plan <code>{{ currentPlanKey }}</code
118
+ >.
119
+ </span>
120
+ </label>
121
+ </template>
122
+ <template v-else>
123
+ <label class="msb-field">
124
+ <span class="msb-field-label">BundleVersion-ID</span>
125
+ <input
126
+ v-model="addForm.bundleVersionId"
127
+ class="msb-input"
128
+ placeholder="UUID der gewünschten Bundle-Version"
129
+ />
130
+ <span class="msb-field-hint">
131
+ Aus dem öffentlichen Marketing-Catalog. Konsument kann
132
+ <code>availableBundles</code>
133
+ als Prop reichen, dann erscheint hier ein Dropdown.
134
+ </span>
135
+ </label>
136
+ </template>
137
+ <label class="msb-field">
138
+ <span class="msb-field-label">Mindestlaufzeit (Monate, optional)</span>
139
+ <input
140
+ v-model.number="addForm.minimumTermMonths"
141
+ type="number"
142
+ min="0"
143
+ max="120"
144
+ class="msb-input"
145
+ placeholder="Default: 12"
146
+ />
147
+ </label>
148
+ <div v-if="addError" class="msb-error">{{ addError }}</div>
149
+ </div>
150
+ <div class="msb-modal-foot">
151
+ <button class="msb-btn" type="button" @click="closeAddDialog">Abbrechen</button>
152
+ <button
153
+ class="msb-btn msb-btn--primary"
154
+ type="button"
155
+ :disabled="!canSubmit || adding"
156
+ @click="submitAdd"
157
+ >
158
+ {{ adding ? 'Buche …' : 'Bundle buchen' }}
159
+ </button>
160
+ </div>
161
+ </div>
162
+ </div>
163
+ </div>
164
+ </template>
165
+
166
+ <script setup lang="ts">
167
+ import { computed, onMounted, reactive, ref } from 'vue';
168
+ import type { SubscriptionBundleRecord } from '@saasicat/types';
169
+
170
+ import { useTenantSubscriptionBundles } from '../use-tenant-subscription-bundles.js';
171
+
172
+ // MySubscriptionBundlesPage — tenant self-service page "Meine Bundles".
173
+ // The hosting app embeds the page
174
+ // via route and passes the `billingEndpoint` through. The composable calls
175
+ // `/billing/subscription-bundles`. The bundle label resolution comes
176
+ // from the consumer (optional via the `bundleLabels` prop) — otherwise we
177
+ // show the `bundleVersionId`.
178
+
179
+ interface BookableBundle {
180
+ bundleKey: string;
181
+ label: string;
182
+ bundleVersionId: string;
183
+ monthlyNet: number | null;
184
+ description?: string;
185
+ /** Plan keys the bundle is compatible with. Empty = universal. */
186
+ compatiblePlanKeys: string[];
187
+ }
188
+
189
+ const props = withDefaults(
190
+ defineProps<{
191
+ billingEndpoint: string;
192
+ /**
193
+ * Mapping `bundleVersionId → BundleKey/Label` (the consumer can
194
+ * preload this from the public catalog). Without a mapping we show the
195
+ * bundleVersionId as a fallback.
196
+ */
197
+ bundleLabels?: Record<string, { bundleKey: string; label?: string }>;
198
+ /**
199
+ * Bookable bundles for the add dialog — come from the consumer via
200
+ * `PublicMarketingCatalogResponse.bundles`. Empty = the page shows the
201
+ * UUID fallback input.
202
+ */
203
+ availableBundles?: BookableBundle[];
204
+ /**
205
+ * Current plan key of the tenant — for the plan-compat preselection
206
+ * in the dropdown. If not set: no filtering.
207
+ */
208
+ currentPlanKey?: string | null;
209
+ /** Optional: auth token provider (for the Bearer header). */
210
+ getAuthToken?: () => string | null;
211
+ }>(),
212
+ {
213
+ bundleLabels: () => ({}),
214
+ availableBundles: () => [],
215
+ currentPlanKey: null,
216
+ getAuthToken: undefined,
217
+ },
218
+ );
219
+
220
+ const { bundles, loading, error, load, add, cancel } = useTenantSubscriptionBundles({
221
+ billingEndpoint: props.billingEndpoint,
222
+ getAuthToken: props.getAuthToken,
223
+ });
224
+
225
+ onMounted(() => load());
226
+
227
+ // ─── Add dialog ─────────────────────────────────────────────
228
+ const addOpen = ref(false);
229
+ const adding = ref(false);
230
+ const addError = ref<string | null>(null);
231
+ const addForm = reactive<{ bundleVersionId: string; minimumTermMonths: number | null }>({
232
+ bundleVersionId: '',
233
+ minimumTermMonths: null,
234
+ });
235
+
236
+ const canSubmit = computed(() => addForm.bundleVersionId.trim().length > 0);
237
+
238
+ /**
239
+ * Filters `availableBundles` down to those compatible with the current
240
+ * plan. Empty `compatiblePlanKeys` = universal.
241
+ * Already-booked bundles (same `bundleVersionId`, not canceled) are
242
+ * hidden — idempotency protection complementing the backend.
243
+ */
244
+ const bookable = computed<BookableBundle[]>(() => {
245
+ const bookedActive = new Set(
246
+ bundles.value.filter((b) => b.canceledAt === null).map((b) => b.bundleVersionId),
247
+ );
248
+ return props.availableBundles.filter((b) => {
249
+ if (bookedActive.has(b.bundleVersionId)) return false;
250
+ if (b.compatiblePlanKeys.length === 0) return true;
251
+ if (!props.currentPlanKey) return true;
252
+ return b.compatiblePlanKeys.includes(props.currentPlanKey);
253
+ });
254
+ });
255
+
256
+ const hiddenBecauseIncompatible = computed(() => {
257
+ if (!props.currentPlanKey) return 0;
258
+ return props.availableBundles.filter(
259
+ (b) =>
260
+ b.compatiblePlanKeys.length > 0 &&
261
+ !b.compatiblePlanKeys.includes(props.currentPlanKey!),
262
+ ).length;
263
+ });
264
+
265
+ /** Auto label lookup from availableBundles (in addition to the bundleLabels prop). */
266
+ const labelsMap = computed<Record<string, { bundleKey: string; label?: string }>>(() => {
267
+ const merged: Record<string, { bundleKey: string; label?: string }> = {
268
+ ...props.bundleLabels,
269
+ };
270
+ for (const b of props.availableBundles) {
271
+ if (!merged[b.bundleVersionId]) {
272
+ merged[b.bundleVersionId] = { bundleKey: b.bundleKey, label: b.label };
273
+ }
274
+ }
275
+ return merged;
276
+ });
277
+
278
+ function openAddDialog(): void {
279
+ addForm.bundleVersionId = '';
280
+ addForm.minimumTermMonths = null;
281
+ addError.value = null;
282
+ addOpen.value = true;
283
+ }
284
+
285
+ function closeAddDialog(): void {
286
+ addOpen.value = false;
287
+ }
288
+
289
+ async function submitAdd(): Promise<void> {
290
+ if (!canSubmit.value || adding.value) return;
291
+ adding.value = true;
292
+ addError.value = null;
293
+ try {
294
+ await add({
295
+ bundleVersionId: addForm.bundleVersionId.trim(),
296
+ minimumTermMonths:
297
+ addForm.minimumTermMonths === null ? undefined : addForm.minimumTermMonths,
298
+ });
299
+ addOpen.value = false;
300
+ } catch (err) {
301
+ addError.value = err instanceof Error ? err.message : String(err);
302
+ } finally {
303
+ adding.value = false;
304
+ }
305
+ }
306
+
307
+ // ─── Cancel ─────────────────────────────────────────────────
308
+ const cancellingId = ref<string | null>(null);
309
+
310
+ async function onCancel(b: SubscriptionBundleRecord): Promise<void> {
311
+ const ok = window.confirm(
312
+ `Bundle wirklich kündigen? Die Kündigung wird zum nächstmöglichen Termin wirksam.`,
313
+ );
314
+ if (!ok) return;
315
+ cancellingId.value = b.id;
316
+ try {
317
+ await cancel(b.id);
318
+ } finally {
319
+ cancellingId.value = null;
320
+ }
321
+ }
322
+
323
+ // ─── Display helpers ────────────────────────────────────────
324
+ function resolveBundleKey(b: SubscriptionBundleRecord): string {
325
+ const meta = labelsMap.value[b.bundleVersionId];
326
+ return meta?.label || meta?.bundleKey || b.bundleVersionId;
327
+ }
328
+
329
+ function statusOf(b: SubscriptionBundleRecord): 'active' | 'canceled-pending' | 'canceled' {
330
+ if (b.canceledAt === null) return 'active';
331
+ if (b.canceledEffectiveAt && b.canceledEffectiveAt.getTime() > Date.now()) {
332
+ return 'canceled-pending';
333
+ }
334
+ return 'canceled';
335
+ }
336
+
337
+ function statusLabel(b: SubscriptionBundleRecord): string {
338
+ const s = statusOf(b);
339
+ if (s === 'active') return 'Aktiv';
340
+ if (s === 'canceled-pending') return 'Kündigung wirksam ab …';
341
+ return 'Beendet';
342
+ }
343
+
344
+ function cardStatusClass(b: SubscriptionBundleRecord): string {
345
+ return `msb-card--${statusOf(b)}`;
346
+ }
347
+
348
+ function formatDate(date: Date | string | null | undefined): string {
349
+ if (!date) return '—';
350
+ const d = typeof date === 'string' ? new Date(date) : date;
351
+ if (Number.isNaN(d.getTime())) return '—';
352
+ return d.toLocaleDateString('de-DE', { year: 'numeric', month: '2-digit', day: '2-digit' });
353
+ }
354
+ </script>
355
+
356
+ <style scoped>
357
+ .msb {
358
+ padding: 16px;
359
+ display: flex;
360
+ flex-direction: column;
361
+ gap: 16px;
362
+ font-family:
363
+ 'Inter',
364
+ -apple-system,
365
+ BlinkMacSystemFont,
366
+ system-ui,
367
+ sans-serif;
368
+ color: #0f172a;
369
+ }
370
+ .msb-head {
371
+ display: flex;
372
+ align-items: flex-end;
373
+ justify-content: space-between;
374
+ gap: 16px;
375
+ }
376
+ .msb-title {
377
+ margin: 0;
378
+ font-size: 20px;
379
+ font-weight: 700;
380
+ letter-spacing: -0.01em;
381
+ }
382
+ .msb-sub {
383
+ margin: 4px 0 0;
384
+ color: #64748b;
385
+ font-size: 13px;
386
+ max-width: 580px;
387
+ line-height: 1.5;
388
+ }
389
+ .msb-loading {
390
+ padding: 32px;
391
+ text-align: center;
392
+ color: #64748b;
393
+ }
394
+ .msb-empty {
395
+ padding: 32px 24px;
396
+ text-align: center;
397
+ background: #fff;
398
+ border: 1px dashed #cbd5e1;
399
+ border-radius: 12px;
400
+ color: #475569;
401
+ }
402
+ .msb-empty-link {
403
+ background: transparent;
404
+ border: 0;
405
+ color: #2563eb;
406
+ cursor: pointer;
407
+ font: inherit;
408
+ text-decoration: underline;
409
+ }
410
+ .msb-error {
411
+ padding: 10px 14px;
412
+ background: #fef2f2;
413
+ border: 1px solid #fecaca;
414
+ border-radius: 8px;
415
+ color: #b91c1c;
416
+ font-size: 13px;
417
+ }
418
+ .msb-list {
419
+ display: flex;
420
+ flex-direction: column;
421
+ gap: 10px;
422
+ }
423
+ .msb-card {
424
+ background: #fff;
425
+ border: 1px solid #e2e8f0;
426
+ border-radius: 12px;
427
+ padding: 14px 18px;
428
+ }
429
+ .msb-card--canceled-pending {
430
+ border-color: #fde68a;
431
+ background: #fffbeb;
432
+ }
433
+ .msb-card--canceled {
434
+ opacity: 0.65;
435
+ }
436
+ .msb-card-head {
437
+ display: flex;
438
+ align-items: flex-start;
439
+ justify-content: space-between;
440
+ gap: 12px;
441
+ }
442
+ .msb-card-title {
443
+ display: flex;
444
+ align-items: center;
445
+ gap: 10px;
446
+ flex-wrap: wrap;
447
+ }
448
+ .msb-card-key {
449
+ font:
450
+ 700 13px 'JetBrains Mono',
451
+ ui-monospace,
452
+ monospace;
453
+ letter-spacing: 0.04em;
454
+ }
455
+ .msb-card-meta {
456
+ margin-top: 4px;
457
+ font-size: 12.5px;
458
+ color: #64748b;
459
+ }
460
+ .msb-chip {
461
+ font-size: 10.5px;
462
+ font-weight: 700;
463
+ text-transform: uppercase;
464
+ letter-spacing: 0.06em;
465
+ padding: 2px 8px;
466
+ border-radius: 999px;
467
+ }
468
+ .msb-chip--active {
469
+ background: #d1fae5;
470
+ color: #065f46;
471
+ }
472
+ .msb-chip--canceled-pending {
473
+ background: #fef3c7;
474
+ color: #92400e;
475
+ }
476
+ .msb-chip--canceled {
477
+ background: #e2e8f0;
478
+ color: #475569;
479
+ }
480
+ .msb-cancel-info {
481
+ margin-top: 10px;
482
+ padding: 8px 12px;
483
+ background: #fffbeb;
484
+ border: 1px solid #fde68a;
485
+ border-radius: 6px;
486
+ color: #92400e;
487
+ font-size: 12.5px;
488
+ }
489
+
490
+ .msb-btn {
491
+ display: inline-flex;
492
+ align-items: center;
493
+ gap: 6px;
494
+ padding: 7px 14px;
495
+ background: #fff;
496
+ border: 1px solid #cbd5e1;
497
+ border-radius: 7px;
498
+ cursor: pointer;
499
+ font-family: inherit;
500
+ font-size: 13px;
501
+ color: #0f172a;
502
+ }
503
+ .msb-btn:hover:not(:disabled) {
504
+ background: #f8fafc;
505
+ }
506
+ .msb-btn:disabled {
507
+ cursor: not-allowed;
508
+ opacity: 0.55;
509
+ }
510
+ .msb-btn--primary {
511
+ background: #2563eb;
512
+ border-color: #2563eb;
513
+ color: #fff;
514
+ }
515
+ .msb-btn--primary:hover:not(:disabled) {
516
+ background: #1d4ed8;
517
+ }
518
+ .msb-btn--ghost {
519
+ background: transparent;
520
+ border-color: #fca5a5;
521
+ color: #b91c1c;
522
+ }
523
+ .msb-btn--ghost:hover:not(:disabled) {
524
+ background: #fef2f2;
525
+ }
526
+
527
+ .msb-modal-bg {
528
+ position: fixed;
529
+ inset: 0;
530
+ background: rgba(15, 23, 42, 0.55);
531
+ display: grid;
532
+ place-items: center;
533
+ z-index: 1000;
534
+ }
535
+ .msb-modal {
536
+ width: min(520px, 96vw);
537
+ background: #fff;
538
+ border-radius: 12px;
539
+ box-shadow: 0 20px 60px rgba(15, 23, 42, 0.25);
540
+ display: flex;
541
+ flex-direction: column;
542
+ }
543
+ .msb-modal-head {
544
+ display: flex;
545
+ align-items: center;
546
+ justify-content: space-between;
547
+ padding: 16px 20px 10px;
548
+ border-bottom: 1px solid #e2e8f0;
549
+ }
550
+ .msb-modal-title {
551
+ font-size: 16px;
552
+ font-weight: 700;
553
+ }
554
+ .msb-modal-x {
555
+ background: transparent;
556
+ border: 0;
557
+ cursor: pointer;
558
+ color: #64748b;
559
+ padding: 4px;
560
+ border-radius: 4px;
561
+ }
562
+ .msb-modal-x:hover {
563
+ background: #f1f5f9;
564
+ color: #0f172a;
565
+ }
566
+ .msb-modal-body {
567
+ padding: 16px 20px;
568
+ display: flex;
569
+ flex-direction: column;
570
+ gap: 14px;
571
+ }
572
+ .msb-modal-foot {
573
+ padding: 12px 20px;
574
+ border-top: 1px solid #e2e8f0;
575
+ background: #f8fafc;
576
+ border-radius: 0 0 12px 12px;
577
+ display: flex;
578
+ justify-content: flex-end;
579
+ gap: 8px;
580
+ }
581
+ .msb-field {
582
+ display: flex;
583
+ flex-direction: column;
584
+ gap: 4px;
585
+ }
586
+ .msb-field-label {
587
+ font-size: 12px;
588
+ font-weight: 600;
589
+ color: #475569;
590
+ }
591
+ .msb-field-hint {
592
+ font-size: 11px;
593
+ color: #94a3b8;
594
+ }
595
+ .msb-input {
596
+ padding: 7px 10px;
597
+ border: 1px solid #cbd5e1;
598
+ border-radius: 6px;
599
+ font-family: inherit;
600
+ font-size: 13px;
601
+ color: #0f172a;
602
+ background: #fff;
603
+ }
604
+ </style>