@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,861 @@
1
+ <template>
2
+ <div class="mc-promo">
3
+ <!-- Head -->
4
+ <div class="mc-promo-head">
5
+ <div class="mc-promo-head-text">
6
+ <div class="mc-promo-title">Aktionen &amp; Rabatte</div>
7
+ <div class="mc-promo-subtitle">
8
+ Zeitlich begrenzte Preis-Aktionen — überschreiben den regulären Preis auf der
9
+ Pricing-Page (SPEC_V2 §9a).
10
+ </div>
11
+ </div>
12
+ <div class="mc-promo-stats">
13
+ <span class="mc-promo-stat active">{{ counts.active }} aktiv</span>
14
+ <span class="mc-promo-stat scheduled">{{ counts.scheduled }} geplant</span>
15
+ <span class="mc-promo-stat expired">{{ counts.expired }} abgelaufen</span>
16
+ </div>
17
+ <button class="mc-promo-add" type="button" :disabled="busy" @click="onAdd">
18
+ + Aktion anlegen
19
+ </button>
20
+ </div>
21
+
22
+ <!-- Timeline -->
23
+ <div v-if="promotions.length > 0" class="mc-promo-timeline">
24
+ <div class="mc-promo-timeline-head">Aktions-Timeline · gestrichelt = heute</div>
25
+ <div class="mc-promo-timeline-chart" :style="{ height: `${timelineHeight}px` }">
26
+ <div
27
+ v-for="t in ticks"
28
+ :key="t.label + t.x"
29
+ class="mc-promo-tick"
30
+ :style="{ left: `${t.x}%` }"
31
+ >
32
+ <span class="mc-promo-tick-label">{{ t.label }}</span>
33
+ </div>
34
+ <div class="mc-promo-today" :style="{ left: `${todayX}%` }">
35
+ <span>heute</span>
36
+ </div>
37
+ <div
38
+ v-for="bar in bars"
39
+ :key="bar.id"
40
+ class="mc-promo-bar"
41
+ :class="bar.status"
42
+ :style="{
43
+ left: `${bar.left}%`,
44
+ width: `${bar.width}%`,
45
+ top: `${bar.top}px`,
46
+ background: bar.color,
47
+ }"
48
+ :title="`${bar.label} · ${bar.from} → ${bar.to}`"
49
+ @click="expandedId = expandedId === bar.id ? null : bar.id"
50
+ >
51
+ <span class="mc-promo-bar-label">{{ bar.label }}</span>
52
+ </div>
53
+ </div>
54
+ </div>
55
+
56
+ <div v-if="promotions.length === 0" class="mc-promo-empty">
57
+ Noch keine Aktionen. Über <strong>+ Aktion anlegen</strong> eine zeitgesteuerte
58
+ Preis-Aktion erstellen.
59
+ </div>
60
+
61
+ <!-- List -->
62
+ <div class="mc-promo-list">
63
+ <template v-for="p in sortedPromotions" :key="p.id">
64
+ <div
65
+ class="mc-promo-row"
66
+ :class="{ expanded: expandedId === p.id }"
67
+ @click="expandedId = expandedId === p.id ? null : p.id"
68
+ >
69
+ <span class="mc-promo-color" :style="{ background: p.color }" />
70
+ <div class="mc-promo-row-main">
71
+ <div class="mc-promo-row-title">{{ p.internalLabel }}</div>
72
+ <div class="mc-promo-row-sub">
73
+ <span class="mc-promo-typechip">{{ typeChip(p) }}</span>
74
+ <span v-if="p.appliesTo.length === 0" class="mc-promo-muted">
75
+ — keine Pläne —
76
+ </span>
77
+ <span v-for="k in p.appliesTo" :key="k" class="mc-promo-planchip">
78
+ {{ k }}
79
+ </span>
80
+ </div>
81
+ </div>
82
+ <div class="mc-promo-row-when">
83
+ {{ p.validFrom }} → {{ p.validTo }}
84
+ <span class="mc-promo-cycle">
85
+ {{ cycleLabel(p.billingCycle) }}
86
+ <template v-if="p.onlyLocales">
87
+ · nur {{ p.onlyLocales.join(', ').toUpperCase() }}
88
+ </template>
89
+ </span>
90
+ </div>
91
+ <span class="mc-promo-status" :class="statusOf(p)">
92
+ {{ statusLabel(statusOf(p)) }}
93
+ </span>
94
+ </div>
95
+
96
+ <!-- Editor -->
97
+ <div v-if="expandedId === p.id" class="mc-promo-editor">
98
+ <div class="mc-promo-editor-grid">
99
+ <div class="mc-promo-editor-col">
100
+ <label class="mc-promo-label">Interne Bezeichnung</label>
101
+ <input
102
+ class="mc-promo-input"
103
+ :value="p.internalLabel"
104
+ @change="patch(p, { internalLabel: inputVal($event) })"
105
+ />
106
+
107
+ <label class="mc-promo-label">Typ</label>
108
+ <div class="mc-promo-typegrid">
109
+ <button
110
+ v-for="t in TYPE_OPTIONS"
111
+ :key="t.id"
112
+ type="button"
113
+ class="mc-promo-typeopt"
114
+ :class="{ active: p.type === t.id }"
115
+ @click="changeType(p, t.id)"
116
+ >
117
+ {{ t.label }}
118
+ </button>
119
+ </div>
120
+
121
+ <label class="mc-promo-label">Wert</label>
122
+ <div class="mc-promo-valrow">
123
+ <template v-if="p.type === 'percent' || p.type === 'amount'">
124
+ <input
125
+ class="mc-promo-input mc-promo-input--sm"
126
+ type="number"
127
+ :value="numValue(p)"
128
+ @change="patch(p, { value: numInput($event) })"
129
+ />
130
+ <span class="mc-promo-muted">
131
+ {{ p.type === 'percent' ? '% Rabatt' : '€ Festbetrag' }}
132
+ </span>
133
+ </template>
134
+ <template v-else-if="p.type === 'intro'">
135
+ <span class="mc-promo-muted">für</span>
136
+ <input
137
+ class="mc-promo-input mc-promo-input--sm"
138
+ type="number"
139
+ :value="introMonths(p)"
140
+ @change="patchIntro(p, 'months', numInput($event))"
141
+ />
142
+ <span class="mc-promo-muted">Monate à</span>
143
+ <input
144
+ class="mc-promo-input mc-promo-input--sm"
145
+ type="number"
146
+ :value="introPrice(p)"
147
+ @change="patchIntro(p, 'price', numInput($event))"
148
+ />
149
+ <span class="mc-promo-muted">€/Mo</span>
150
+ </template>
151
+ <template v-else>
152
+ <span class="mc-promo-muted">erste</span>
153
+ <input
154
+ class="mc-promo-input mc-promo-input--sm"
155
+ type="number"
156
+ :value="numValue(p)"
157
+ @change="patch(p, { value: numInput($event) })"
158
+ />
159
+ <span class="mc-promo-muted">Monate gratis</span>
160
+ </template>
161
+ </div>
162
+
163
+ <label class="mc-promo-label">Gültigkeit</label>
164
+ <div class="mc-promo-valrow">
165
+ <input
166
+ class="mc-promo-input"
167
+ type="date"
168
+ :value="p.validFrom"
169
+ @change="patch(p, { validFrom: inputVal($event) })"
170
+ />
171
+ <span class="mc-promo-muted">→</span>
172
+ <input
173
+ class="mc-promo-input"
174
+ type="date"
175
+ :value="p.validTo"
176
+ @change="patch(p, { validTo: inputVal($event) })"
177
+ />
178
+ </div>
179
+
180
+ <label class="mc-promo-label">Abrechnungs-Zyklus</label>
181
+ <div class="mc-promo-typegrid">
182
+ <button
183
+ v-for="c in CYCLE_OPTIONS"
184
+ :key="c.id"
185
+ type="button"
186
+ class="mc-promo-typeopt"
187
+ :class="{ active: p.billingCycle === c.id }"
188
+ @click="patch(p, { billingCycle: c.id })"
189
+ >
190
+ {{ c.label }}
191
+ </button>
192
+ </div>
193
+
194
+ <label class="mc-promo-label">Priorität</label>
195
+ <input
196
+ class="mc-promo-input mc-promo-input--sm"
197
+ type="number"
198
+ :value="p.priority"
199
+ @change="patch(p, { priority: numInput($event) })"
200
+ />
201
+ </div>
202
+
203
+ <div class="mc-promo-editor-col">
204
+ <label class="mc-promo-label">Betroffene Pläne</label>
205
+ <div class="mc-promo-planlist">
206
+ <button
207
+ v-for="pl in plans"
208
+ :key="pl.key"
209
+ type="button"
210
+ class="mc-promo-planopt"
211
+ :class="{ active: p.appliesTo.includes(pl.key) }"
212
+ @click="toggleApply(p, pl.key)"
213
+ >
214
+ {{ pl.label }}
215
+ <code>{{ pl.key }}</code>
216
+ </button>
217
+ </div>
218
+
219
+ <label class="mc-promo-label">Sprach-Beschränkung</label>
220
+ <div class="mc-promo-typegrid">
221
+ <button
222
+ type="button"
223
+ class="mc-promo-typeopt"
224
+ :class="{ active: !p.onlyLocales }"
225
+ @click="patch(p, { onlyLocales: null })"
226
+ >
227
+ alle Sprachen
228
+ </button>
229
+ <button
230
+ v-for="l in activeLocales"
231
+ :key="l"
232
+ type="button"
233
+ class="mc-promo-typeopt"
234
+ :class="{ active: p.onlyLocales?.includes(l) }"
235
+ @click="toggleLocale(p, l)"
236
+ >
237
+ nur {{ l.toUpperCase() }}
238
+ </button>
239
+ </div>
240
+
241
+ <label class="mc-promo-label">Übersetzungen</label>
242
+ <div v-for="l in activeLocales" :key="l" class="mc-promo-i18n-block">
243
+ <span class="mc-promo-i18n-code">{{ l.toUpperCase() }}</span>
244
+ <input
245
+ class="mc-promo-input"
246
+ placeholder="Badge-Text"
247
+ :value="p.i18n?.[l]?.badge || ''"
248
+ @change="patchI18n(p, l, 'badge', inputVal($event))"
249
+ />
250
+ <input
251
+ class="mc-promo-input"
252
+ placeholder="Fineprint (unter dem CTA)"
253
+ :value="p.i18n?.[l]?.fineprint || ''"
254
+ @change="patchI18n(p, l, 'fineprint', inputVal($event))"
255
+ />
256
+ </div>
257
+
258
+ <label class="mc-promo-label">Farbe</label>
259
+ <div class="mc-promo-colors">
260
+ <button
261
+ v-for="c in COLORS"
262
+ :key="c"
263
+ type="button"
264
+ class="mc-promo-colorbtn"
265
+ :class="{ active: p.color === c }"
266
+ :style="{ background: c }"
267
+ @click="patch(p, { color: c })"
268
+ />
269
+ </div>
270
+
271
+ <button class="mc-promo-delete" type="button" @click="onRemove(p)">
272
+ Aktion löschen
273
+ </button>
274
+ </div>
275
+ </div>
276
+ </div>
277
+ </template>
278
+ </div>
279
+ </div>
280
+ </template>
281
+
282
+ <script setup lang="ts">
283
+ import { computed, ref } from 'vue';
284
+ import {
285
+ promoStatus,
286
+ type CreatePromotionData,
287
+ type PromotionBillingCycle,
288
+ type PromotionRow,
289
+ type PromotionStatus,
290
+ type PromotionType,
291
+ type UpdatePromotionData,
292
+ } from '@saasicat/types';
293
+
294
+ // Promotions tab of the Marketing Catalog (SPEC_V2 §9a). Standalone child
295
+ // component — the MarketingCatalogPage wires `usePromotions` and passes the
296
+ // list + CRUD callbacks through.
297
+
298
+ const DEFAULT_LOCALE = 'de';
299
+
300
+ const props = defineProps<{
301
+ promotions: PromotionRow[];
302
+ /** Plan list for the `appliesTo` selection. */
303
+ plans: Array<{ key: string; label: string }>;
304
+ activeLocales: string[];
305
+ busy?: boolean;
306
+ create: (data: CreatePromotionData) => Promise<PromotionRow>;
307
+ update: (id: string, data: UpdatePromotionData) => Promise<PromotionRow>;
308
+ remove: (id: string) => Promise<void>;
309
+ projectKey: string;
310
+ }>();
311
+
312
+ const busy = computed(() => props.busy ?? false);
313
+ const expandedId = ref<string | null>(null);
314
+
315
+ const TYPE_OPTIONS: Array<{ id: PromotionType; label: string }> = [
316
+ { id: 'percent', label: 'Prozent-Rabatt' },
317
+ { id: 'amount', label: 'Festbetrag' },
318
+ { id: 'intro', label: 'Einführungspreis' },
319
+ { id: 'freeMonths', label: 'Gratis-Monate' },
320
+ ];
321
+ const CYCLE_OPTIONS: Array<{ id: PromotionBillingCycle; label: string }> = [
322
+ { id: 'monthly', label: 'Monatlich' },
323
+ { id: 'yearly', label: 'Jährlich' },
324
+ { id: 'both', label: 'Beide' },
325
+ ];
326
+ const COLORS = ['#10b981', '#dc2626', '#f59e0b', '#2563eb', '#7c3aed', '#0f172a'];
327
+
328
+ const today = new Date();
329
+
330
+ function statusOf(p: PromotionRow): PromotionStatus {
331
+ return promoStatus(p, today);
332
+ }
333
+ function statusLabel(s: PromotionStatus): string {
334
+ return s === 'active' ? 'aktiv' : s === 'scheduled' ? 'geplant' : 'abgelaufen';
335
+ }
336
+
337
+ const counts = computed(() => ({
338
+ active: props.promotions.filter((p) => statusOf(p) === 'active').length,
339
+ scheduled: props.promotions.filter((p) => statusOf(p) === 'scheduled').length,
340
+ expired: props.promotions.filter((p) => statusOf(p) === 'expired').length,
341
+ }));
342
+
343
+ const sortedPromotions = computed(() => {
344
+ const order: Record<PromotionStatus, number> = { active: 0, scheduled: 1, expired: 2 };
345
+ return [...props.promotions].sort((a, b) => {
346
+ const d = order[statusOf(a)] - order[statusOf(b)];
347
+ if (d !== 0) return d;
348
+ return new Date(a.validFrom).getTime() - new Date(b.validFrom).getTime();
349
+ });
350
+ });
351
+
352
+ function typeChip(p: PromotionRow): string {
353
+ if (p.type === 'percent' && typeof p.value === 'number') return `−${p.value}%`;
354
+ if (p.type === 'amount' && typeof p.value === 'number') return `−${p.value} €`;
355
+ if (p.type === 'intro' && typeof p.value === 'object') {
356
+ return `${p.value.price} € · ${p.value.months} Mo.`;
357
+ }
358
+ if (p.type === 'freeMonths' && typeof p.value === 'number') return `${p.value} Mo. gratis`;
359
+ return p.type;
360
+ }
361
+ function cycleLabel(c: PromotionBillingCycle): string {
362
+ return c === 'both' ? 'mtl. & jährl.' : c === 'monthly' ? 'mtl.' : 'jährl.';
363
+ }
364
+
365
+ // ─── Timeline ───
366
+ const axisStart = computed(() => {
367
+ const dates = props.promotions.flatMap((p) => [
368
+ new Date(p.validFrom).getTime(),
369
+ new Date(p.validTo).getTime(),
370
+ ]);
371
+ const min = Math.min(today.getTime(), ...dates);
372
+ return new Date(min - 30 * 86400000);
373
+ });
374
+ const axisEnd = computed(() => {
375
+ const dates = props.promotions.flatMap((p) => [
376
+ new Date(p.validFrom).getTime(),
377
+ new Date(p.validTo).getTime(),
378
+ ]);
379
+ const max = Math.max(today.getTime(), ...dates);
380
+ return new Date(max + 30 * 86400000);
381
+ });
382
+ function pct(d: Date | string): number {
383
+ const span = axisEnd.value.getTime() - axisStart.value.getTime();
384
+ if (span <= 0) return 0;
385
+ const v = ((new Date(d).getTime() - axisStart.value.getTime()) / span) * 100;
386
+ return Math.max(0, Math.min(100, v));
387
+ }
388
+ const todayX = computed(() => pct(today));
389
+ const ticks = computed(() => {
390
+ const out: Array<{ x: number; label: string }> = [];
391
+ const start = axisStart.value;
392
+ const end = axisEnd.value;
393
+ const d = new Date(Date.UTC(start.getFullYear(), start.getMonth(), 1));
394
+ while (d <= end) {
395
+ if (d.getMonth() % 3 === 0) {
396
+ out.push({
397
+ x: pct(d),
398
+ label: `${['Jan', 'Apr', 'Jul', 'Okt'][d.getMonth() / 3]} ${d.getFullYear()}`,
399
+ });
400
+ }
401
+ d.setMonth(d.getMonth() + 1);
402
+ }
403
+ return out;
404
+ });
405
+ const ROW_H = 26;
406
+ const bars = computed(() => {
407
+ const rows: Array<Array<{ from: number; to: number }>> = [];
408
+ return sortedPromotions.value.map((p) => {
409
+ const left = pct(p.validFrom);
410
+ const right = pct(p.validTo);
411
+ let rowIdx = rows.findIndex(
412
+ (r) => !r.some((b) => !(b.to < left - 1 || b.from > right + 1)),
413
+ );
414
+ if (rowIdx === -1) {
415
+ rowIdx = rows.length;
416
+ rows.push([]);
417
+ }
418
+ rows[rowIdx].push({ from: left, to: right });
419
+ return {
420
+ id: p.id,
421
+ label: p.internalLabel,
422
+ color: p.color,
423
+ status: statusOf(p),
424
+ from: p.validFrom,
425
+ to: p.validTo,
426
+ left,
427
+ width: Math.max(2, right - left),
428
+ top: 8 + rowIdx * ROW_H,
429
+ };
430
+ });
431
+ });
432
+ const timelineHeight = computed(() => {
433
+ const rowCount = Math.max(1, ...bars.value.map((b) => (b.top - 8) / ROW_H + 1));
434
+ return 16 + rowCount * ROW_H + 22;
435
+ });
436
+
437
+ // ─── Mutations ───
438
+ function inputVal(e: Event): string {
439
+ return (e.target as HTMLInputElement).value;
440
+ }
441
+ function numInput(e: Event): number {
442
+ return Number((e.target as HTMLInputElement).value) || 0;
443
+ }
444
+ function numValue(p: PromotionRow): number {
445
+ return typeof p.value === 'number' ? p.value : 0;
446
+ }
447
+ function introPrice(p: PromotionRow): number {
448
+ return typeof p.value === 'object' ? p.value.price : 0;
449
+ }
450
+ function introMonths(p: PromotionRow): number {
451
+ return typeof p.value === 'object' ? p.value.months : 0;
452
+ }
453
+
454
+ function patch(p: PromotionRow, data: UpdatePromotionData): void {
455
+ void props.update(p.id, data);
456
+ }
457
+ function patchIntro(p: PromotionRow, field: 'price' | 'months', value: number): void {
458
+ const base = typeof p.value === 'object' ? p.value : { price: 0, months: 3 };
459
+ patch(p, { value: { ...base, [field]: value } });
460
+ }
461
+ function changeType(p: PromotionRow, type: PromotionType): void {
462
+ let value: PromotionRow['value'] = 10;
463
+ if (type === 'intro') value = { price: 9, months: 3 };
464
+ if (type === 'freeMonths') value = 2;
465
+ patch(p, { type, value });
466
+ }
467
+ function toggleApply(p: PromotionRow, planKey: string): void {
468
+ const has = p.appliesTo.includes(planKey);
469
+ patch(p, {
470
+ appliesTo: has ? p.appliesTo.filter((k) => k !== planKey) : [...p.appliesTo, planKey],
471
+ });
472
+ }
473
+ function toggleLocale(p: PromotionRow, locale: string): void {
474
+ const cur = p.onlyLocales ?? [];
475
+ const next = cur.includes(locale) ? cur.filter((l) => l !== locale) : [...cur, locale];
476
+ patch(p, { onlyLocales: next.length === 0 ? null : next });
477
+ }
478
+ function patchI18n(
479
+ p: PromotionRow,
480
+ locale: string,
481
+ field: 'badge' | 'fineprint',
482
+ value: string,
483
+ ): void {
484
+ const i18n = { ...(p.i18n ?? {}) };
485
+ const entry = { ...(i18n[locale] ?? {}) };
486
+ if (value) entry[field] = value;
487
+ else delete entry[field];
488
+ i18n[locale] = entry;
489
+ patch(p, { i18n });
490
+ }
491
+
492
+ async function onAdd(): Promise<void> {
493
+ const created = await props.create({
494
+ projectKey: props.projectKey,
495
+ internalLabel: 'Neue Aktion',
496
+ type: 'percent',
497
+ value: 10,
498
+ appliesTo: [],
499
+ billingCycle: 'both',
500
+ validFrom: new Date().toISOString().slice(0, 10),
501
+ validTo: new Date(today.getTime() + 30 * 86400000).toISOString().slice(0, 10),
502
+ priority: 5,
503
+ color: '#2563eb',
504
+ i18n: { [DEFAULT_LOCALE]: { badge: 'Aktion' } },
505
+ });
506
+ expandedId.value = created.id;
507
+ }
508
+
509
+ async function onRemove(p: PromotionRow): Promise<void> {
510
+ if (!window.confirm(`Aktion „${p.internalLabel}“ wirklich löschen?`)) return;
511
+ if (expandedId.value === p.id) expandedId.value = null;
512
+ await props.remove(p.id);
513
+ }
514
+ </script>
515
+
516
+ <style scoped>
517
+ .mc-promo {
518
+ display: flex;
519
+ flex-direction: column;
520
+ gap: 14px;
521
+ }
522
+ .mc-promo-head {
523
+ display: flex;
524
+ align-items: center;
525
+ gap: 16px;
526
+ }
527
+ .mc-promo-head-text {
528
+ flex: 1;
529
+ }
530
+ .mc-promo-title {
531
+ font-size: 15px;
532
+ font-weight: 700;
533
+ }
534
+ .mc-promo-subtitle {
535
+ font-size: 12px;
536
+ color: #64748b;
537
+ }
538
+ .mc-promo-stats {
539
+ display: flex;
540
+ gap: 8px;
541
+ }
542
+ .mc-promo-stat {
543
+ font-size: 11px;
544
+ font-weight: 600;
545
+ padding: 3px 9px;
546
+ border-radius: 999px;
547
+ background: #f1f5f9;
548
+ color: #475569;
549
+ }
550
+ .mc-promo-stat.active {
551
+ background: #dcfce7;
552
+ color: #166534;
553
+ }
554
+ .mc-promo-stat.scheduled {
555
+ background: #dbeafe;
556
+ color: #1d4ed8;
557
+ }
558
+ .mc-promo-stat.expired {
559
+ background: #f1f5f9;
560
+ color: #94a3b8;
561
+ }
562
+ .mc-promo-add {
563
+ border: 0;
564
+ background: #3f6bff;
565
+ color: #fff;
566
+ font-weight: 600;
567
+ font-size: 13px;
568
+ padding: 8px 14px;
569
+ border-radius: 8px;
570
+ cursor: pointer;
571
+ }
572
+ .mc-promo-add:disabled {
573
+ opacity: 0.5;
574
+ cursor: default;
575
+ }
576
+ .mc-promo-timeline {
577
+ background: #fff;
578
+ border: 1px solid #e2e8f0;
579
+ border-radius: 10px;
580
+ padding: 12px 14px;
581
+ }
582
+ .mc-promo-timeline-head {
583
+ font-size: 11px;
584
+ color: #64748b;
585
+ margin-bottom: 6px;
586
+ }
587
+ .mc-promo-timeline-chart {
588
+ position: relative;
589
+ border-left: 1px solid #e2e8f0;
590
+ }
591
+ .mc-promo-tick {
592
+ position: absolute;
593
+ top: 0;
594
+ bottom: 0;
595
+ width: 1px;
596
+ background: #f1f5f9;
597
+ }
598
+ .mc-promo-tick-label {
599
+ position: absolute;
600
+ bottom: 0;
601
+ left: 3px;
602
+ font-size: 9px;
603
+ color: #94a3b8;
604
+ white-space: nowrap;
605
+ }
606
+ .mc-promo-today {
607
+ position: absolute;
608
+ top: 0;
609
+ bottom: 18px;
610
+ width: 0;
611
+ border-left: 2px dashed #ef4444;
612
+ }
613
+ .mc-promo-today span {
614
+ position: absolute;
615
+ top: -2px;
616
+ left: 3px;
617
+ font-size: 9px;
618
+ color: #ef4444;
619
+ font-weight: 700;
620
+ }
621
+ .mc-promo-bar {
622
+ position: absolute;
623
+ height: 20px;
624
+ border-radius: 5px;
625
+ cursor: pointer;
626
+ display: flex;
627
+ align-items: center;
628
+ overflow: hidden;
629
+ opacity: 0.92;
630
+ }
631
+ .mc-promo-bar.expired {
632
+ opacity: 0.45;
633
+ }
634
+ .mc-promo-bar-label {
635
+ font-size: 10px;
636
+ color: #fff;
637
+ font-weight: 600;
638
+ padding: 0 6px;
639
+ white-space: nowrap;
640
+ }
641
+ .mc-promo-empty {
642
+ padding: 28px;
643
+ text-align: center;
644
+ color: #94a3b8;
645
+ font-size: 13px;
646
+ border: 1px dashed #cbd5e1;
647
+ border-radius: 10px;
648
+ }
649
+ .mc-promo-list {
650
+ display: flex;
651
+ flex-direction: column;
652
+ gap: 6px;
653
+ }
654
+ .mc-promo-row {
655
+ display: flex;
656
+ align-items: center;
657
+ gap: 12px;
658
+ background: #fff;
659
+ border: 1px solid #e2e8f0;
660
+ border-radius: 8px;
661
+ padding: 10px 12px;
662
+ cursor: pointer;
663
+ }
664
+ .mc-promo-row.expanded {
665
+ border-color: #3f6bff;
666
+ border-bottom-left-radius: 0;
667
+ border-bottom-right-radius: 0;
668
+ }
669
+ .mc-promo-color {
670
+ width: 10px;
671
+ height: 28px;
672
+ border-radius: 3px;
673
+ flex-shrink: 0;
674
+ }
675
+ .mc-promo-row-main {
676
+ flex: 1;
677
+ min-width: 0;
678
+ }
679
+ .mc-promo-row-title {
680
+ font-size: 13px;
681
+ font-weight: 600;
682
+ }
683
+ .mc-promo-row-sub {
684
+ display: flex;
685
+ gap: 6px;
686
+ align-items: center;
687
+ flex-wrap: wrap;
688
+ margin-top: 2px;
689
+ }
690
+ .mc-promo-typechip {
691
+ font-size: 10px;
692
+ font-weight: 700;
693
+ background: #eff6ff;
694
+ color: #1d4ed8;
695
+ padding: 1px 6px;
696
+ border-radius: 4px;
697
+ }
698
+ .mc-promo-planchip {
699
+ font-size: 10px;
700
+ background: #f1f5f9;
701
+ color: #475569;
702
+ padding: 1px 6px;
703
+ border-radius: 4px;
704
+ }
705
+ .mc-promo-muted {
706
+ font-size: 11px;
707
+ color: #94a3b8;
708
+ }
709
+ .mc-promo-row-when {
710
+ font-size: 11px;
711
+ color: #64748b;
712
+ text-align: right;
713
+ }
714
+ .mc-promo-cycle {
715
+ display: block;
716
+ color: #94a3b8;
717
+ }
718
+ .mc-promo-status {
719
+ font-size: 10px;
720
+ font-weight: 700;
721
+ text-transform: uppercase;
722
+ padding: 3px 8px;
723
+ border-radius: 6px;
724
+ }
725
+ .mc-promo-status.active {
726
+ background: #dcfce7;
727
+ color: #166534;
728
+ }
729
+ .mc-promo-status.scheduled {
730
+ background: #dbeafe;
731
+ color: #1d4ed8;
732
+ }
733
+ .mc-promo-status.expired {
734
+ background: #f1f5f9;
735
+ color: #94a3b8;
736
+ }
737
+ .mc-promo-editor {
738
+ border: 1px solid #3f6bff;
739
+ border-top: 0;
740
+ border-radius: 0 0 8px 8px;
741
+ background: #f8fafc;
742
+ padding: 14px;
743
+ }
744
+ .mc-promo-editor-grid {
745
+ display: grid;
746
+ grid-template-columns: 1fr 1fr;
747
+ gap: 18px;
748
+ }
749
+ .mc-promo-editor-col {
750
+ display: flex;
751
+ flex-direction: column;
752
+ gap: 6px;
753
+ }
754
+ .mc-promo-label {
755
+ font-size: 10px;
756
+ font-weight: 700;
757
+ text-transform: uppercase;
758
+ letter-spacing: 0.5px;
759
+ color: #475569;
760
+ margin-top: 6px;
761
+ }
762
+ .mc-promo-input {
763
+ border: 1px solid #cbd5e1;
764
+ border-radius: 6px;
765
+ padding: 6px 8px;
766
+ font-size: 13px;
767
+ }
768
+ .mc-promo-input--sm {
769
+ max-width: 90px;
770
+ }
771
+ .mc-promo-valrow {
772
+ display: flex;
773
+ gap: 8px;
774
+ align-items: center;
775
+ }
776
+ .mc-promo-typegrid {
777
+ display: flex;
778
+ flex-wrap: wrap;
779
+ gap: 6px;
780
+ }
781
+ .mc-promo-typeopt {
782
+ border: 1px solid #cbd5e1;
783
+ background: #fff;
784
+ border-radius: 6px;
785
+ padding: 5px 10px;
786
+ font-size: 12px;
787
+ cursor: pointer;
788
+ }
789
+ .mc-promo-typeopt.active {
790
+ border-color: #3f6bff;
791
+ background: #eff6ff;
792
+ color: #1d4ed8;
793
+ font-weight: 600;
794
+ }
795
+ .mc-promo-planlist {
796
+ display: flex;
797
+ flex-direction: column;
798
+ gap: 4px;
799
+ }
800
+ .mc-promo-planopt {
801
+ display: flex;
802
+ justify-content: space-between;
803
+ border: 1px solid #cbd5e1;
804
+ background: #fff;
805
+ border-radius: 6px;
806
+ padding: 6px 10px;
807
+ font-size: 12px;
808
+ cursor: pointer;
809
+ }
810
+ .mc-promo-planopt.active {
811
+ border-color: #3f6bff;
812
+ background: #eff6ff;
813
+ }
814
+ .mc-promo-planopt code {
815
+ color: #94a3b8;
816
+ font-size: 10px;
817
+ }
818
+ .mc-promo-i18n-block {
819
+ display: flex;
820
+ flex-direction: column;
821
+ gap: 4px;
822
+ padding: 8px;
823
+ border: 1px solid #e2e8f0;
824
+ border-radius: 8px;
825
+ background: #fff;
826
+ }
827
+ .mc-promo-i18n-code {
828
+ font-size: 10px;
829
+ font-weight: 700;
830
+ background: #f1f5f9;
831
+ padding: 2px 7px;
832
+ border-radius: 5px;
833
+ align-self: flex-start;
834
+ }
835
+ .mc-promo-colors {
836
+ display: flex;
837
+ gap: 6px;
838
+ }
839
+ .mc-promo-colorbtn {
840
+ width: 24px;
841
+ height: 24px;
842
+ border-radius: 6px;
843
+ border: 2px solid transparent;
844
+ cursor: pointer;
845
+ }
846
+ .mc-promo-colorbtn.active {
847
+ border-color: #0f172a;
848
+ }
849
+ .mc-promo-delete {
850
+ margin-top: 12px;
851
+ align-self: flex-start;
852
+ border: 1px solid #fecaca;
853
+ background: #fef2f2;
854
+ color: #b91c1c;
855
+ font-weight: 600;
856
+ font-size: 12px;
857
+ padding: 6px 12px;
858
+ border-radius: 6px;
859
+ cursor: pointer;
860
+ }
861
+ </style>