@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,323 @@
1
+ <template>
2
+ <q-card-section>
3
+ <div class="sp-plan-section__usage-title">{{ i18n.bundlesStoreTitle }}</div>
4
+
5
+ <!-- Booked bundles -->
6
+ <div v-if="bookedRows.length > 0" class="sp-bundle-store__booked">
7
+ <div class="sp-bundle-store__subtitle">{{ i18n.bundlesBookedTitle }}</div>
8
+ <ul class="sp-plan-section__item-list">
9
+ <li v-for="row in bookedRows" :key="row.id" class="sp-plan-section__item">
10
+ <div>
11
+ <span class="sp-plan-section__item-label">{{ row.label }}</span>
12
+ <span
13
+ v-if="row.canceledAt"
14
+ class="sp-plan-section__item-canceled"
15
+ >
16
+ {{ i18n.bundleCanceledAt }}
17
+ {{ formatDate(row.canceledEffectiveAt ?? row.canceledAt) }}
18
+ </span>
19
+ <span
20
+ v-else-if="row.minimumTermEndsAt"
21
+ class="sp-plan-section__item-price"
22
+ >
23
+ {{ i18n.bundleMinimumTermUntil }} {{ formatDate(row.minimumTermEndsAt) }}
24
+ </span>
25
+ </div>
26
+ <div class="sp-bundle-store__booked-actions">
27
+ <span class="sp-plan-section__item-price">
28
+ {{ formatCurrency(row.monthlyNet) }} {{ i18n.bundlesPerMonth }}
29
+ </span>
30
+ <q-btn
31
+ v-if="!row.canceledAt"
32
+ flat
33
+ dense
34
+ color="negative"
35
+ :label="i18n.bundleCancelAction"
36
+ :loading="cancelingId === row.id"
37
+ :disable="cancelingId === row.id"
38
+ @click="emit('cancel', row.id)"
39
+ />
40
+ <q-btn
41
+ v-if="row.canceledAt"
42
+ flat
43
+ dense
44
+ color="primary"
45
+ :label="i18n.bundleReactivateAction"
46
+ :loading="reactivatingId === row.id"
47
+ :disable="reactivatingId === row.id"
48
+ @click="emit('reactivate', row.id)"
49
+ />
50
+ </div>
51
+ </li>
52
+ </ul>
53
+ </div>
54
+
55
+ <!-- Available bundles -->
56
+ <div class="sp-bundle-store__available">
57
+ <div class="sp-bundle-store__subtitle">{{ i18n.bundlesAvailableTitle }}</div>
58
+ <div v-if="availableRows.length === 0" class="sp-bundle-store__empty">
59
+ {{ i18n.bundlesAvailableEmpty }}
60
+ </div>
61
+ <div v-else class="sp-bundle-store__grid">
62
+ <q-card
63
+ v-for="row in availableRows"
64
+ :key="row.bundle.bundleVersionId"
65
+ flat
66
+ bordered
67
+ class="sp-bundle-store__card"
68
+ :class="{ 'sp-bundle-store__card--disabled': row.state !== 'bookable' }"
69
+ >
70
+ <div class="sp-bundle-store__card-head">
71
+ <span class="sp-bundle-store__card-name">{{ row.bundle.label }}</span>
72
+ <span class="sp-bundle-store__card-price">
73
+ {{ formatCurrency(row.bundle.monthlyNet ?? 0) }}
74
+ <small>{{ i18n.bundlesPerMonth }}</small>
75
+ </span>
76
+ </div>
77
+
78
+ <q-badge
79
+ v-if="row.state === 'booked'"
80
+ color="positive"
81
+ :label="i18n.bundleAlreadyBooked"
82
+ class="sp-bundle-store__card-badge"
83
+ />
84
+ <q-badge
85
+ v-else-if="row.state === 'incompatible'"
86
+ color="grey-5"
87
+ text-color="grey-9"
88
+ :label="i18n.bundleIncompatible"
89
+ class="sp-bundle-store__card-badge"
90
+ />
91
+ <q-badge
92
+ v-else-if="row.state === 'missing-requires'"
93
+ color="grey-5"
94
+ text-color="grey-9"
95
+ :label="`${i18n.bundleMissingRequires}: ${missingRequiresOf(row.bundle)
96
+ .map(featureLabel)
97
+ .join(', ')}`"
98
+ class="sp-bundle-store__card-badge"
99
+ />
100
+
101
+ <p v-if="row.bundle.description" class="sp-bundle-store__card-desc">
102
+ {{ row.bundle.description }}
103
+ </p>
104
+ <div v-if="row.bundle.features.length > 0" class="sp-bundle-store__card-feats">
105
+ <div class="sp-bundle-store__card-feats-label">
106
+ {{ i18n.bundleIncludesLabel }}:
107
+ </div>
108
+ <ul>
109
+ <li v-for="f in row.bundle.features" :key="f">{{ featureLabel(f) }}</li>
110
+ </ul>
111
+ </div>
112
+ <q-btn
113
+ v-if="row.state === 'bookable'"
114
+ color="primary"
115
+ unelevated
116
+ class="sp-bundle-store__card-action"
117
+ :label="buyingId === row.bundle.bundleVersionId ? i18n.bundleBookInProgress : i18n.bundleBookAction"
118
+ :loading="buyingId === row.bundle.bundleVersionId"
119
+ :disable="buyingId !== null"
120
+ @click="emit('buy', row.bundle.bundleVersionId)"
121
+ />
122
+ </q-card>
123
+ </div>
124
+ </div>
125
+
126
+ <div v-if="error" class="sp-plan-section__error">{{ error }}</div>
127
+ </q-card-section>
128
+ </template>
129
+
130
+ <script setup lang="ts">
131
+ import { computed } from 'vue';
132
+ import { missingRequiresFor } from '@saasicat/types';
133
+ import type { TenantPlanSectionI18n } from '../default-i18n.js';
134
+ import type { CatalogBundle } from '../../use-tenant-billing-catalog.js';
135
+ import type { SubscriptionBundleShape } from '../../use-tenant-billing.js';
136
+
137
+ // TenantBundleStore — bundle sales on "Paket & Verbrauch" (#15):
138
+ // lists booked (cancelable) and available (bookable) catalog bundles.
139
+ // Source: `/billing/subscription-bundles` (booked) + `/billing/bundles`
140
+ // (catalog). Price/label of booked bundles are joined against the
141
+ // catalog via `bundleVersionId`.
142
+
143
+ const props = defineProps<{
144
+ /** Booked SubscriptionBundles (incl. canceled). */
145
+ booked: SubscriptionBundleShape[];
146
+ /** Full bundle catalog (unfiltered — also used for joining booked bundles). */
147
+ available: CatalogBundle[];
148
+ /** Features of the current plan — for the compatibility check (#22). */
149
+ planFeatures: string[];
150
+ i18n: TenantPlanSectionI18n;
151
+ formatCurrency: (n: number) => string;
152
+ formatDate: (iso: string) => string;
153
+ featureLabel: (key: string) => string;
154
+ /** bundleVersionId currently being booked (spinner). */
155
+ buyingId: string | null;
156
+ /** SubscriptionBundle id currently being canceled (spinner). */
157
+ cancelingId: string | null;
158
+ /** SubscriptionBundle id currently being reactivated (spinner). */
159
+ reactivatingId: string | null;
160
+ error: string | null;
161
+ }>();
162
+
163
+ const emit = defineEmits<{
164
+ buy: [bundleVersionId: string];
165
+ cancel: [subscriptionBundleId: string];
166
+ reactivate: [subscriptionBundleId: string];
167
+ }>();
168
+
169
+ const catalogByVersion = computed(
170
+ () => new Map(props.available.map((b) => [b.bundleVersionId, b])),
171
+ );
172
+
173
+ interface BookedRow {
174
+ id: string;
175
+ bundleVersionId: string;
176
+ label: string;
177
+ monthlyNet: number;
178
+ minimumTermEndsAt: string | null;
179
+ canceledAt: string | null;
180
+ canceledEffectiveAt: string | null;
181
+ }
182
+
183
+ const bookedRows = computed<BookedRow[]>(() =>
184
+ props.booked.map((b) => {
185
+ const cat = catalogByVersion.value.get(b.bundleVersionId);
186
+ return {
187
+ id: b.id,
188
+ bundleVersionId: b.bundleVersionId,
189
+ // Server label takes precedence; catalog join only as fallback; UUID as last resort.
190
+ label: b.label ?? cat?.label ?? b.bundleVersionId,
191
+ monthlyNet: b.monthlyNet != null ? Number(b.monthlyNet) : (cat?.monthlyNet ?? 0),
192
+ minimumTermEndsAt: b.minimumTermEndsAt,
193
+ canceledAt: b.canceledAt,
194
+ canceledEffectiveAt: b.canceledEffectiveAt,
195
+ };
196
+ }),
197
+ );
198
+
199
+ const activeBookedVersionIds = computed(
200
+ () => new Set(props.booked.filter((b) => !b.canceledAt).map((b) => b.bundleVersionId)),
201
+ );
202
+
203
+ const planFeatureSet = computed(() => new Set(props.planFeatures));
204
+
205
+ type BundleState = 'bookable' | 'booked' | 'incompatible' | 'missing-requires';
206
+
207
+ interface AvailableRow {
208
+ bundle: CatalogBundle;
209
+ state: BundleState;
210
+ }
211
+
212
+ // requires coverage (#35): plan features ∪ features of the actively booked
213
+ // bundles. On an active booking `planFeatures` usually already contains the
214
+ // bundle features (EntitlementSnapshot); the catalog join is the fallback
215
+ // for freshly booked bundles before the usage reload.
216
+ const coveredFeatureSet = computed(() => {
217
+ const covered = new Set(props.planFeatures);
218
+ for (const versionId of activeBookedVersionIds.value) {
219
+ for (const f of catalogByVersion.value.get(versionId)?.features ?? []) {
220
+ covered.add(f);
221
+ }
222
+ }
223
+ return covered;
224
+ });
225
+
226
+ function missingRequiresOf(b: CatalogBundle): string[] {
227
+ return missingRequiresFor(b, coveredFeatureSet.value);
228
+ }
229
+
230
+ // #22: All catalog bundles are shown (no longer hidden), but marked:
231
+ // already booked (not bookable again), incompatible, or missing requires
232
+ // prerequisites (#35 — grayed out instead of bookable).
233
+ // Incompatible = intersection of the bundle features with the plan features ≠ ∅
234
+ // (the bundle would sell already-included features twice). Quotas don't
235
+ // count — they act additively.
236
+ function resolveState(b: CatalogBundle): BundleState {
237
+ if (activeBookedVersionIds.value.has(b.bundleVersionId)) return 'booked';
238
+ if (b.features.some((f) => planFeatureSet.value.has(f))) return 'incompatible';
239
+ if (missingRequiresOf(b).length > 0) return 'missing-requires';
240
+ return 'bookable';
241
+ }
242
+
243
+ const availableRows = computed<AvailableRow[]>(() =>
244
+ props.available.map((b) => ({ bundle: b, state: resolveState(b) })),
245
+ );
246
+ </script>
247
+
248
+ <style scoped>
249
+ .sp-bundle-store__subtitle {
250
+ font-size: 13px;
251
+ font-weight: 600;
252
+ color: var(--sp-text-strong, rgba(0, 0, 0, 0.7));
253
+ margin: 4px 0 8px;
254
+ }
255
+ .sp-bundle-store__available {
256
+ margin-top: 16px;
257
+ }
258
+ .sp-bundle-store__booked-actions {
259
+ display: flex;
260
+ align-items: center;
261
+ gap: 12px;
262
+ }
263
+ .sp-bundle-store__empty {
264
+ color: var(--sp-text-muted, rgba(0, 0, 0, 0.55));
265
+ font-size: 13px;
266
+ }
267
+ .sp-bundle-store__grid {
268
+ display: grid;
269
+ grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
270
+ gap: 16px;
271
+ }
272
+ .sp-bundle-store__card {
273
+ padding: 14px;
274
+ display: flex;
275
+ flex-direction: column;
276
+ gap: 8px;
277
+ }
278
+ .sp-bundle-store__card--disabled {
279
+ opacity: 0.6;
280
+ }
281
+ .sp-bundle-store__card-badge {
282
+ align-self: flex-start;
283
+ }
284
+ .sp-bundle-store__card-head {
285
+ display: flex;
286
+ justify-content: space-between;
287
+ align-items: baseline;
288
+ gap: 8px;
289
+ }
290
+ .sp-bundle-store__card-name {
291
+ font-weight: 600;
292
+ font-size: 15px;
293
+ }
294
+ .sp-bundle-store__card-price {
295
+ font-weight: 600;
296
+ white-space: nowrap;
297
+ }
298
+ .sp-bundle-store__card-price small {
299
+ font-weight: 400;
300
+ color: var(--sp-text-muted, rgba(0, 0, 0, 0.55));
301
+ }
302
+ .sp-bundle-store__card-desc {
303
+ margin: 0;
304
+ font-size: 13px;
305
+ color: var(--sp-text-secondary, rgba(0, 0, 0, 0.6));
306
+ }
307
+ .sp-bundle-store__card-feats {
308
+ font-size: 12px;
309
+ color: var(--sp-text-muted, rgba(0, 0, 0, 0.55));
310
+ }
311
+ .sp-bundle-store__card-feats-label {
312
+ font-weight: 500;
313
+ margin-bottom: 2px;
314
+ }
315
+ .sp-bundle-store__card-feats ul {
316
+ list-style: disc;
317
+ padding-left: 18px;
318
+ margin: 0;
319
+ }
320
+ .sp-bundle-store__card-action {
321
+ margin-top: auto;
322
+ }
323
+ </style>
@@ -0,0 +1,122 @@
1
+ <template>
2
+ <q-card-section>
3
+ <div class="sp-plan-section__usage-title">{{ i18n.featuresOverviewTitle }}</div>
4
+ <ul class="sp-feature-matrix">
5
+ <li
6
+ v-for="f in features"
7
+ :key="f.key"
8
+ class="sp-feature-matrix__row"
9
+ :class="{ 'sp-feature-matrix__row--locked': !f.active }"
10
+ >
11
+ <q-icon
12
+ :name="f.active ? 'check_circle' : 'lock'"
13
+ :color="f.active ? 'positive' : 'grey-5'"
14
+ size="20px"
15
+ class="sp-feature-matrix__status"
16
+ />
17
+ <div class="sp-feature-matrix__text">
18
+ <div class="sp-feature-matrix__label">
19
+ <q-icon v-if="f.icon" :name="f.icon" size="16px" class="q-mr-xs" />
20
+ {{ f.label }}
21
+ <q-badge
22
+ v-if="!f.active"
23
+ color="grey-4"
24
+ text-color="grey-9"
25
+ :label="i18n.featuresLocked"
26
+ class="q-ml-sm"
27
+ />
28
+ </div>
29
+ <div v-if="f.description" class="sp-feature-matrix__desc">
30
+ {{ f.description }}
31
+ </div>
32
+ </div>
33
+ </li>
34
+ </ul>
35
+ </q-card-section>
36
+ </template>
37
+
38
+ <script setup lang="ts">
39
+ import { computed } from 'vue';
40
+ import type { FeatureUiRegistry } from '@saasicat/types';
41
+ import type { TenantPlanSectionI18n } from '../default-i18n.js';
42
+
43
+ // TenantFeatureMatrix — complete feature-scope overview (#18):
44
+ // all known features, split into included/locked, with translation
45
+ // from the feature registry (fallback: featureLabel hook → key).
46
+
47
+ const props = defineProps<{
48
+ /** Feature registry (label/description/icon per FeatureKey). */
49
+ featureRegistry: FeatureUiRegistry | null;
50
+ /** FeatureKeys unlocked in the current plan. */
51
+ activeFeatures: string[];
52
+ /** Fallback label if the registry does not know the key. */
53
+ featureLabel: (key: string) => string;
54
+ i18n: TenantPlanSectionI18n;
55
+ }>();
56
+
57
+ interface FeatureRow {
58
+ key: string;
59
+ active: boolean;
60
+ label: string;
61
+ description: string | null;
62
+ icon: string | null;
63
+ }
64
+
65
+ const features = computed<FeatureRow[]>(() => {
66
+ const registry = props.featureRegistry ?? {};
67
+ const active = new Set(props.activeFeatures);
68
+ // Union: all registry keys + all active keys (in case an active
69
+ // feature is exceptionally not present in the registry).
70
+ const keys = new Set<string>([...Object.keys(registry), ...props.activeFeatures]);
71
+ const rows: FeatureRow[] = [...keys].map((key) => {
72
+ const meta = registry[key];
73
+ return {
74
+ key,
75
+ active: active.has(key),
76
+ label: meta?.label ?? props.featureLabel(key),
77
+ description: meta?.description ?? null,
78
+ icon: meta?.icon ?? null,
79
+ };
80
+ });
81
+ // Included first, then alphabetically by label.
82
+ return rows.sort((a, b) => {
83
+ if (a.active !== b.active) return a.active ? -1 : 1;
84
+ return a.label.localeCompare(b.label, 'de');
85
+ });
86
+ });
87
+ </script>
88
+
89
+ <style scoped>
90
+ .sp-feature-matrix {
91
+ list-style: none;
92
+ padding: 0;
93
+ margin: 0;
94
+ display: grid;
95
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
96
+ gap: 8px 24px;
97
+ }
98
+ .sp-feature-matrix__row {
99
+ display: flex;
100
+ gap: 10px;
101
+ align-items: flex-start;
102
+ padding: 6px 0;
103
+ }
104
+ .sp-feature-matrix__row--locked {
105
+ opacity: 0.6;
106
+ }
107
+ .sp-feature-matrix__status {
108
+ margin-top: 1px;
109
+ flex: 0 0 auto;
110
+ }
111
+ .sp-feature-matrix__label {
112
+ font-weight: 500;
113
+ display: flex;
114
+ align-items: center;
115
+ flex-wrap: wrap;
116
+ }
117
+ .sp-feature-matrix__desc {
118
+ font-size: 12px;
119
+ color: var(--sp-text-muted, rgba(0, 0, 0, 0.55));
120
+ margin-top: 2px;
121
+ }
122
+ </style>
@@ -0,0 +1,67 @@
1
+ <template>
2
+ <q-card-section class="sp-plan-section__card-head">
3
+ <div>
4
+ <div class="sp-plan-section__eyebrow">
5
+ {{ i18n.activePlan }}
6
+ </div>
7
+ <h2 class="sp-plan-section__plan-name">{{ currentPlanName }}</h2>
8
+ <div class="sp-plan-section__meta">
9
+ <q-badge :color="statusColor" :label="statusLabel" />
10
+ <span class="sp-plan-section__cycle">{{ cycleLabel }}</span>
11
+ <span v-if="currentPriceEur !== null" class="sp-plan-section__price">
12
+ {{ formatCurrency(currentPriceEur) }}
13
+ {{ currentPriceUnit }}
14
+ </span>
15
+ </div>
16
+ <p v-if="nextBillingDate" class="sp-plan-section__sub">
17
+ {{ i18n.nextBillingDate }}: {{ formatDate(nextBillingDate) }}
18
+ </p>
19
+ <p v-if="usage.status === 'TRIAL' && usage.trialEndsAt" class="sp-plan-section__sub">
20
+ {{ i18n.trialEndsAt }}: {{ formatDate(usage.trialEndsAt) }}
21
+ </p>
22
+ <p v-if="usage.isPilot && usage.pilotEndsAt" class="sp-plan-section__sub">
23
+ {{ i18n.pilotEndsAt }}: {{ formatDate(usage.pilotEndsAt) }}
24
+ </p>
25
+ <p v-if="usage.pendingPlan" class="sp-plan-section__sub">
26
+ {{ i18n.pendingChange }}:
27
+ {{ i18n.changeFromTo }}
28
+ <strong>{{ usage.pendingPlan }}</strong>
29
+ <template v-if="usage.pendingEffectiveAt">
30
+ — {{ i18n.changeEffectiveAt }}
31
+ {{ formatDate(usage.pendingEffectiveAt) }}
32
+ </template>
33
+ </p>
34
+ </div>
35
+ <div class="sp-plan-section__actions">
36
+ <q-btn
37
+ color="primary"
38
+ unelevated
39
+ :label="i18n.changePlanButton"
40
+ @click="emit('changePlan')"
41
+ />
42
+ </div>
43
+ </q-card-section>
44
+ </template>
45
+
46
+ <script setup lang="ts">
47
+ import type { TenantPlanSectionI18n } from '../default-i18n.js';
48
+ import type { UsageSnapshotShape } from '../../use-tenant-billing.js';
49
+
50
+ defineProps<{
51
+ usage: UsageSnapshotShape;
52
+ i18n: TenantPlanSectionI18n;
53
+ currentPlanName: string;
54
+ statusColor: string;
55
+ statusLabel: string;
56
+ cycleLabel: string;
57
+ currentPriceEur: number | null;
58
+ currentPriceUnit: string;
59
+ nextBillingDate: string | null;
60
+ formatCurrency: (value: number) => string;
61
+ formatDate: (value: string | Date) => string;
62
+ }>();
63
+
64
+ const emit = defineEmits<{
65
+ (e: 'changePlan'): void;
66
+ }>();
67
+ </script>
@@ -0,0 +1,31 @@
1
+ <template>
2
+ <q-card-section>
3
+ <div class="sp-plan-section__usage-title">{{ i18n.usageTitle }}</div>
4
+ <div class="sp-plan-section__usage-grid">
5
+ <UsageBar
6
+ v-for="key in catalogQuotaKeys"
7
+ :key="key"
8
+ :label="quotaLabel(key)"
9
+ :used="usage.usage[key] ?? 0"
10
+ :max="usage.limits.quotas[key] ?? 0"
11
+ :fractional="isFractionalQuota(key)"
12
+ :format-value="usageBarFormatter(key)"
13
+ />
14
+ </div>
15
+ </q-card-section>
16
+ </template>
17
+
18
+ <script setup lang="ts">
19
+ import type { TenantPlanSectionI18n } from '../default-i18n.js';
20
+ import type { UsageSnapshotShape } from '../../use-tenant-billing.js';
21
+ import UsageBar from '../UsageBar.vue';
22
+
23
+ defineProps<{
24
+ usage: UsageSnapshotShape;
25
+ i18n: TenantPlanSectionI18n;
26
+ catalogQuotaKeys: string[];
27
+ quotaLabel: (key: string) => string;
28
+ isFractionalQuota: (key: string) => boolean;
29
+ usageBarFormatter: (key: string) => ((value: number) => string) | undefined;
30
+ }>();
31
+ </script>