@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,737 @@
1
+ <template>
2
+ <q-dialog
3
+ :model-value="modelValue"
4
+ @update:model-value="emit('update:modelValue', $event)"
5
+ persistent
6
+ >
7
+ <q-card class="pc-dlg">
8
+ <q-card-section class="pc-dlg__head">
9
+ <div>
10
+ <div class="pc-dlg__title">Neuer Promo-Code</div>
11
+ <div class="pc-dlg__sub">
12
+ {{
13
+ subtitle ?? 'Code, Rabatt-Logik, Laufzeit und Plan-Zuordnung festlegen.'
14
+ }}
15
+ </div>
16
+ </div>
17
+ <q-btn
18
+ class="pc-dlg__close"
19
+ flat
20
+ dense
21
+ round
22
+ icon="close"
23
+ v-close-popup
24
+ :disable="loading"
25
+ />
26
+ </q-card-section>
27
+
28
+ <q-card-section class="pc-dlg__body">
29
+ <!-- Section: Code & Discount -->
30
+ <div class="pc-section">
31
+ <div class="pc-section__title">Code &amp; Rabatt</div>
32
+ <div class="pc-grid pc-grid--2">
33
+ <div class="pc-field">
34
+ <div class="pc-field__label">Code</div>
35
+ <div class="pc-code-input">
36
+ <input
37
+ v-model="form.code"
38
+ class="pc-input pc-input--code"
39
+ placeholder="z. B. WELCOME10"
40
+ @input="onCodeInput"
41
+ />
42
+ <button
43
+ type="button"
44
+ class="pc-btn-mini"
45
+ @click="form.code = randomCode()"
46
+ >
47
+ Zufall
48
+ </button>
49
+ </div>
50
+ <div class="pc-field__hint">
51
+ Großbuchstaben, Zahlen, „-" und „_" · nach Anlage stabil
52
+ </div>
53
+ </div>
54
+
55
+ <div class="pc-field">
56
+ <div class="pc-field__label">Rabatt-Typ</div>
57
+ <div class="pc-type-grid">
58
+ <button
59
+ v-for="o in TYPE_OPTIONS"
60
+ :key="o.k"
61
+ type="button"
62
+ class="pc-type-opt"
63
+ :class="{ 'pc-type-opt--active': form.valueType === o.k }"
64
+ @click="form.valueType = o.k"
65
+ >
66
+ <div class="pc-type-opt__label">{{ o.label }}</div>
67
+ <div class="pc-type-opt__sub">{{ o.sub }}</div>
68
+ </button>
69
+ </div>
70
+ </div>
71
+ </div>
72
+
73
+ <div class="pc-field" style="max-width: 280px">
74
+ <div class="pc-field__label">
75
+ {{ form.valueType === 'PERCENT' ? 'Rabatt in %' : 'Rabatt in €' }}
76
+ </div>
77
+ <input
78
+ v-model.number="form.value"
79
+ class="pc-input"
80
+ type="number"
81
+ min="1"
82
+ :max="form.valueType === 'PERCENT' ? 100 : undefined"
83
+ />
84
+ </div>
85
+ </div>
86
+
87
+ <!-- Section: Validity & Duration -->
88
+ <div class="pc-section">
89
+ <div class="pc-section__title">Gültigkeit &amp; Laufzeit</div>
90
+
91
+ <div v-if="plans.length > 0" class="pc-field">
92
+ <div class="pc-field__label">Anwendbar auf Pläne</div>
93
+ <div class="pc-plan-pick">
94
+ <button
95
+ v-for="p in plans"
96
+ :key="p.key"
97
+ type="button"
98
+ class="pc-plan-opt"
99
+ :class="{ 'pc-plan-opt--on': isPlanSelected(p.key) }"
100
+ :style="planChipStyle(p)"
101
+ @click="togglePlan(p.key)"
102
+ >
103
+ <span
104
+ class="pc-plan-opt__mark"
105
+ :style="{ background: p.color ?? '#64748b' }"
106
+ />
107
+ {{ p.label }}
108
+ </button>
109
+ </div>
110
+ <div class="pc-field__hint">
111
+ Leer = alle Pläne ({{ form.appliesToPlans.length }} ausgewählt)
112
+ </div>
113
+ </div>
114
+
115
+ <div class="pc-grid pc-grid--2">
116
+ <div class="pc-field">
117
+ <div class="pc-field__label">Laufzeit des Rabatts</div>
118
+ <div class="pc-dur">
119
+ <button
120
+ v-for="o in DURATION_OPTIONS"
121
+ :key="o.k"
122
+ type="button"
123
+ class="pc-dur-opt"
124
+ :class="{ 'pc-dur-opt--active': form.durationType === o.k }"
125
+ @click="form.durationType = o.k"
126
+ >
127
+ {{ o.label }}
128
+ </button>
129
+ </div>
130
+ <input
131
+ v-if="form.durationType !== 'ONCE'"
132
+ v-model.number="form.durationValue"
133
+ class="pc-input"
134
+ type="number"
135
+ min="1"
136
+ style="margin-top: 8px; max-width: 120px"
137
+ :placeholder="
138
+ form.durationType === 'MONTHS' ? 'Monate' : 'Perioden'
139
+ "
140
+ />
141
+ </div>
142
+
143
+ <div class="pc-field">
144
+ <div class="pc-field__label">Max. Einlösungen</div>
145
+ <input
146
+ v-model.number="form.maxRedemptions"
147
+ class="pc-input"
148
+ type="number"
149
+ min="1"
150
+ placeholder="leer = ∞"
151
+ />
152
+ <div class="pc-field__hint">Leerlassen für unbegrenzt</div>
153
+ </div>
154
+ </div>
155
+
156
+ <div class="pc-grid pc-grid--2">
157
+ <div class="pc-field">
158
+ <div class="pc-field__label">Gültig ab</div>
159
+ <input v-model="form.validFrom" class="pc-input" type="date" />
160
+ </div>
161
+ <div class="pc-field">
162
+ <div class="pc-field__label">Gültig bis</div>
163
+ <input v-model="form.validUntil" class="pc-input" type="date" />
164
+ </div>
165
+ </div>
166
+ </div>
167
+
168
+ <!-- Section: Campaign & Note -->
169
+ <div class="pc-section">
170
+ <div class="pc-section__title">Kampagne &amp; Notizen</div>
171
+ <div v-if="showCampaignTag" class="pc-field">
172
+ <div class="pc-field__label">Kampagne (optional)</div>
173
+ <input
174
+ v-model="form.campaignTag"
175
+ class="pc-input"
176
+ placeholder="z. B. Frühjahrs-Aktion 2026"
177
+ />
178
+ <div class="pc-field__hint">Gruppiert mehrere Codes optisch</div>
179
+ </div>
180
+ <div class="pc-field">
181
+ <div class="pc-field__label">Notiz (intern)</div>
182
+ <textarea
183
+ v-model="form.description"
184
+ class="pc-input"
185
+ rows="2"
186
+ placeholder="Kontext, Quelle, Sales-Bezug — wird nicht an Kunden ausgespielt."
187
+ />
188
+ </div>
189
+ </div>
190
+
191
+ <!-- Section: Advanced (backend-only fields, collapsed) -->
192
+ <div class="pc-section">
193
+ <button
194
+ type="button"
195
+ class="pc-section__toggle"
196
+ @click="advancedOpen = !advancedOpen"
197
+ >
198
+ <q-icon
199
+ :name="advancedOpen ? 'expand_more' : 'chevron_right'"
200
+ size="16px"
201
+ />
202
+ Erweiterte Einschränkungen
203
+ </button>
204
+ <div v-if="advancedOpen" class="pc-advanced">
205
+ <div class="pc-grid pc-grid--2">
206
+ <div class="pc-field">
207
+ <div class="pc-field__label">Nur für Abrechnungs-Zyklus</div>
208
+ <select v-model="form.appliesToBilling" class="pc-input">
209
+ <option :value="undefined">Beide</option>
210
+ <option value="MONTHLY">Monatlich</option>
211
+ <option value="YEARLY">Jährlich</option>
212
+ </select>
213
+ </div>
214
+ <div class="pc-field">
215
+ <div class="pc-field__label">Mindest-Plan-Betrag (€ brutto)</div>
216
+ <input
217
+ v-model.number="form.minimumPlanAmountGross"
218
+ class="pc-input"
219
+ type="number"
220
+ min="0"
221
+ placeholder="leer = keine Schwelle"
222
+ />
223
+ </div>
224
+ </div>
225
+ <div class="pc-grid pc-grid--2">
226
+ <label class="pc-check">
227
+ <input v-model="form.firstTimeCustomersOnly" type="checkbox" />
228
+ <span>Nur Neukunden</span>
229
+ </label>
230
+ <label class="pc-check">
231
+ <input v-model="form.allowZeroInvoice" type="checkbox" />
232
+ <span>0-€-Rechnung zulassen</span>
233
+ </label>
234
+ </div>
235
+ <div class="pc-field">
236
+ <div class="pc-field__label">Erlös-Minderungs-Konto (Buchhaltung)</div>
237
+ <input
238
+ v-model="form.revenueDeductionAccount"
239
+ class="pc-input"
240
+ placeholder="z. B. 8736"
241
+ />
242
+ </div>
243
+ </div>
244
+ </div>
245
+
246
+ <!-- Live preview -->
247
+ <div class="pc-preview">
248
+ <div class="pc-preview__eyebrow">Vorschau im Catalog</div>
249
+ <div class="pc-preview__body">
250
+ <code class="pc-preview__code">{{ form.code || 'CODE' }}</code>
251
+ <span class="pc-preview__disc">{{ previewValue }}</span>
252
+ <span class="pc-preview__meta">{{ previewMeta }}</span>
253
+ </div>
254
+ </div>
255
+
256
+ <p v-if="error" class="pc-error">{{ error }}</p>
257
+ </q-card-section>
258
+
259
+ <q-card-actions align="right" class="pc-dlg__foot">
260
+ <q-btn flat label="Abbrechen" v-close-popup :disable="loading" />
261
+ <q-btn
262
+ unelevated
263
+ color="primary"
264
+ label="Anlegen"
265
+ :loading="loading"
266
+ :disable="!isValid"
267
+ @click="onSubmit"
268
+ />
269
+ </q-card-actions>
270
+ </q-card>
271
+ </q-dialog>
272
+ </template>
273
+
274
+ <script setup lang="ts">
275
+ import { computed, reactive, ref, watch } from 'vue';
276
+ import type {
277
+ PromoCodeCreatePayload,
278
+ PromoCodeDurationType,
279
+ PromoCodePlanOption,
280
+ PromoCodeValueType,
281
+ } from './types.js';
282
+
283
+ // Platform promo-code create dialog (sim layout: sections, type grid, plan
284
+ // picker, live preview). The app supplies the submit handler and optionally
285
+ // the plan list for the plan picker (e.g. from the manifest snapshot). If no
286
+ // plans are passed through, the plan selection section is hidden.
287
+
288
+ const props = withDefaults(
289
+ defineProps<{
290
+ modelValue: boolean;
291
+ subtitle?: string;
292
+ showCampaignTag?: boolean;
293
+ plans?: readonly PromoCodePlanOption[];
294
+ submit: (payload: PromoCodeCreatePayload) => Promise<void>;
295
+ }>(),
296
+ {
297
+ showCampaignTag: true,
298
+ plans: () => [],
299
+ },
300
+ );
301
+
302
+ const emit = defineEmits<{
303
+ (e: 'update:modelValue', v: boolean): void;
304
+ (e: 'created'): void;
305
+ }>();
306
+
307
+ const TYPE_OPTIONS: ReadonlyArray<{ k: PromoCodeValueType; label: string; sub: string }> = [
308
+ { k: 'PERCENT', label: 'Prozent', sub: '−x %' },
309
+ { k: 'ABSOLUTE', label: 'Fester €', sub: '−x €' },
310
+ ];
311
+
312
+ const DURATION_OPTIONS: ReadonlyArray<{ k: PromoCodeDurationType; label: string }> = [
313
+ { k: 'ONCE', label: 'Einmalig' },
314
+ { k: 'MONTHS', label: 'N Monate' },
315
+ { k: 'BILLING_CYCLES', label: 'N Perioden' },
316
+ ];
317
+
318
+ function emptyForm() {
319
+ return {
320
+ code: '',
321
+ valueType: 'PERCENT' as PromoCodeValueType,
322
+ value: 25 as number,
323
+ durationType: 'BILLING_CYCLES' as PromoCodeDurationType,
324
+ durationValue: 1 as number | null,
325
+ maxRedemptions: null as number | null,
326
+ validFrom: '' as string,
327
+ validUntil: '' as string,
328
+ appliesToPlans: [] as string[],
329
+ appliesToBilling: undefined as 'MONTHLY' | 'YEARLY' | undefined,
330
+ firstTimeCustomersOnly: false,
331
+ minimumPlanAmountGross: null as number | null,
332
+ allowZeroInvoice: false,
333
+ revenueDeductionAccount: '',
334
+ campaignTag: '',
335
+ description: '',
336
+ };
337
+ }
338
+
339
+ const form = reactive(emptyForm());
340
+ const loading = ref(false);
341
+ const error = ref('');
342
+ const advancedOpen = ref(false);
343
+
344
+ function onCodeInput(): void {
345
+ form.code = form.code.toUpperCase().replace(/[^A-Z0-9_-]/g, '');
346
+ }
347
+
348
+ function randomCode(): string {
349
+ const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
350
+ let s = '';
351
+ for (let i = 0; i < 8; i++) s += chars[Math.floor(Math.random() * chars.length)];
352
+ return s;
353
+ }
354
+
355
+ function isPlanSelected(key: string): boolean {
356
+ return form.appliesToPlans.includes(key);
357
+ }
358
+ function togglePlan(key: string): void {
359
+ if (form.appliesToPlans.includes(key)) {
360
+ form.appliesToPlans = form.appliesToPlans.filter((k) => k !== key);
361
+ } else {
362
+ form.appliesToPlans = [...form.appliesToPlans, key];
363
+ }
364
+ }
365
+ function planChipStyle(p: PromoCodePlanOption): Record<string, string> {
366
+ if (!isPlanSelected(p.key) || !p.color) return {};
367
+ return {
368
+ borderColor: p.color,
369
+ background: `${p.color}12`,
370
+ color: p.color,
371
+ };
372
+ }
373
+
374
+ const previewValue = computed(() => {
375
+ if (form.valueType === 'PERCENT') return `−${form.value || 0}%`;
376
+ return `−${form.value || 0} €`;
377
+ });
378
+
379
+ const previewMeta = computed(() => {
380
+ const parts: string[] = [];
381
+ if (form.durationType === 'ONCE') parts.push('einmalig');
382
+ else if (form.durationType === 'MONTHS') parts.push(`${form.durationValue || 0} Monate`);
383
+ else parts.push(`${form.durationValue || 0} Perioden`);
384
+ parts.push(
385
+ form.appliesToPlans.length > 0
386
+ ? form.appliesToPlans.join(', ')
387
+ : props.plans.length > 0
388
+ ? 'alle Pläne'
389
+ : 'kein Plan-Filter',
390
+ );
391
+ if (form.maxRedemptions) parts.push(`max. ${form.maxRedemptions}`);
392
+ return parts.join(' · ');
393
+ });
394
+
395
+ const isValid = computed(() => {
396
+ if (!/^[A-Z0-9_-]{3,32}$/.test(form.code)) return false;
397
+ if (!form.value || form.value <= 0) return false;
398
+ if (form.valueType === 'PERCENT' && form.value > 100) return false;
399
+ if (form.durationType !== 'ONCE' && (!form.durationValue || form.durationValue < 1))
400
+ return false;
401
+ return true;
402
+ });
403
+
404
+ watch(
405
+ () => props.modelValue,
406
+ (open) => {
407
+ if (open) {
408
+ Object.assign(form, emptyForm());
409
+ error.value = '';
410
+ advancedOpen.value = false;
411
+ }
412
+ },
413
+ );
414
+
415
+ async function onSubmit() {
416
+ if (!isValid.value) return;
417
+ loading.value = true;
418
+ error.value = '';
419
+ try {
420
+ await props.submit({
421
+ code: form.code,
422
+ valueType: form.valueType,
423
+ value: form.value,
424
+ durationType: form.durationType,
425
+ durationValue: form.durationType === 'ONCE' ? null : form.durationValue,
426
+ maxRedemptions: form.maxRedemptions ?? null,
427
+ validFrom: form.validFrom || null,
428
+ validUntil: form.validUntil || null,
429
+ appliesToPlans: form.appliesToPlans.length > 0 ? [...form.appliesToPlans] : undefined,
430
+ appliesToBilling: form.appliesToBilling,
431
+ firstTimeCustomersOnly: form.firstTimeCustomersOnly || undefined,
432
+ minimumPlanAmountGross: form.minimumPlanAmountGross ?? undefined,
433
+ allowZeroInvoice: form.allowZeroInvoice || undefined,
434
+ revenueDeductionAccount: form.revenueDeductionAccount || undefined,
435
+ campaignTag: form.campaignTag || undefined,
436
+ description: form.description || undefined,
437
+ });
438
+ emit('created');
439
+ emit('update:modelValue', false);
440
+ } catch (err) {
441
+ error.value =
442
+ (err as { response?: { data?: { message?: string } } }).response?.data?.message ??
443
+ (err as Error).message ??
444
+ 'Anlegen fehlgeschlagen';
445
+ } finally {
446
+ loading.value = false;
447
+ }
448
+ }
449
+ </script>
450
+
451
+ <style scoped>
452
+ .pc-dlg {
453
+ min-width: 720px;
454
+ max-width: 96vw;
455
+ }
456
+ .pc-dlg__head {
457
+ display: flex;
458
+ align-items: flex-start;
459
+ gap: 16px;
460
+ padding-bottom: 8px;
461
+ }
462
+ .pc-dlg__close {
463
+ margin-left: auto;
464
+ }
465
+ .pc-dlg__title {
466
+ font-family: var(--sa-font-head, system-ui, sans-serif);
467
+ font-weight: 700;
468
+ font-size: 18px;
469
+ color: var(--sa-heading, #0f172a);
470
+ }
471
+ .pc-dlg__sub {
472
+ font-size: 12.5px;
473
+ color: var(--sa-muted, #64748b);
474
+ margin-top: 2px;
475
+ }
476
+ .pc-dlg__body {
477
+ display: flex;
478
+ flex-direction: column;
479
+ gap: 16px;
480
+ padding-top: 4px;
481
+ }
482
+ .pc-dlg__foot {
483
+ border-top: 1px solid var(--sa-border, #e2e8f0);
484
+ }
485
+
486
+ .pc-section {
487
+ border: 1px solid var(--sa-border, #e2e8f0);
488
+ border-radius: 10px;
489
+ padding: 14px 16px;
490
+ background: #fafbfc;
491
+ }
492
+ .pc-section__title {
493
+ font-family: var(--sa-font-head, system-ui, sans-serif);
494
+ font-weight: 700;
495
+ font-size: 13px;
496
+ color: var(--sa-heading, #0f172a);
497
+ margin-bottom: 12px;
498
+ letter-spacing: -0.005em;
499
+ }
500
+ .pc-section__toggle {
501
+ border: 0;
502
+ background: transparent;
503
+ cursor: pointer;
504
+ font: 600 12.5px var(--sa-font-body, system-ui, sans-serif);
505
+ color: var(--sa-muted-dark, #475569);
506
+ display: inline-flex;
507
+ align-items: center;
508
+ gap: 4px;
509
+ padding: 0;
510
+ }
511
+ .pc-advanced {
512
+ display: flex;
513
+ flex-direction: column;
514
+ gap: 12px;
515
+ margin-top: 12px;
516
+ }
517
+
518
+ .pc-grid {
519
+ display: grid;
520
+ gap: 12px;
521
+ margin-bottom: 12px;
522
+ }
523
+ .pc-grid--2 {
524
+ grid-template-columns: 1fr 1fr;
525
+ }
526
+ @media (max-width: 600px) {
527
+ .pc-grid--2 {
528
+ grid-template-columns: 1fr;
529
+ }
530
+ }
531
+ .pc-grid:last-child {
532
+ margin-bottom: 0;
533
+ }
534
+
535
+ .pc-field {
536
+ display: flex;
537
+ flex-direction: column;
538
+ gap: 4px;
539
+ }
540
+ .pc-field__label {
541
+ font-size: 11.5px;
542
+ font-weight: 600;
543
+ color: var(--sa-muted, #64748b);
544
+ text-transform: uppercase;
545
+ letter-spacing: 0.04em;
546
+ }
547
+ .pc-field__hint {
548
+ font-size: 11.5px;
549
+ color: #94a3b8;
550
+ }
551
+
552
+ .pc-input {
553
+ width: 100%;
554
+ border: 1px solid var(--sa-border, #e2e8f0);
555
+ background: #fff;
556
+ border-radius: 7px;
557
+ padding: 8px 10px;
558
+ font: 13.5px var(--sa-font-body, system-ui, sans-serif);
559
+ color: var(--sa-body, #1e293b);
560
+ outline: 0;
561
+ }
562
+ .pc-input:focus {
563
+ border-color: var(--sa-primary, #3f6bff);
564
+ box-shadow: 0 0 0 3px rgba(63, 107, 255, 0.12);
565
+ }
566
+ .pc-input--code {
567
+ font: 600 14px var(--sa-font-mono, ui-monospace, monospace);
568
+ letter-spacing: 0.04em;
569
+ text-transform: uppercase;
570
+ }
571
+ textarea.pc-input {
572
+ font: 13.5px var(--sa-font-body, system-ui, sans-serif);
573
+ resize: vertical;
574
+ }
575
+
576
+ .pc-code-input {
577
+ display: flex;
578
+ gap: 8px;
579
+ align-items: center;
580
+ }
581
+ .pc-btn-mini {
582
+ border: 1px solid var(--sa-border, #e2e8f0);
583
+ background: #fff;
584
+ border-radius: 7px;
585
+ padding: 6px 10px;
586
+ font: 500 12px var(--sa-font-body, system-ui, sans-serif);
587
+ cursor: pointer;
588
+ color: var(--sa-muted-dark, #475569);
589
+ }
590
+ .pc-btn-mini:hover {
591
+ background: #f1f5f9;
592
+ }
593
+
594
+ .pc-type-grid {
595
+ display: grid;
596
+ grid-template-columns: 1fr 1fr;
597
+ gap: 6px;
598
+ }
599
+ .pc-type-opt {
600
+ border: 1px solid var(--sa-border, #e2e8f0);
601
+ background: #fff;
602
+ border-radius: 8px;
603
+ padding: 8px 10px;
604
+ text-align: left;
605
+ cursor: pointer;
606
+ transition:
607
+ border-color 0.1s,
608
+ background 0.1s;
609
+ }
610
+ .pc-type-opt:hover {
611
+ border-color: #cbd5e1;
612
+ }
613
+ .pc-type-opt--active {
614
+ border-color: var(--sa-primary, #3f6bff);
615
+ background: var(--sa-primary-soft, rgba(63, 107, 255, 0.08));
616
+ }
617
+ .pc-type-opt__label {
618
+ font: 600 12.5px var(--sa-font-body, system-ui, sans-serif);
619
+ color: var(--sa-heading, #0f172a);
620
+ }
621
+ .pc-type-opt__sub {
622
+ font: 11.5px var(--sa-font-mono, ui-monospace, monospace);
623
+ color: var(--sa-muted, #64748b);
624
+ margin-top: 1px;
625
+ }
626
+
627
+ .pc-plan-pick {
628
+ display: flex;
629
+ flex-wrap: wrap;
630
+ gap: 6px;
631
+ }
632
+ .pc-plan-opt {
633
+ display: inline-flex;
634
+ align-items: center;
635
+ gap: 8px;
636
+ background: #fff;
637
+ border: 1px solid var(--sa-border, #e2e8f0);
638
+ border-radius: 999px;
639
+ padding: 5px 12px 5px 8px;
640
+ font: 600 12px var(--sa-font-body, system-ui, sans-serif);
641
+ cursor: pointer;
642
+ color: var(--sa-muted-dark, #475569);
643
+ transition:
644
+ border-color 0.1s,
645
+ background 0.1s;
646
+ }
647
+ .pc-plan-opt:hover {
648
+ border-color: #cbd5e1;
649
+ }
650
+ .pc-plan-opt--on {
651
+ border-color: var(--sa-primary, #3f6bff);
652
+ background: var(--sa-primary-soft, rgba(63, 107, 255, 0.08));
653
+ color: var(--sa-primary, #3f6bff);
654
+ }
655
+ .pc-plan-opt__mark {
656
+ width: 8px;
657
+ height: 8px;
658
+ border-radius: 50%;
659
+ }
660
+
661
+ .pc-dur {
662
+ display: flex;
663
+ gap: 4px;
664
+ }
665
+ .pc-dur-opt {
666
+ flex: 1;
667
+ border: 1px solid var(--sa-border, #e2e8f0);
668
+ background: #fff;
669
+ border-radius: 7px;
670
+ padding: 6px 10px;
671
+ font: 500 12px var(--sa-font-body, system-ui, sans-serif);
672
+ cursor: pointer;
673
+ color: var(--sa-muted-dark, #475569);
674
+ }
675
+ .pc-dur-opt--active {
676
+ border-color: var(--sa-primary, #3f6bff);
677
+ background: var(--sa-primary-soft, rgba(63, 107, 255, 0.08));
678
+ color: var(--sa-primary, #3f6bff);
679
+ }
680
+
681
+ .pc-check {
682
+ display: inline-flex;
683
+ align-items: center;
684
+ gap: 8px;
685
+ font: 13px var(--sa-font-body, system-ui, sans-serif);
686
+ color: var(--sa-body, #1e293b);
687
+ cursor: pointer;
688
+ }
689
+
690
+ .pc-preview {
691
+ background: var(--sa-primary-soft, rgba(63, 107, 255, 0.06));
692
+ border: 1px solid var(--sa-primary-border, rgba(63, 107, 255, 0.18));
693
+ border-radius: 10px;
694
+ padding: 12px 14px;
695
+ }
696
+ .pc-preview__eyebrow {
697
+ font-size: 11px;
698
+ font-weight: 700;
699
+ color: var(--sa-primary, #3f6bff);
700
+ text-transform: uppercase;
701
+ letter-spacing: 0.06em;
702
+ margin-bottom: 6px;
703
+ }
704
+ .pc-preview__body {
705
+ display: flex;
706
+ align-items: center;
707
+ gap: 10px;
708
+ flex-wrap: wrap;
709
+ }
710
+ .pc-preview__code {
711
+ background: #fff;
712
+ border: 1px solid var(--sa-border, #e2e8f0);
713
+ border-radius: 6px;
714
+ padding: 3px 8px;
715
+ font: 600 13px var(--sa-font-mono, ui-monospace, monospace);
716
+ letter-spacing: 0.04em;
717
+ color: var(--sa-heading, #0f172a);
718
+ }
719
+ .pc-preview__disc {
720
+ font: 700 13px var(--sa-font-body, system-ui, sans-serif);
721
+ color: var(--sa-positive, #047857);
722
+ }
723
+ .pc-preview__meta {
724
+ font-size: 12px;
725
+ color: var(--sa-muted, #64748b);
726
+ }
727
+
728
+ .pc-error {
729
+ background: #fef2f2;
730
+ border: 1px solid #fecaca;
731
+ color: #b91c1c;
732
+ font-size: 13px;
733
+ margin: 8px 0 0;
734
+ padding: 8px 12px;
735
+ border-radius: 8px;
736
+ }
737
+ </style>