@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,417 @@
1
+ <template>
2
+ <div class="sp-onb">
3
+ <slot name="header" :pricing="draft.pricing.value">
4
+ <header class="sp-onb__header">
5
+ <div>
6
+ <div class="sp-onb__eyebrow">{{ i18n.eyebrow }}</div>
7
+ <h1>{{ i18n.title }}</h1>
8
+ <p>{{ i18n.subtitle }}</p>
9
+ </div>
10
+ <PlanCycleToggle
11
+ :model-value="draft.cycle.value"
12
+ :i18n="i18n.cycle"
13
+ @update:model-value="draft.setCycle"
14
+ />
15
+ </header>
16
+ </slot>
17
+
18
+ <div class="sp-onb__layout">
19
+ <div class="sp-onb__main">
20
+ <!-- ═══ Plan ═══ -->
21
+ <section class="sp-onb__section">
22
+ <header class="sp-onb__sec-head">
23
+ <div class="sp-onb__sec-num">01 · {{ i18n.sections.plan.eyebrow }}</div>
24
+ <h2>{{ i18n.sections.plan.title }}</h2>
25
+ <p>{{ i18n.sections.plan.subtitle }}</p>
26
+ </header>
27
+ <PlanGrid
28
+ v-if="plans"
29
+ :model-value="draft.plan.value"
30
+ :plans="plans"
31
+ :cycle="draft.cycle.value"
32
+ :catalog-quota-keys="catalogQuotaKeys"
33
+ :current-plan-id="currentPlanId ?? null"
34
+ :format-currency="formatCurrency"
35
+ :format-quota-value="formatQuotaValue"
36
+ :quota-label="quotaLabel"
37
+ :i18n="i18n.plan"
38
+ @update:model-value="draft.setPlan"
39
+ />
40
+ <div v-else class="sp-onb__loading">{{ i18n.loading }}</div>
41
+ </section>
42
+
43
+ <!-- ═══ Bundles ═══ -->
44
+ <section v-if="bookableSubscriptionBundles.length > 0" class="sp-onb__section">
45
+ <header class="sp-onb__sec-head">
46
+ <div class="sp-onb__sec-num">02 · {{ i18n.sections.bundles.eyebrow }}</div>
47
+ <h2>{{ i18n.sections.bundles.title }}</h2>
48
+ <p>{{ i18n.sections.bundles.subtitle }}</p>
49
+ </header>
50
+ <PublicBundleGrid
51
+ :bundles="bookableSubscriptionBundles"
52
+ :selected="draft.selectedBundleVersionIds.value"
53
+ :cycle="draft.cycle.value"
54
+ :plan-features="selectedPlanFeatures"
55
+ :format-currency="formatCurrency"
56
+ :feature-label="featureLabel"
57
+ :quota-label="quotaLabel"
58
+ :i18n="i18n.bundles"
59
+ @toggle="draft.toggleSubscriptionBundle"
60
+ />
61
+ </section>
62
+
63
+ <div class="sp-onb__actions">
64
+ <button v-if="onBack" type="button" class="sp-onb__back-btn" @click="onBack">
65
+ ← {{ i18n.back }}
66
+ </button>
67
+ <div v-if="submitError" class="sp-onb__error">{{ submitError }}</div>
68
+ </div>
69
+ </div>
70
+
71
+ <PriceSummary
72
+ :pricing="draft.pricing.value"
73
+ :plan-name="draft.selectedPlan.value?.name ?? null"
74
+ :format-currency="formatCurrency"
75
+ :cta-label="submitting ? i18n.submitting : i18n.submit"
76
+ :cta-disabled="submitting || !draft.plan.value"
77
+ :i18n="i18n.summary"
78
+ @submit="handleSubmit"
79
+ >
80
+ <template #promo-input>
81
+ <PromoCodeInput
82
+ v-if="enablePromo"
83
+ :model-value="draft.promoCode.value"
84
+ :state="draft.promoState.value"
85
+ :i18n="i18n.promo"
86
+ @update:model-value="draft.setPromoCode"
87
+ @apply="handleApplyPromo"
88
+ @remove="draft.clearPromo"
89
+ />
90
+ </template>
91
+ </PriceSummary>
92
+ </div>
93
+ </div>
94
+ </template>
95
+
96
+ <script setup lang="ts">
97
+ import { computed, ref } from 'vue';
98
+ import PlanCycleToggle from '../components/plan/PlanCycleToggle.vue';
99
+ import PlanGrid from '../components/plan/PlanGrid.vue';
100
+ import PublicBundleGrid from '../components/plan/PublicBundleGrid.vue';
101
+ import PromoCodeInput from '../components/plan/PromoCodeInput.vue';
102
+ import PriceSummary from '../components/plan/PriceSummary.vue';
103
+ import {
104
+ useSubscriptionDraft,
105
+ type PromoState,
106
+ type SubscriptionDraft,
107
+ } from '../use-subscription-draft.js';
108
+ import type { CatalogPlan } from '../use-tenant-billing-catalog.js';
109
+ import type { BillingCycleStr } from '../use-tenant-billing.js';
110
+ import type {
111
+ OnboardingSelectionRequest,
112
+ OnboardingSelectionResponse,
113
+ PromoPreviewRequest,
114
+ PromoPreviewResponse,
115
+ PublicMarketingBundle,
116
+ } from '@saasicat/types';
117
+
118
+ // OnboardingConfigurator — page component for tenant self-service onboarding.
119
+ // Composes the sub-components, holds the draft state, calls the consumer's
120
+ // submit/preview hooks. Deliberately HTTP-free: the consumer injects
121
+ // `previewPromo()` + `submit()` via the props (Axios wrapper or similar).
122
+
123
+ interface SectionI18n {
124
+ eyebrow: string;
125
+ title: string;
126
+ subtitle: string;
127
+ }
128
+
129
+ interface OnboardingI18n {
130
+ eyebrow: string;
131
+ title: string;
132
+ subtitle: string;
133
+ loading: string;
134
+ back: string;
135
+ submit: string;
136
+ submitting: string;
137
+ sections: {
138
+ plan: SectionI18n;
139
+ bundles: SectionI18n;
140
+ };
141
+ cycle: { ariaLabel: string; monthly: string; yearly: string; savePill?: string };
142
+ plan: {
143
+ popular: string;
144
+ current: string;
145
+ perMonth: string;
146
+ perYear: string;
147
+ priceOnRequest: string;
148
+ };
149
+ bundles: {
150
+ perMonth: string;
151
+ perYear: string;
152
+ empty: string;
153
+ allPlans: string;
154
+ priceOnRequest: string;
155
+ /** Bundle fully covered by the plan/other bundles — not bookable twice. */
156
+ alreadyBooked: string;
157
+ /** Prefix before the list of missing prerequisite features. */
158
+ missingRequires: string;
159
+ };
160
+ promo: { openLabel: string; placeholder: string; apply: string; remove: string };
161
+ summary: {
162
+ noPlan: string;
163
+ cycleMonthly: string;
164
+ cycleYearly: string;
165
+ sectionPlan: string;
166
+ sectionBundles: string;
167
+ empty: string;
168
+ subtotal: string;
169
+ discount: string;
170
+ total: string;
171
+ totalUnitMonthly: string;
172
+ totalUnitYearly: string;
173
+ yearSavings: string;
174
+ disclaimer?: string;
175
+ };
176
+ /** Derived from the backend reason string (`PromoPreviewInvalidReason` → display text). */
177
+ promoReason: Record<string, string>;
178
+ }
179
+
180
+ const props = defineProps<{
181
+ plans: CatalogPlan[] | null;
182
+ /** Independently bookable bundles from `PublicMarketingCatalogResponse.bundles`. */
183
+ availableBundles?: PublicMarketingBundle[];
184
+ catalogQuotaKeys: string[];
185
+ initialPlan?: string | null;
186
+ initialCycle?: BillingCycleStr;
187
+ currentPlanId?: string | null;
188
+ enablePromo?: boolean;
189
+ formatCurrency: (n: number) => string;
190
+ formatQuotaValue: (key: string, value: number) => string;
191
+ quotaLabel: (key: string) => string;
192
+ featureLabel: (key: string) => string;
193
+ /** Async hook for `POST /billing/promo/preview`. */
194
+ previewPromo?: (req: PromoPreviewRequest) => Promise<PromoPreviewResponse>;
195
+ /** Async hook for `POST /billing/onboarding/initial-subscription`. */
196
+ submit: (payload: OnboardingSelectionRequest) => Promise<OnboardingSelectionResponse>;
197
+ /** Optional: back-button handler. */
198
+ onBack?: () => void;
199
+ i18n: OnboardingI18n;
200
+ }>();
201
+
202
+ const emit = defineEmits<{
203
+ submitted: [OnboardingSelectionResponse];
204
+ error: [Error];
205
+ }>();
206
+
207
+ const subscriptionBundleOptions = computed(() => props.availableBundles ?? []);
208
+
209
+ const draft: SubscriptionDraft = useSubscriptionDraft({
210
+ plans: computed(() => props.plans),
211
+ subscriptionBundles: subscriptionBundleOptions,
212
+ initialPlan: props.initialPlan ?? null,
213
+ initialCycle: props.initialCycle ?? 'YEARLY',
214
+ });
215
+
216
+ const submitting = ref(false);
217
+ const submitError = ref<string | null>(null);
218
+
219
+ const selectedPlanFeatures = computed(() => draft.selectedPlan.value?.features ?? []);
220
+
221
+ const bookableSubscriptionBundles = computed(() => {
222
+ const selectedPlan = draft.plan.value;
223
+ return subscriptionBundleOptions.value.filter((bundle) => {
224
+ if (bundle.monthlyNet === null && bundle.yearlyNet === null) return false;
225
+ if (!selectedPlan) return true;
226
+ return (
227
+ bundle.compatiblePlanKeys.length === 0 ||
228
+ bundle.compatiblePlanKeys.includes(selectedPlan)
229
+ );
230
+ });
231
+ });
232
+
233
+ async function handleApplyPromo(): Promise<void> {
234
+ if (!props.previewPromo || !draft.promoCode.value || !draft.plan.value) return;
235
+ draft.setPromoState({ status: 'checking', preview: null, message: '' });
236
+ try {
237
+ const res = await props.previewPromo({
238
+ code: draft.promoCode.value,
239
+ plan: draft.plan.value,
240
+ billingCycle: draft.cycle.value,
241
+ });
242
+ if (res.valid) {
243
+ draft.setPromoState({
244
+ status: 'valid',
245
+ preview: res,
246
+ message: res.label,
247
+ });
248
+ } else {
249
+ const reason = res.reason;
250
+ const restricted = reason === 'BILLING_MISMATCH' || reason === 'PLAN_MISMATCH';
251
+ draft.setPromoState({
252
+ status: restricted ? 'restricted' : 'invalid',
253
+ preview: res,
254
+ message: props.i18n.promoReason[reason] ?? reason,
255
+ });
256
+ }
257
+ } catch (err) {
258
+ draft.setPromoState({
259
+ status: 'invalid',
260
+ preview: null,
261
+ message: err instanceof Error ? err.message : String(err),
262
+ });
263
+ }
264
+ }
265
+
266
+ async function handleSubmit(): Promise<void> {
267
+ if (!draft.plan.value || submitting.value) return;
268
+ submitting.value = true;
269
+ submitError.value = null;
270
+ try {
271
+ const payload = draft.toApiPayload();
272
+ const result = await props.submit(payload);
273
+ emit('submitted', result);
274
+ } catch (err) {
275
+ const e = err instanceof Error ? err : new Error(String(err));
276
+ submitError.value = e.message;
277
+ emit('error', e);
278
+ } finally {
279
+ submitting.value = false;
280
+ }
281
+ }
282
+
283
+ defineExpose({ draft });
284
+ </script>
285
+
286
+ <style scoped>
287
+ .sp-onb {
288
+ padding: 28px 24px 80px;
289
+ max-width: 1480px;
290
+ margin: 0 auto;
291
+ }
292
+ .sp-onb__header {
293
+ margin-bottom: 22px;
294
+ display: flex;
295
+ flex-wrap: wrap;
296
+ align-items: flex-end;
297
+ justify-content: space-between;
298
+ gap: 20px;
299
+ }
300
+ .sp-onb__header > div:first-child {
301
+ flex: 1 1 320px;
302
+ min-width: 0;
303
+ }
304
+ .sp-onb__eyebrow {
305
+ display: inline-flex;
306
+ align-items: center;
307
+ gap: 8px;
308
+ font-family: 'SF Mono', Consolas, monospace;
309
+ font-size: 11px;
310
+ letter-spacing: 0.1em;
311
+ text-transform: uppercase;
312
+ color: var(--q-primary, #0f766e);
313
+ background: rgba(15, 118, 110, 0.08);
314
+ padding: 6px 11px;
315
+ border-radius: 999px;
316
+ border: 1px solid rgba(15, 118, 110, 0.2);
317
+ }
318
+ .sp-onb__header h1 {
319
+ margin: 12px 0 8px;
320
+ font-size: clamp(1.6rem, 3vw, 2.4rem);
321
+ line-height: 1.1;
322
+ letter-spacing: -0.01em;
323
+ }
324
+ .sp-onb__header p {
325
+ margin: 0;
326
+ color: rgba(0, 0, 0, 0.6);
327
+ max-width: 640px;
328
+ }
329
+ .sp-onb__layout {
330
+ display: grid;
331
+ grid-template-columns: minmax(0, 1fr) 380px;
332
+ gap: 24px;
333
+ align-items: start;
334
+ }
335
+ @media (max-width: 1100px) {
336
+ .sp-onb__layout {
337
+ grid-template-columns: 1fr;
338
+ }
339
+ }
340
+ .sp-onb__main {
341
+ display: flex;
342
+ flex-direction: column;
343
+ gap: 20px;
344
+ }
345
+ .sp-onb__section {
346
+ background: #fff;
347
+ border: 1px solid rgba(0, 0, 0, 0.08);
348
+ border-radius: 22px;
349
+ padding: 26px 28px 24px;
350
+ box-shadow: 0 24px 80px rgba(31, 41, 51, 0.05);
351
+ }
352
+ .sp-onb__sec-head {
353
+ padding-bottom: 14px;
354
+ margin-bottom: 18px;
355
+ border-bottom: 1px dashed rgba(0, 0, 0, 0.08);
356
+ }
357
+ .sp-onb__sec-head h2 {
358
+ margin: 6px 0 0;
359
+ font-size: 1.4rem;
360
+ letter-spacing: -0.005em;
361
+ }
362
+ .sp-onb__sec-head p {
363
+ margin: 4px 0 0;
364
+ color: rgba(0, 0, 0, 0.55);
365
+ font-size: 13px;
366
+ max-width: 520px;
367
+ }
368
+ .sp-onb__sec-num {
369
+ display: inline-flex;
370
+ align-items: center;
371
+ gap: 8px;
372
+ font-family: 'SF Mono', Consolas, monospace;
373
+ font-size: 11px;
374
+ letter-spacing: 0.16em;
375
+ text-transform: uppercase;
376
+ color: var(--q-primary, #0f766e);
377
+ font-weight: 700;
378
+ }
379
+ .sp-onb__sec-num::before {
380
+ content: '';
381
+ width: 28px;
382
+ height: 1px;
383
+ background: var(--q-primary, #0f766e);
384
+ }
385
+ .sp-onb__loading {
386
+ padding: 40px;
387
+ text-align: center;
388
+ color: rgba(0, 0, 0, 0.5);
389
+ }
390
+ .sp-onb__actions {
391
+ display: flex;
392
+ justify-content: space-between;
393
+ align-items: center;
394
+ gap: 12px;
395
+ padding-top: 8px;
396
+ }
397
+ .sp-onb__back-btn {
398
+ background: none;
399
+ border: 1px solid rgba(0, 0, 0, 0.15);
400
+ border-radius: 10px;
401
+ padding: 10px 16px;
402
+ font-family: inherit;
403
+ font-size: 13px;
404
+ cursor: pointer;
405
+ color: rgba(0, 0, 0, 0.7);
406
+ }
407
+ .sp-onb__back-btn:hover {
408
+ border-color: rgba(0, 0, 0, 0.4);
409
+ }
410
+ .sp-onb__error {
411
+ color: #b91c1c;
412
+ background: rgba(220, 38, 38, 0.08);
413
+ padding: 10px 14px;
414
+ border-radius: 8px;
415
+ font-size: 13px;
416
+ }
417
+ </style>
@@ -0,0 +1,241 @@
1
+ <template>
2
+ <q-card flat bordered class="sp-package-snapshot">
3
+ <q-card-section class="sp-package-snapshot__head">
4
+ <div>
5
+ <div class="sp-package-snapshot__eyebrow">{{ i18n.packageSnapshotTitle }}</div>
6
+ <p class="sp-package-snapshot__sub">{{ i18n.packageSnapshotSubtitle }}</p>
7
+ </div>
8
+ <q-chip
9
+ v-if="snapshot && capturedAtIso"
10
+ dense
11
+ outline
12
+ color="grey-7"
13
+ :label="`${i18n.packageSnapshotCapturedAt}: ${formatDate(capturedAtIso)}`"
14
+ />
15
+ </q-card-section>
16
+
17
+ <q-separator />
18
+
19
+ <q-card-section v-if="!snapshot" class="sp-package-snapshot__empty">
20
+ {{ i18n.packageSnapshotNone }}
21
+ </q-card-section>
22
+
23
+ <q-card-section v-else>
24
+ <dl class="sp-package-snapshot__list">
25
+ <template v-if="snapshot.label">
26
+ <dt>{{ i18n.activePlan }}</dt>
27
+ <dd>{{ snapshot.label }}</dd>
28
+ </template>
29
+
30
+ <template v-if="snapshot.planId">
31
+ <dt>{{ i18n.packageSnapshotPlanLabel }}</dt>
32
+ <dd>
33
+ <code>{{ snapshot.planId }}</code>
34
+ <span v-if="snapshot.planVersionId" class="sp-package-snapshot__muted">
35
+ ({{ i18n.packageSnapshotPlanVersionLabel }}:
36
+ <code>{{ snapshot.planVersionId }}</code
37
+ >)
38
+ </span>
39
+ </dd>
40
+ </template>
41
+
42
+ <template v-if="cycleLabel">
43
+ <dt>{{ i18n.packageSnapshotCycleLabel }}</dt>
44
+ <dd>{{ cycleLabel }}</dd>
45
+ </template>
46
+
47
+ <template v-if="checkoutOfferId">
48
+ <dt>{{ i18n.packageSnapshotOfferRef }}</dt>
49
+ <dd>
50
+ <code>{{ checkoutOfferId }}</code>
51
+ </dd>
52
+ </template>
53
+
54
+ <template v-if="snapshot.priceMonthlyNet != null">
55
+ <dt>{{ i18n.packageSnapshotPriceMonthly }}</dt>
56
+ <dd>{{ formatCurrency(snapshot.priceMonthlyNet) }}</dd>
57
+ </template>
58
+
59
+ <template v-if="snapshot.priceYearlyNet != null">
60
+ <dt>{{ i18n.packageSnapshotPriceYearly }}</dt>
61
+ <dd>{{ formatCurrency(snapshot.priceYearlyNet) }}</dd>
62
+ </template>
63
+
64
+ <template v-if="snapshot.priceTotalNet != null">
65
+ <dt>{{ i18n.packageSnapshotPriceTotal }}</dt>
66
+ <dd>
67
+ <strong>{{ formatCurrency(snapshot.priceTotalNet) }}</strong>
68
+ </dd>
69
+ </template>
70
+
71
+ <template v-if="bundleVersionIds">
72
+ <dt>{{ i18n.packageSnapshotBundlesLabel }}</dt>
73
+ <dd>
74
+ <span
75
+ v-if="bundleVersionIds.length === 0"
76
+ class="sp-package-snapshot__muted"
77
+ >
78
+ {{ i18n.packageSnapshotBundlesEmpty }}
79
+ </span>
80
+ <ul v-else class="sp-package-snapshot__bundles">
81
+ <li v-for="id in bundleVersionIds" :key="id">
82
+ <code>{{ id }}</code>
83
+ </li>
84
+ </ul>
85
+ </dd>
86
+ </template>
87
+ </dl>
88
+
89
+ <div class="sp-package-snapshot__raw">
90
+ <q-btn
91
+ flat
92
+ dense
93
+ no-caps
94
+ size="sm"
95
+ color="primary"
96
+ :label="showRaw ? i18n.packageSnapshotHideRaw : i18n.packageSnapshotShowRaw"
97
+ :icon="showRaw ? 'expand_less' : 'expand_more'"
98
+ @click="showRaw = !showRaw"
99
+ />
100
+ <pre v-if="showRaw" class="sp-package-snapshot__raw-body">{{ rawJson }}</pre>
101
+ </div>
102
+ </q-card-section>
103
+ </q-card>
104
+ </template>
105
+
106
+ <script setup lang="ts">
107
+ import { computed, ref } from 'vue';
108
+ import type { PackageSnapshotShape } from '../use-tenant-billing.js';
109
+ import type { TenantPlanSectionI18n } from './default-i18n.js';
110
+
111
+ // PackageSnapshotPanel — P11.4 (METAMODELL §17a):
112
+ // Read-only display of `Subscription.packageSnapshot`. Shows the
113
+ // tenant the frozen website offer (offer snapshot) — plan,
114
+ // bundles, prices, capture timestamp. No actions, no diff against
115
+ // the current state. Deliberately defensive against missing fields, because the
116
+ // snapshot schema keeps expanding and older subscriptions are narrower.
117
+
118
+ interface Props {
119
+ snapshot: PackageSnapshotShape | null;
120
+ checkoutOfferId: string | null;
121
+ i18n: TenantPlanSectionI18n;
122
+ formatDate: (iso: string | Date) => string;
123
+ formatCurrency: (n: number) => string;
124
+ }
125
+
126
+ const props = defineProps<Props>();
127
+
128
+ const showRaw = ref(false);
129
+
130
+ const capturedAtIso = computed(() => {
131
+ if (!props.snapshot) return null;
132
+ return typeof props.snapshot.capturedAt === 'string' ? props.snapshot.capturedAt : null;
133
+ });
134
+
135
+ const bundleVersionIds = computed<string[] | null>(() => {
136
+ const ids = props.snapshot?.bundleVersionIds;
137
+ if (!Array.isArray(ids)) return null;
138
+ return ids.filter((id): id is string => typeof id === 'string');
139
+ });
140
+
141
+ const cycleLabel = computed(() => {
142
+ const cycle = props.snapshot?.billingCycle;
143
+ if (cycle === 'YEARLY') return props.i18n.cycleYearly;
144
+ if (cycle === 'MONTHLY') return props.i18n.cycleMonthly;
145
+ return null;
146
+ });
147
+
148
+ const rawJson = computed(() => {
149
+ if (!props.snapshot) return '';
150
+ try {
151
+ return JSON.stringify(props.snapshot, null, 2);
152
+ } catch {
153
+ return String(props.snapshot);
154
+ }
155
+ });
156
+ </script>
157
+
158
+ <style scoped>
159
+ .sp-package-snapshot {
160
+ --sp-text-muted: rgba(0, 0, 0, 0.55);
161
+ --sp-text-strong: rgba(0, 0, 0, 0.85);
162
+ --sp-border: rgba(0, 0, 0, 0.08);
163
+ --sp-pre-bg: rgba(0, 0, 0, 0.04);
164
+ }
165
+ body.body--dark .sp-package-snapshot {
166
+ --sp-text-muted: rgba(255, 255, 255, 0.62);
167
+ --sp-text-strong: rgba(255, 255, 255, 0.85);
168
+ --sp-border: rgba(255, 255, 255, 0.16);
169
+ --sp-pre-bg: rgba(255, 255, 255, 0.06);
170
+ }
171
+ .sp-package-snapshot__head {
172
+ display: flex;
173
+ justify-content: space-between;
174
+ align-items: flex-start;
175
+ gap: 16px;
176
+ flex-wrap: wrap;
177
+ }
178
+ .sp-package-snapshot__eyebrow {
179
+ font-size: 12px;
180
+ text-transform: uppercase;
181
+ letter-spacing: 0.05em;
182
+ color: var(--sp-text-muted);
183
+ margin-bottom: 4px;
184
+ font-weight: 600;
185
+ }
186
+ .sp-package-snapshot__sub {
187
+ margin: 0;
188
+ color: var(--sp-text-muted);
189
+ font-size: 13px;
190
+ }
191
+ .sp-package-snapshot__empty {
192
+ color: var(--sp-text-muted);
193
+ font-size: 13px;
194
+ font-style: italic;
195
+ }
196
+ .sp-package-snapshot__list {
197
+ display: grid;
198
+ grid-template-columns: max-content 1fr;
199
+ column-gap: 24px;
200
+ row-gap: 8px;
201
+ margin: 0;
202
+ }
203
+ .sp-package-snapshot__list dt {
204
+ font-weight: 600;
205
+ color: var(--sp-text-strong);
206
+ }
207
+ .sp-package-snapshot__list dd {
208
+ margin: 0;
209
+ color: var(--sp-text-strong);
210
+ }
211
+ .sp-package-snapshot__muted {
212
+ color: var(--sp-text-muted);
213
+ font-size: 13px;
214
+ }
215
+ .sp-package-snapshot__bundles {
216
+ margin: 0;
217
+ padding-left: 16px;
218
+ list-style: disc;
219
+ }
220
+ .sp-package-snapshot__raw {
221
+ margin-top: 16px;
222
+ border-top: 1px solid var(--sp-border);
223
+ padding-top: 12px;
224
+ }
225
+ .sp-package-snapshot__raw-body {
226
+ margin: 8px 0 0;
227
+ padding: 12px;
228
+ background: var(--sp-pre-bg);
229
+ border-radius: 4px;
230
+ font-size: 12px;
231
+ overflow-x: auto;
232
+ max-height: 320px;
233
+ }
234
+ code {
235
+ font-family: 'SFMono-Regular', Menlo, Consolas, monospace;
236
+ font-size: 12px;
237
+ background: var(--sp-pre-bg);
238
+ padding: 1px 4px;
239
+ border-radius: 3px;
240
+ }
241
+ </style>