@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.
- package/LICENSE +202 -0
- package/README.md +47 -0
- package/dist/index.cjs +3776 -0
- package/dist/index.d.cts +2395 -0
- package/dist/index.d.ts +2395 -0
- package/dist/index.js +3659 -0
- package/dist/testing-e2e/admin-pages-suite.cjs +134 -0
- package/dist/testing-e2e/admin-pages-suite.d.cts +37 -0
- package/dist/testing-e2e/admin-pages-suite.d.ts +37 -0
- package/dist/testing-e2e/admin-pages-suite.js +109 -0
- package/package.json +87 -0
- package/src/action-registry.ts +110 -0
- package/src/batch-column-fetcher.ts +152 -0
- package/src/boot-loader.ts +60 -0
- package/src/components/BundleVersionPublishDialog.vue +359 -0
- package/src/components/BusinessTypeVersionEditorDialog.vue +417 -0
- package/src/components/BusinessTypeVersionPublishDialog.vue +258 -0
- package/src/components/FeatureGate.vue +56 -0
- package/src/components/KpiCard.vue +53 -0
- package/src/components/KvBlock.vue +36 -0
- package/src/components/MarketingPromotionsTab.vue +861 -0
- package/src/components/MfaPromptDialog.vue +104 -0
- package/src/components/TenantActionConfirmDialog.vue +252 -0
- package/src/components/VersionDiffPreview.vue +150 -0
- package/src/components/bundle-editor/BundleCreatePanel.vue +681 -0
- package/src/components/bundle-editor/BundleFeaturesEditor.vue +215 -0
- package/src/components/bundle-editor/BundlePlanCompatPicker.vue +316 -0
- package/src/components/bundle-editor/BundleQuotasEditor.vue +219 -0
- package/src/components/bundle-editor/BundleStatusBanner.vue +189 -0
- package/src/components/bundle-editor/BundleVersionInlineEditor.vue +659 -0
- package/src/components/bundle-editor/BundleVersionStrip.vue +207 -0
- package/src/components/bundle-editor/bundle-version-status.ts +164 -0
- package/src/components/bundle-editor/catalog-i18n.ts +85 -0
- package/src/components/dialogs/PilotCreateDialog.vue +780 -0
- package/src/components/dialogs/PilotEditDialog.vue +548 -0
- package/src/components/dialogs/PromoCodeCreateDialog.vue +737 -0
- package/src/components/dialogs/PromoCodeEditDialog.vue +859 -0
- package/src/components/dialogs/types.ts +126 -0
- package/src/components/plan/PlanCycleToggle.vue +99 -0
- package/src/components/plan/PlanGrid.vue +211 -0
- package/src/components/plan/PriceSummary.vue +293 -0
- package/src/components/plan/PromoCodeInput.vue +192 -0
- package/src/components/plan/PublicBundleGrid.vue +287 -0
- package/src/components/plan-cockpit/PlanCockpit.vue +993 -0
- package/src/components/plan-cockpit/PlanCockpitAuditLog.vue +65 -0
- package/src/components/plan-cockpit/PlanCockpitDiffPanel.vue +91 -0
- package/src/components/plan-cockpit/PlanCockpitHeader.vue +98 -0
- package/src/components/plan-cockpit/PlanCockpitImpactPanel.vue +99 -0
- package/src/components/plan-cockpit/PlanCockpitKpis.vue +57 -0
- package/src/components/plan-cockpit/PlanCockpitVersions.vue +226 -0
- package/src/components/plan-cockpit/types.ts +72 -0
- package/src/components/plan-create-dialog/PlanCreateDialog.vue +508 -0
- package/src/components/plan-detail/PlanAuditLog.vue +59 -0
- package/src/components/plan-detail/PlanDetail.vue +1195 -0
- package/src/components/plan-detail/PlanDetailHeader.vue +185 -0
- package/src/components/plan-detail/PlanDetailKpis.vue +57 -0
- package/src/components/plan-detail/PlanTerminateDialog.vue +76 -0
- package/src/components/plan-detail/PlanVersionDiffPanel.vue +149 -0
- package/src/components/plan-detail/PlanVersionsPanel.vue +255 -0
- package/src/components/plan-detail/types.ts +59 -0
- package/src/components/plan-list/PlanList.vue +1067 -0
- package/src/components/plan-matrix/PlanMatrix.vue +1113 -0
- package/src/components/plan-review/PlanReview.vue +742 -0
- package/src/components/plan-version-editor/PlanCatalogPreview.vue +184 -0
- package/src/components/plan-version-editor/PlanComponentPool.vue +272 -0
- package/src/components/plan-version-editor/PlanVersionBasket.vue +281 -0
- package/src/components/plan-version-editor/PlanVersionDiffDialog.vue +84 -0
- package/src/components/plan-version-editor/PlanVersionEditor.vue +1728 -0
- package/src/components/plan-version-editor/PlanVersionEditorHeader.vue +94 -0
- package/src/components/plan-version-editor/types.ts +96 -0
- package/src/components/wizard-stepper/WizardStepper.vue +107 -0
- package/src/create-admin-routes.ts +68 -0
- package/src/create-super-admin-app.ts +345 -0
- package/src/entitlement-provider.ts +36 -0
- package/src/feature-router-guard.ts +77 -0
- package/src/http-json.ts +62 -0
- package/src/index.ts +98 -0
- package/src/manifest-loader.ts +139 -0
- package/src/manifest-store-factory.ts +114 -0
- package/src/nav-builder.ts +274 -0
- package/src/pages-standard/AdminLayout.vue +411 -0
- package/src/pages-standard/AdminManifestErrorPage.vue +79 -0
- package/src/pages-standard/AuditPage.vue +223 -0
- package/src/pages-standard/BundlesPage.vue +910 -0
- package/src/pages-standard/BusinessTypesPage.vue +662 -0
- package/src/pages-standard/DashboardPage.vue +595 -0
- package/src/pages-standard/DiscoveryPage.vue +663 -0
- package/src/pages-standard/EmailHistoryPage.vue +576 -0
- package/src/pages-standard/MarketingCatalogPage.vue +1771 -0
- package/src/pages-standard/PilotsPage.vue +577 -0
- package/src/pages-standard/PlanVersionsPage.vue +216 -0
- package/src/pages-standard/PlansPage.vue +1222 -0
- package/src/pages-standard/PlatformEmailPage.vue +434 -0
- package/src/pages-standard/PromoCodeDetailPage.vue +331 -0
- package/src/pages-standard/PromoCodesPage.vue +589 -0
- package/src/pages-standard/SubscriptionsPage.vue +159 -0
- package/src/pages-standard/SuperAdminLoginPage.vue +299 -0
- package/src/pages-standard/SuperAdminSetupWizard.vue +405 -0
- package/src/pages-standard/TenantDetailPage.vue +432 -0
- package/src/pages-standard/TenantsPage.vue +732 -0
- package/src/pages-standard/UsersPage.vue +504 -0
- package/src/pages-standard/bundles-page/BundleAccordionList.vue +116 -0
- package/src/pages-standard/bundles-page/BundleDetailPanel.vue +193 -0
- package/src/pages-standard/bundles-page/BundlesFilterBar.vue +48 -0
- package/src/pages-standard/bundles-page/BundlesHeader.vue +67 -0
- package/src/pages-standard/bundles-page/BundlesKpis.vue +39 -0
- package/src/pages-standard/bundles-page/types.ts +15 -0
- package/src/pages-standard/discovery-page/CatalogEntryTransPanel.vue +246 -0
- package/src/pages-standard/discovery-page/DiscoveryCapList.vue +171 -0
- package/src/pages-standard/discovery-page/DiscoveryFeatureCard.vue +410 -0
- package/src/pages-standard/discovery-page/DiscoveryHeader.vue +31 -0
- package/src/pages-standard/discovery-page/DiscoveryKpis.vue +47 -0
- package/src/pages-standard/discovery-page/DiscoveryMetaBanner.vue +26 -0
- package/src/pages-standard/discovery-page/DiscoveryQuotaCard.vue +247 -0
- package/src/pages-standard/discovery-page/DiscoveryStatusControl.vue +73 -0
- package/src/pages-standard/discovery-page/discovery-ui.ts +166 -0
- package/src/pages-standard/email-history.types.ts +48 -0
- package/src/pages-standard/marketing-catalog/MarketingCatalogAdmin.vue +448 -0
- package/src/pages-standard/marketing-catalog/MarketingCatalogHeader.vue +93 -0
- package/src/pages-standard/marketing-catalog/MarketingCatalogPreview.vue +138 -0
- package/src/pages-standard/marketing-catalog/MarketingCatalogToolbar.vue +58 -0
- package/src/pages-standard/marketing-catalog/types.ts +43 -0
- package/src/pages-standard/plan-versions/PlanDiffCard.vue +132 -0
- package/src/pages-standard/plan-versions/PlanVersionHeader.vue +293 -0
- package/src/pages-standard/plan-versions/PlanVersionStatusBadge.vue +48 -0
- package/src/pages-standard/plan-versions/PlanVersionsAudit.vue +307 -0
- package/src/pages-standard/plan-versions/PlanVersionsDiff.vue +269 -0
- package/src/pages-standard/plan-versions/PlanVersionsKpi.vue +65 -0
- package/src/pages-standard/plan-versions/PlanVersionsList.vue +292 -0
- package/src/pages-standard/plan-versions/PlanVersionsMatrix.vue +261 -0
- package/src/pages-standard/plan-versions/PlanVersionsTimeline.vue +300 -0
- package/src/pages-standard/plan-versions/VersionDiffPreview.vue +167 -0
- package/src/pages-standard/plan-versions/format.ts +53 -0
- package/src/pages-standard/plan-versions/types.ts +33 -0
- package/src/pages-standard/plans-page/PlanArchiveDialog.vue +50 -0
- package/src/pages-standard/plans-page/PlanBundleOverview.vue +147 -0
- package/src/pages-standard/plans-page/PlanDiscardDraftDialog.vue +46 -0
- package/src/pages-standard/plans-page/PlanPublishDialog.vue +106 -0
- package/src/pages-standard/plans-page/PlansPageToast.vue +69 -0
- package/src/pages-standard/plans-page/types.ts +18 -0
- package/src/pages-standard/platform-email.types.ts +41 -0
- package/src/pages-standard/sa-theme.css +223 -0
- package/src/pages-standard/tenants/StatusPill.vue +62 -0
- package/src/pages-standard/tenants/format.ts +38 -0
- package/src/pages-tenant/LimitsRow.vue +67 -0
- package/src/pages-tenant/MySubscriptionBundlesPage.vue +604 -0
- package/src/pages-tenant/OnboardingConfigurator.vue +417 -0
- package/src/pages-tenant/PackageSnapshotPanel.vue +241 -0
- package/src/pages-tenant/PendingVersionBanner.vue +126 -0
- package/src/pages-tenant/PlanChangeWizard.vue +630 -0
- package/src/pages-tenant/TenantPlanSection.vue +729 -0
- package/src/pages-tenant/UsageBar.vue +107 -0
- package/src/pages-tenant/default-i18n.ts +280 -0
- package/src/pages-tenant/tenant-plan-section/BundlePreviewDialog.vue +304 -0
- package/src/pages-tenant/tenant-plan-section/TenantBundleStore.vue +323 -0
- package/src/pages-tenant/tenant-plan-section/TenantFeatureMatrix.vue +122 -0
- package/src/pages-tenant/tenant-plan-section/TenantPlanCardHeader.vue +67 -0
- package/src/pages-tenant/tenant-plan-section/TenantUsageGrid.vue +31 -0
- package/src/plan-versions-catalog.ts +368 -0
- package/src/platform-loaders.ts +68 -0
- package/src/project-page-host.ts +108 -0
- package/src/testing-e2e/admin-pages-suite.ts +187 -0
- package/src/types.ts +58 -0
- package/src/use-actions.ts +25 -0
- package/src/use-api-list.ts +151 -0
- package/src/use-audit-entries.ts +48 -0
- package/src/use-batch-columns.ts +59 -0
- package/src/use-bulk-publish.ts +150 -0
- package/src/use-bundle-versions-map.ts +112 -0
- package/src/use-bundles.ts +297 -0
- package/src/use-business-types.ts +269 -0
- package/src/use-catalog-entries.ts +243 -0
- package/src/use-discovery.ts +158 -0
- package/src/use-entitlement.ts +87 -0
- package/src/use-live-plan-versions.ts +126 -0
- package/src/use-manifest.ts +57 -0
- package/src/use-marketing-projections.ts +160 -0
- package/src/use-nav.ts +33 -0
- package/src/use-plan-editor.ts +162 -0
- package/src/use-plan-versions.ts +83 -0
- package/src/use-plans.ts +375 -0
- package/src/use-platform-tenant-actions.ts +286 -0
- package/src/use-promotions.ts +126 -0
- package/src/use-public-boot.ts +40 -0
- package/src/use-subscription-draft.ts +355 -0
- package/src/use-super-admin-context.ts +93 -0
- package/src/use-tenant-action-flow.ts +238 -0
- package/src/use-tenant-billing-catalog.ts +160 -0
- package/src/use-tenant-billing.ts +453 -0
- package/src/use-tenant-manifest.ts +94 -0
- package/src/use-tenant-subscription-bundles.ts +148 -0
- package/src/use-tenants.ts +61 -0
- package/src/version.ts +10 -0
|
@@ -0,0 +1,1771 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
MarketingCatalogPage — marketing catalog for SuperAdmin (SPEC_V2 §11.1 M3).
|
|
3
|
+
|
|
4
|
+
Public marketing projection per plan: filtered view for website,
|
|
5
|
+
pricing page and checkout. Two tabs:
|
|
6
|
+
· Public catalog preview — browser mockup of the pricing page.
|
|
7
|
+
· Marketing administration — visibility, badge, priority, highlight,
|
|
8
|
+
teaser, trial & top features per plan.
|
|
9
|
+
|
|
10
|
+
Live-data sources:
|
|
11
|
+
· usePlans — plan master records.
|
|
12
|
+
· /catalog/plans/:id/versions — live PlanVersion (pricing + features).
|
|
13
|
+
· useMarketingProjections — MarketingProjection per (PLAN, live version,
|
|
14
|
+
locale). Edits go live immediately.
|
|
15
|
+
|
|
16
|
+
Self-sufficient page (like PlansPage): consumers only pass through
|
|
17
|
+
`adminEndpoint` + `projectKey`, the wrapper stays thin.
|
|
18
|
+
-->
|
|
19
|
+
<template>
|
|
20
|
+
<div class="mc">
|
|
21
|
+
<MarketingCatalogHeader
|
|
22
|
+
v-model:locale-picker-open="localePickerOpen"
|
|
23
|
+
:active-locale-set="activeLocaleSet"
|
|
24
|
+
:active-locale="activeLocale"
|
|
25
|
+
:default-locale="defaultLocale"
|
|
26
|
+
:addable-locales="addableLocales"
|
|
27
|
+
:busy="busy"
|
|
28
|
+
@locale-change="onLocaleChange"
|
|
29
|
+
@remove-locale="removeLocale"
|
|
30
|
+
@add-locale="addLocale"
|
|
31
|
+
@reload="reloadAll"
|
|
32
|
+
/>
|
|
33
|
+
|
|
34
|
+
<div v-if="pageError" class="mc-banner mc-banner--error" role="alert">
|
|
35
|
+
<strong>Fehler:</strong> {{ pageError }}
|
|
36
|
+
<button class="mc-banner-x" type="button" @click="pageError = null">×</button>
|
|
37
|
+
</div>
|
|
38
|
+
|
|
39
|
+
<MarketingCatalogToolbar
|
|
40
|
+
v-model:tab="tab"
|
|
41
|
+
:active-promo-count="activePromoCount"
|
|
42
|
+
:catalog-version="catalogVersion"
|
|
43
|
+
:active-locale="activeLocale"
|
|
44
|
+
/>
|
|
45
|
+
|
|
46
|
+
<div v-if="loading" class="mc-loading">Lade Marketing-Catalog …</div>
|
|
47
|
+
|
|
48
|
+
<!-- ─── Tab: Promotions ─── -->
|
|
49
|
+
<MarketingPromotionsTab
|
|
50
|
+
v-else-if="tab === 'promos'"
|
|
51
|
+
:promotions="promotions"
|
|
52
|
+
:plans="promoPlanOptions"
|
|
53
|
+
:active-locales="activeLocaleSet"
|
|
54
|
+
:busy="busy"
|
|
55
|
+
:project-key="projectKey"
|
|
56
|
+
:create="promotionsApi.create"
|
|
57
|
+
:update="promotionsApi.update"
|
|
58
|
+
:remove="promotionsApi.remove"
|
|
59
|
+
/>
|
|
60
|
+
|
|
61
|
+
<div v-else-if="rows.length === 0" class="mc-banner mc-banner--info">
|
|
62
|
+
Noch keine Pläne angelegt — der Marketing-Catalog projiziert veröffentlichte Pläne. Lege
|
|
63
|
+
zuerst unter <strong>Pläne</strong> einen Plan mit Live-Version an.
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
<MarketingCatalogPreview
|
|
67
|
+
v-else-if="tab === 'preview'"
|
|
68
|
+
:visible-rows="visibleRows"
|
|
69
|
+
:preview-url="previewUrl"
|
|
70
|
+
:monthly-of="monthlyOf"
|
|
71
|
+
:yearly-of="yearlyOf"
|
|
72
|
+
:format-euro="formatEuro"
|
|
73
|
+
:promo-of="promoOf"
|
|
74
|
+
:promo-result-of="promoResultOf"
|
|
75
|
+
:promo-badge-of="promoBadgeOf"
|
|
76
|
+
:promo-fineprint-of="promoFineprintOf"
|
|
77
|
+
:promo-color-of="promoColorOf"
|
|
78
|
+
:cta-text="ctaText"
|
|
79
|
+
:show-trial-note="showTrialNote"
|
|
80
|
+
:top-feature-label="topFeatureLabel"
|
|
81
|
+
/>
|
|
82
|
+
|
|
83
|
+
<MarketingCatalogAdmin
|
|
84
|
+
v-else
|
|
85
|
+
:admin-rows="adminRows"
|
|
86
|
+
:busy="busy"
|
|
87
|
+
:expanded-key="expandedKey"
|
|
88
|
+
:active-locale="activeLocale"
|
|
89
|
+
:default-locale="defaultLocale"
|
|
90
|
+
:edit-features="editFeatures"
|
|
91
|
+
:format-version-title="formatVersionTitle"
|
|
92
|
+
:format-version-tab="formatVersionTab"
|
|
93
|
+
:auto-cta-text="autoCtaText"
|
|
94
|
+
:cta-value="ctaValue"
|
|
95
|
+
:resolve-component-label="resolveComponentLabel"
|
|
96
|
+
:suggestions-for="suggestionsFor"
|
|
97
|
+
@select-version="selectVersion"
|
|
98
|
+
@patch="patch"
|
|
99
|
+
@patch-display-label="patchDisplayLabel"
|
|
100
|
+
@toggle-expand="toggleExpand"
|
|
101
|
+
@update-feature-label="updateFeatureLabel"
|
|
102
|
+
@update-feature-strong="updateFeatureStrong"
|
|
103
|
+
@persist-features="persistFeatures"
|
|
104
|
+
@move-feature="moveFeature"
|
|
105
|
+
@remove-feature="removeFeature"
|
|
106
|
+
@add-feature="addFeature"
|
|
107
|
+
@add-suggestion="addSuggestion"
|
|
108
|
+
/>
|
|
109
|
+
</div>
|
|
110
|
+
</template>
|
|
111
|
+
|
|
112
|
+
<script setup lang="ts">
|
|
113
|
+
import { computed, onMounted, ref } from 'vue';
|
|
114
|
+
import {
|
|
115
|
+
applyPromo,
|
|
116
|
+
pickActivePromo,
|
|
117
|
+
promoStatus,
|
|
118
|
+
type MarketingProjectionRow,
|
|
119
|
+
type MarketingTopFeature,
|
|
120
|
+
type PlanRow,
|
|
121
|
+
type PlanVersionRow,
|
|
122
|
+
type PromotionResult,
|
|
123
|
+
type PromotionRow,
|
|
124
|
+
} from '@saasicat/types';
|
|
125
|
+
import { usePlans } from '../use-plans.js';
|
|
126
|
+
import { useMarketingProjections } from '../use-marketing-projections.js';
|
|
127
|
+
import { usePromotions } from '../use-promotions.js';
|
|
128
|
+
import { useCatalogEntries } from '../use-catalog-entries.js';
|
|
129
|
+
import { defaultHttpClient, type HttpClient } from '../types.js';
|
|
130
|
+
import MarketingPromotionsTab from '../components/MarketingPromotionsTab.vue';
|
|
131
|
+
import MarketingCatalogAdmin from './marketing-catalog/MarketingCatalogAdmin.vue';
|
|
132
|
+
import MarketingCatalogHeader from './marketing-catalog/MarketingCatalogHeader.vue';
|
|
133
|
+
import MarketingCatalogPreview from './marketing-catalog/MarketingCatalogPreview.vue';
|
|
134
|
+
import MarketingCatalogToolbar from './marketing-catalog/MarketingCatalogToolbar.vue';
|
|
135
|
+
import type {
|
|
136
|
+
FeatureSuggestion,
|
|
137
|
+
MarketingCatalogTab,
|
|
138
|
+
MarketingRow,
|
|
139
|
+
ResolvedMarketing,
|
|
140
|
+
} from './marketing-catalog/types.js';
|
|
141
|
+
|
|
142
|
+
const DEFAULT_ACCENT = '#64748b';
|
|
143
|
+
|
|
144
|
+
const props = defineProps<{
|
|
145
|
+
adminEndpoint: string;
|
|
146
|
+
projectKey: string;
|
|
147
|
+
http?: HttpClient;
|
|
148
|
+
getAuthToken?: () => string | null;
|
|
149
|
+
/** Available locales — the first one is the default. Defaults to `['de']`. */
|
|
150
|
+
availableLocales?: string[];
|
|
151
|
+
/** Feature-label map for top-feature suggestions (key → label). */
|
|
152
|
+
featureRegistry?: Record<string, { label?: string }>;
|
|
153
|
+
/** Quota label/unit map for top-feature suggestions. */
|
|
154
|
+
quotaRegistry?: Record<string, { label?: string; unit?: string }>;
|
|
155
|
+
/** Accent color per plan key for the plan mark in the administration view. */
|
|
156
|
+
planAccents?: Record<string, string>;
|
|
157
|
+
}>();
|
|
158
|
+
|
|
159
|
+
const availableLocales = computed(() =>
|
|
160
|
+
props.availableLocales && props.availableLocales.length > 0 ? props.availableLocales : ['de'],
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
const tab = ref<MarketingCatalogTab>('preview');
|
|
164
|
+
const expandedKey = ref<string | null>(null);
|
|
165
|
+
const activeLocale = ref<string>(availableLocales.value[0]);
|
|
166
|
+
|
|
167
|
+
// LocaleManager — `availableLocales` is the allowed pool (app config,
|
|
168
|
+
// SPEC_V2 §6.5), `activeLocaleSet` the runtime-activated subset.
|
|
169
|
+
// Persisted via `/admin/catalog/marketing-settings` (MarketingSettings).
|
|
170
|
+
const defaultLocale = computed(() => availableLocales.value[0]);
|
|
171
|
+
const activeLocaleSet = ref<string[]>([...availableLocales.value]);
|
|
172
|
+
const localePickerOpen = ref(false);
|
|
173
|
+
const addableLocales = computed(() =>
|
|
174
|
+
availableLocales.value.filter((l) => !activeLocaleSet.value.includes(l)),
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
/** Loads the persisted activeLocales subset (fallback: full pool). */
|
|
178
|
+
async function loadMarketingSettings(): Promise<void> {
|
|
179
|
+
try {
|
|
180
|
+
const res = await httpClient(
|
|
181
|
+
`${props.adminEndpoint}/catalog/marketing-settings?projectKey=${encodeURIComponent(props.projectKey)}`,
|
|
182
|
+
{ headers: authHeaders() },
|
|
183
|
+
);
|
|
184
|
+
if (res.status !== 200) return;
|
|
185
|
+
const body = (await res.json().catch(() => null)) as {
|
|
186
|
+
activeLocales?: string[];
|
|
187
|
+
} | null;
|
|
188
|
+
if (body && Array.isArray(body.activeLocales) && body.activeLocales.length > 0) {
|
|
189
|
+
// Restrict to the valid pool; the default locale stays active.
|
|
190
|
+
const pool = availableLocales.value;
|
|
191
|
+
const next = body.activeLocales.filter((l) => pool.includes(l));
|
|
192
|
+
if (!next.includes(defaultLocale.value)) next.unshift(defaultLocale.value);
|
|
193
|
+
activeLocaleSet.value = next;
|
|
194
|
+
}
|
|
195
|
+
} catch {
|
|
196
|
+
// Network error — stays with the pool default.
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Persists the current activeLocales selection (best-effort). */
|
|
201
|
+
async function persistActiveLocales(): Promise<void> {
|
|
202
|
+
try {
|
|
203
|
+
await httpClient(`${props.adminEndpoint}/catalog/marketing-settings`, {
|
|
204
|
+
method: 'PUT',
|
|
205
|
+
headers: { 'content-type': 'application/json', ...authHeaders() },
|
|
206
|
+
body: JSON.stringify({
|
|
207
|
+
projectKey: props.projectKey,
|
|
208
|
+
activeLocales: activeLocaleSet.value,
|
|
209
|
+
}),
|
|
210
|
+
});
|
|
211
|
+
} catch {
|
|
212
|
+
// best-effort — the UI state is preserved.
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function addLocale(loc: string): void {
|
|
217
|
+
if (!activeLocaleSet.value.includes(loc)) {
|
|
218
|
+
activeLocaleSet.value = [...activeLocaleSet.value, loc];
|
|
219
|
+
void persistActiveLocales();
|
|
220
|
+
}
|
|
221
|
+
localePickerOpen.value = false;
|
|
222
|
+
}
|
|
223
|
+
function removeLocale(loc: string): void {
|
|
224
|
+
if (loc === defaultLocale.value) return;
|
|
225
|
+
activeLocaleSet.value = activeLocaleSet.value.filter((l) => l !== loc);
|
|
226
|
+
if (activeLocale.value === loc) void onLocaleChange(defaultLocale.value);
|
|
227
|
+
void persistActiveLocales();
|
|
228
|
+
}
|
|
229
|
+
const busy = ref(false);
|
|
230
|
+
const pageError = ref<string | null>(null);
|
|
231
|
+
const versionsByPlanId = ref<Record<string, PlanVersionRow[]>>({});
|
|
232
|
+
|
|
233
|
+
/** Local editing copy of the top features of the currently expanded row. */
|
|
234
|
+
const editFeatures = ref<MarketingTopFeature[]>([]);
|
|
235
|
+
|
|
236
|
+
const plansApi = usePlans({
|
|
237
|
+
adminEndpoint: props.adminEndpoint,
|
|
238
|
+
projectKey: props.projectKey,
|
|
239
|
+
http: props.http,
|
|
240
|
+
getAuthToken: props.getAuthToken,
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
const projectionsApi = useMarketingProjections({
|
|
244
|
+
adminEndpoint: props.adminEndpoint,
|
|
245
|
+
http: props.http,
|
|
246
|
+
getAuthToken: props.getAuthToken,
|
|
247
|
+
filter: { projectKey: props.projectKey, targetType: 'PLAN', locale: activeLocale.value },
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
const promotionsApi = usePromotions({
|
|
251
|
+
adminEndpoint: props.adminEndpoint,
|
|
252
|
+
projectKey: props.projectKey,
|
|
253
|
+
http: props.http,
|
|
254
|
+
getAuthToken: props.getAuthToken,
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
// Catalog entries (features + quotas with i18n) — provides the translated
|
|
258
|
+
// labels for the top-features editor (SPEC_V2 §6.3 + §6.5).
|
|
259
|
+
const catalogEntriesApi = useCatalogEntries({
|
|
260
|
+
adminEndpoint: props.adminEndpoint,
|
|
261
|
+
projectKey: props.projectKey,
|
|
262
|
+
http: props.http,
|
|
263
|
+
getAuthToken: props.getAuthToken,
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
const loading = computed(() => plansApi.loading.value || projectionsApi.loading.value);
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Translated label of a feature/quota key for the editing locale.
|
|
270
|
+
* Order: catalog-entry i18n → catalog-entry label → static
|
|
271
|
+
* registry prop → key as last fallback.
|
|
272
|
+
*/
|
|
273
|
+
function resolveComponentLabel(key: string): string {
|
|
274
|
+
const loc = activeLocale.value;
|
|
275
|
+
const f = catalogEntriesApi.features.value.find((x) => x.featureKey === key);
|
|
276
|
+
if (f) {
|
|
277
|
+
const translated = f.i18n?.[loc]?.label;
|
|
278
|
+
if (translated && translated.trim()) return translated;
|
|
279
|
+
if (f.label && f.label !== key) return f.label;
|
|
280
|
+
}
|
|
281
|
+
const q = catalogEntriesApi.quotas.value.find((x) => x.quotaKey === key);
|
|
282
|
+
if (q) {
|
|
283
|
+
const translated = q.i18n?.[loc]?.label;
|
|
284
|
+
if (translated && translated.trim()) return translated;
|
|
285
|
+
if (q.label && q.label !== key) return q.label;
|
|
286
|
+
}
|
|
287
|
+
return props.featureRegistry?.[key]?.label ?? props.quotaRegistry?.[key]?.label ?? key;
|
|
288
|
+
}
|
|
289
|
+
/** Translated quota unit for the editing locale. */
|
|
290
|
+
function resolveQuotaUnit(key: string): string {
|
|
291
|
+
const loc = activeLocale.value;
|
|
292
|
+
const q = catalogEntriesApi.quotas.value.find((x) => x.quotaKey === key);
|
|
293
|
+
if (q) {
|
|
294
|
+
const translated = q.i18n?.[loc]?.unit;
|
|
295
|
+
if (translated && translated.trim()) return translated;
|
|
296
|
+
if (q.unit) return q.unit;
|
|
297
|
+
}
|
|
298
|
+
return props.quotaRegistry?.[key]?.unit ?? '';
|
|
299
|
+
}
|
|
300
|
+
/** Effective top-feature label: the `label` override wins, otherwise key resolution. */
|
|
301
|
+
function topFeatureLabel(f: MarketingTopFeature): string {
|
|
302
|
+
let key = f.key;
|
|
303
|
+
let label = (f.label ?? '').trim();
|
|
304
|
+
// Migration: legacy entry whose `label` is a known key.
|
|
305
|
+
if (!key && label && knownComponentKeys.value.has(label)) {
|
|
306
|
+
key = label;
|
|
307
|
+
label = '';
|
|
308
|
+
}
|
|
309
|
+
if (label) return label;
|
|
310
|
+
return key ? resolveComponentLabel(key) : '';
|
|
311
|
+
}
|
|
312
|
+
/** Set of all known feature/quota keys — for migrating legacy entries. */
|
|
313
|
+
const knownComponentKeys = computed(() => {
|
|
314
|
+
const s = new Set<string>();
|
|
315
|
+
for (const f of catalogEntriesApi.features.value) s.add(f.featureKey);
|
|
316
|
+
for (const q of catalogEntriesApi.quotas.value) s.add(q.quotaKey);
|
|
317
|
+
return s;
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
/** Plan list (key + label) for the promotions `appliesTo` selection. */
|
|
321
|
+
const promoPlanOptions = computed(() =>
|
|
322
|
+
plansApi.plans.value.map((p) => ({
|
|
323
|
+
key: p.planKey,
|
|
324
|
+
label: p.label || p.planKey,
|
|
325
|
+
})),
|
|
326
|
+
);
|
|
327
|
+
|
|
328
|
+
/** Promotions list + count of currently active promotions (for the tab badge). */
|
|
329
|
+
const promotions = computed(() => promotionsApi.promotions.value);
|
|
330
|
+
const activePromoCount = computed(
|
|
331
|
+
() => promotions.value.filter((p) => promoStatus(p) === 'active').length,
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
const httpClient: HttpClient = props.http ?? defaultHttpClient();
|
|
335
|
+
|
|
336
|
+
function authHeaders(): Record<string, string> {
|
|
337
|
+
const token = props.getAuthToken?.();
|
|
338
|
+
return token ? { Authorization: `Bearer ${token}` } : {};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async function reloadVersions(): Promise<void> {
|
|
342
|
+
const results = await Promise.all(
|
|
343
|
+
plansApi.plans.value.map(async (p) => {
|
|
344
|
+
const res = await httpClient(`${props.adminEndpoint}/catalog/plans/${p.id}/versions`, {
|
|
345
|
+
headers: authHeaders(),
|
|
346
|
+
});
|
|
347
|
+
if (res.status !== 200) return [p.id, [] as PlanVersionRow[]] as const;
|
|
348
|
+
const body = (await res.json().catch(() => [])) as PlanVersionRow[];
|
|
349
|
+
return [p.id, Array.isArray(body) ? body : []] as const;
|
|
350
|
+
}),
|
|
351
|
+
);
|
|
352
|
+
const next: Record<string, PlanVersionRow[]> = {};
|
|
353
|
+
for (const [id, vs] of results) next[id] = vs;
|
|
354
|
+
versionsByPlanId.value = next;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async function reloadAll(): Promise<void> {
|
|
358
|
+
busy.value = true;
|
|
359
|
+
pageError.value = null;
|
|
360
|
+
try {
|
|
361
|
+
await Promise.all([
|
|
362
|
+
plansApi.load(),
|
|
363
|
+
projectionsApi.load(),
|
|
364
|
+
promotionsApi.load(),
|
|
365
|
+
catalogEntriesApi.load(),
|
|
366
|
+
loadMarketingSettings(),
|
|
367
|
+
]);
|
|
368
|
+
await reloadVersions();
|
|
369
|
+
} catch (err) {
|
|
370
|
+
pageError.value = err instanceof Error ? err.message : String(err);
|
|
371
|
+
} finally {
|
|
372
|
+
busy.value = false;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
onMounted(() => {
|
|
377
|
+
void reloadAll();
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* All published versions of a plan, sorted by `validFrom` ascending.
|
|
382
|
+
* Serves as the tab list in the marketing-catalog administration.
|
|
383
|
+
*/
|
|
384
|
+
function publishedVersionsOf(plan: PlanRow): PlanVersionRow[] {
|
|
385
|
+
const versions = versionsByPlanId.value[plan.id] ?? [];
|
|
386
|
+
return versions
|
|
387
|
+
.filter((v) => v.publishedAt !== null)
|
|
388
|
+
.sort((a, b) => {
|
|
389
|
+
const fa = a.validFrom ? new Date(a.validFrom).getTime() : 0;
|
|
390
|
+
const fb = b.validFrom ? new Date(b.validFrom).getTime() : 0;
|
|
391
|
+
if (fa !== fb) return fa - fb;
|
|
392
|
+
return a.version - b.version;
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Version active as of `asOf` (SPEC_V2 §4.2): `validFrom <= asOf` and
|
|
398
|
+
* (`validUntil == null OR validUntil > asOf`). Default is the version
|
|
399
|
+
* active today. Fallback when no `validFrom` data is maintained:
|
|
400
|
+
* highest version.
|
|
401
|
+
*/
|
|
402
|
+
function activeVersionOf(plan: PlanRow, asOf: Date = new Date()): PlanVersionRow | null {
|
|
403
|
+
const published = publishedVersionsOf(plan);
|
|
404
|
+
if (published.length === 0) {
|
|
405
|
+
// Effective version = live ?? draft (same principle as the
|
|
406
|
+
// plan matrix): otherwise initial-seed drafts are not
|
|
407
|
+
// curatable and provide no top-feature suggestions.
|
|
408
|
+
const drafts = (versionsByPlanId.value[plan.id] ?? []).filter((v) => !v.publishedAt);
|
|
409
|
+
return drafts.sort((a, b) => b.version - a.version)[0] ?? null;
|
|
410
|
+
}
|
|
411
|
+
const t = asOf.getTime();
|
|
412
|
+
const active = published.filter((v) => {
|
|
413
|
+
if (!v.validFrom) return false;
|
|
414
|
+
const from = new Date(v.validFrom).getTime();
|
|
415
|
+
if (Number.isNaN(from) || from > t) return false;
|
|
416
|
+
if (v.validUntil) {
|
|
417
|
+
const until = new Date(v.validUntil).getTime();
|
|
418
|
+
if (!Number.isNaN(until) && until <= t) return false;
|
|
419
|
+
}
|
|
420
|
+
return true;
|
|
421
|
+
});
|
|
422
|
+
if (active.length > 0) {
|
|
423
|
+
// Highest validFrom wins (= "most recent active").
|
|
424
|
+
return active[active.length - 1];
|
|
425
|
+
}
|
|
426
|
+
// No match (validFrom all in the future OR all expired)
|
|
427
|
+
// → fall back to the last non-superseded version.
|
|
428
|
+
return (
|
|
429
|
+
[...published].reverse().find((v) => v.supersededAt === null) ??
|
|
430
|
+
published[published.length - 1]
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** Per-plan state: which version is currently selected in the UI (tab). */
|
|
435
|
+
const selectedVersionByPlanId = ref<Record<string, string>>({});
|
|
436
|
+
|
|
437
|
+
function selectedVersionOf(plan: PlanRow): PlanVersionRow | null {
|
|
438
|
+
const explicit = selectedVersionByPlanId.value[plan.id];
|
|
439
|
+
if (explicit) {
|
|
440
|
+
const found = (versionsByPlanId.value[plan.id] ?? []).find((v) => v.id === explicit);
|
|
441
|
+
if (found) return found;
|
|
442
|
+
}
|
|
443
|
+
return activeVersionOf(plan);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function selectVersion(plan: PlanRow, versionId: string): void {
|
|
447
|
+
selectedVersionByPlanId.value = {
|
|
448
|
+
...selectedVersionByPlanId.value,
|
|
449
|
+
[plan.id]: versionId,
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function formatVersionTab(v: PlanVersionRow): string {
|
|
454
|
+
const from = v.validFrom ? formatDateShort(v.validFrom) : '?';
|
|
455
|
+
const until = v.validUntil ? formatDateShort(v.validUntil) : '∞';
|
|
456
|
+
return `v${v.version} · ${from}–${until}`;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function formatVersionTitle(v: PlanVersionRow): string {
|
|
460
|
+
const from = v.validFrom ? formatDateLong(v.validFrom) : 'unbekannt';
|
|
461
|
+
const until = v.validUntil ? formatDateLong(v.validUntil) : 'unbegrenzt';
|
|
462
|
+
const changeNote = v.changeNote ? ` — ${v.changeNote}` : '';
|
|
463
|
+
return `Version ${v.version}: gültig ${from} bis ${until}${changeNote}`;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function formatDateShort(iso: string): string {
|
|
467
|
+
try {
|
|
468
|
+
return new Date(iso).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' });
|
|
469
|
+
} catch {
|
|
470
|
+
return iso.slice(0, 10);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function formatDateLong(iso: string): string {
|
|
475
|
+
try {
|
|
476
|
+
return new Date(iso).toLocaleDateString('de-DE', {
|
|
477
|
+
day: '2-digit',
|
|
478
|
+
month: 'short',
|
|
479
|
+
year: 'numeric',
|
|
480
|
+
});
|
|
481
|
+
} catch {
|
|
482
|
+
return iso.slice(0, 10);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function resolveMarketing(
|
|
487
|
+
plan: PlanRow,
|
|
488
|
+
projection: MarketingProjectionRow | null,
|
|
489
|
+
): ResolvedMarketing {
|
|
490
|
+
if (projection) {
|
|
491
|
+
// `displayLabel == plan.label` ⇒ effectively "no translation", because the
|
|
492
|
+
// public-catalog service falls back to `plan.label` anyway. We then show
|
|
493
|
+
// the field empty (override = nothing) so the DE fallback stays
|
|
494
|
+
// visible.
|
|
495
|
+
const labelOverride =
|
|
496
|
+
projection.displayLabel && projection.displayLabel !== plan.label
|
|
497
|
+
? projection.displayLabel
|
|
498
|
+
: '';
|
|
499
|
+
return {
|
|
500
|
+
displayLabel: labelOverride,
|
|
501
|
+
visible: projection.visible,
|
|
502
|
+
highlight: projection.highlight,
|
|
503
|
+
badge: projection.badge,
|
|
504
|
+
priority: projection.priority,
|
|
505
|
+
description: projection.description,
|
|
506
|
+
trialEnabled: projection.trialEnabled,
|
|
507
|
+
trialDays: projection.trialDays,
|
|
508
|
+
ctaLabel: projection.ctaLabel,
|
|
509
|
+
topFeatures: projection.topFeatures,
|
|
510
|
+
priceTag: projection.priceTag,
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
return {
|
|
514
|
+
displayLabel: '',
|
|
515
|
+
visible: true,
|
|
516
|
+
highlight: false,
|
|
517
|
+
badge: '',
|
|
518
|
+
priority: 0,
|
|
519
|
+
description: plan.description ?? '',
|
|
520
|
+
trialEnabled: false,
|
|
521
|
+
trialDays: 30,
|
|
522
|
+
ctaLabel: null,
|
|
523
|
+
topFeatures: [],
|
|
524
|
+
priceTag: null,
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const rows = computed<MarketingRow[]>(() =>
|
|
529
|
+
[...plansApi.plans.value]
|
|
530
|
+
.sort((a, b) => a.sortOrder - b.sortOrder)
|
|
531
|
+
.map((plan) => {
|
|
532
|
+
// `liveVersion` is now the version selected in the UI
|
|
533
|
+
// (default = today's active, otherwise switched by tab click).
|
|
534
|
+
// Existing bindings (`row.liveVersion` disable states) stay
|
|
535
|
+
// unchanged — a version without pricing does not count as
|
|
536
|
+
// "editable".
|
|
537
|
+
const liveVersion = selectedVersionOf(plan);
|
|
538
|
+
const projection = liveVersion
|
|
539
|
+
? (projectionsApi.projections.value.find(
|
|
540
|
+
(p) => p.targetVersionId === liveVersion.id,
|
|
541
|
+
) ?? null)
|
|
542
|
+
: null;
|
|
543
|
+
return {
|
|
544
|
+
plan,
|
|
545
|
+
accent: props.planAccents?.[plan.planKey] ?? DEFAULT_ACCENT,
|
|
546
|
+
liveVersion,
|
|
547
|
+
publishedVersions: publishedVersionsOf(plan),
|
|
548
|
+
projection,
|
|
549
|
+
m: resolveMarketing(plan, projection),
|
|
550
|
+
};
|
|
551
|
+
}),
|
|
552
|
+
);
|
|
553
|
+
|
|
554
|
+
/** Administration: priority DESC, then plan sortOrder. */
|
|
555
|
+
const adminRows = computed<MarketingRow[]>(() =>
|
|
556
|
+
[...rows.value].sort(
|
|
557
|
+
(a, b) => b.m.priority - a.m.priority || a.plan.sortOrder - b.plan.sortOrder,
|
|
558
|
+
),
|
|
559
|
+
);
|
|
560
|
+
|
|
561
|
+
/** Preview: only visible plans, priority DESC. */
|
|
562
|
+
const visibleRows = computed<MarketingRow[]>(() => adminRows.value.filter((r) => r.m.visible));
|
|
563
|
+
|
|
564
|
+
const catalogVersion = computed<string>(() => {
|
|
565
|
+
const stamps = projectionsApi.projections.value.map((p) => p.updatedAt);
|
|
566
|
+
if (stamps.length === 0) return new Date().toISOString().slice(0, 10);
|
|
567
|
+
return stamps.sort().slice(-1)[0].slice(0, 10);
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
const previewUrl = computed(() => `${props.projectKey}.de/preise`);
|
|
571
|
+
|
|
572
|
+
// ─── Pricing ───
|
|
573
|
+
function monthlyOf(row: MarketingRow): number {
|
|
574
|
+
return row.liveVersion ? Number.parseFloat(row.liveVersion.monthlyNet) || 0 : 0;
|
|
575
|
+
}
|
|
576
|
+
function yearlyOf(row: MarketingRow): number {
|
|
577
|
+
return row.liveVersion ? Number.parseFloat(row.liveVersion.yearlyNet) || 0 : 0;
|
|
578
|
+
}
|
|
579
|
+
function formatEuro(value: number): string {
|
|
580
|
+
const rounded = Math.round(value * 100) / 100;
|
|
581
|
+
const text = Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(2).replace('.', ',');
|
|
582
|
+
return `${text} €`;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// ─── Promo application in the preview (SPEC_V2 §9a) ───
|
|
586
|
+
const promoToday = new Date();
|
|
587
|
+
function promoOf(row: MarketingRow): PromotionRow | null {
|
|
588
|
+
return pickActivePromo(
|
|
589
|
+
promotions.value,
|
|
590
|
+
row.plan.planKey,
|
|
591
|
+
activeLocale.value,
|
|
592
|
+
'monthly',
|
|
593
|
+
promoToday,
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
function promoResultOf(row: MarketingRow): PromotionResult | null {
|
|
597
|
+
const promo = promoOf(row);
|
|
598
|
+
if (!promo || !row.liveVersion) return null;
|
|
599
|
+
return applyPromo(promo, monthlyOf(row));
|
|
600
|
+
}
|
|
601
|
+
function promoBadgeOf(row: MarketingRow): string {
|
|
602
|
+
const promo = promoOf(row);
|
|
603
|
+
if (!promo) return '';
|
|
604
|
+
return promo.i18n?.[activeLocale.value]?.badge || promo.i18n?.de?.badge || 'Aktion';
|
|
605
|
+
}
|
|
606
|
+
function promoFineprintOf(row: MarketingRow): string {
|
|
607
|
+
const promo = promoOf(row);
|
|
608
|
+
if (!promo) return '';
|
|
609
|
+
return promo.i18n?.[activeLocale.value]?.fineprint || promo.i18n?.de?.fineprint || '';
|
|
610
|
+
}
|
|
611
|
+
function promoColorOf(row: MarketingRow): string {
|
|
612
|
+
return promoOf(row)?.color ?? '#10b981';
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// ─── CTA ───
|
|
616
|
+
function autoCtaText(row: MarketingRow): string {
|
|
617
|
+
if (!row.liveVersion) return 'Kontakt aufnehmen';
|
|
618
|
+
if (row.m.trialEnabled) return `Jetzt ${row.m.trialDays} Tage testen`;
|
|
619
|
+
return 'Plan wählen';
|
|
620
|
+
}
|
|
621
|
+
function ctaText(row: MarketingRow): string {
|
|
622
|
+
return row.m.ctaLabel && row.m.ctaLabel.length > 0 ? row.m.ctaLabel : autoCtaText(row);
|
|
623
|
+
}
|
|
624
|
+
function showTrialNote(row: MarketingRow): boolean {
|
|
625
|
+
return Boolean(row.liveVersion) && row.m.trialEnabled && !row.m.ctaLabel;
|
|
626
|
+
}
|
|
627
|
+
/** Persist an empty CTA text as `null` (= auto text). */
|
|
628
|
+
function ctaValue(raw: string): string | null {
|
|
629
|
+
const trimmed = raw.trim();
|
|
630
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// ─── Top-feature suggestions from the plan components ───
|
|
634
|
+
function suggestionsFor(row: MarketingRow): FeatureSuggestion[] {
|
|
635
|
+
if (!row.liveVersion) return [];
|
|
636
|
+
const usedKeys = new Set(editFeatures.value.map((f) => f.key).filter((k): k is string => !!k));
|
|
637
|
+
const fromFeatures: FeatureSuggestion[] = row.liveVersion.features.map((key) => ({
|
|
638
|
+
key,
|
|
639
|
+
label: resolveComponentLabel(key),
|
|
640
|
+
strong: '',
|
|
641
|
+
}));
|
|
642
|
+
const quotas = row.liveVersion.quotas ?? {};
|
|
643
|
+
const fromQuotas: FeatureSuggestion[] = Object.entries(quotas).map(([key, value]) => {
|
|
644
|
+
const unit = resolveQuotaUnit(key);
|
|
645
|
+
return {
|
|
646
|
+
key,
|
|
647
|
+
label: resolveComponentLabel(key),
|
|
648
|
+
strong: `${value}${unit ? ` ${unit}` : ''}`,
|
|
649
|
+
};
|
|
650
|
+
});
|
|
651
|
+
// Offer all components of the version (no cap of 6) — only hide keys
|
|
652
|
+
// that are already used.
|
|
653
|
+
return [...fromFeatures, ...fromQuotas].filter((s) => !usedKeys.has(s.key));
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// ─── Persistence ───
|
|
657
|
+
function fallbackText(row: MarketingRow): string {
|
|
658
|
+
return row.plan.description ?? row.plan.label;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* Persists a partial against the MarketingProjection of the live version.
|
|
663
|
+
* If the projection does not exist yet, it is created from the resolved
|
|
664
|
+
* values + partial. Plans without a live version are not marketable.
|
|
665
|
+
*/
|
|
666
|
+
/**
|
|
667
|
+
* Sets the plan name in the current locale.
|
|
668
|
+
* Empty input = explicit "no translation" → persists `plan.label`,
|
|
669
|
+
* the public-catalog service then renders the DE name anyway.
|
|
670
|
+
*/
|
|
671
|
+
async function patchDisplayLabel(row: MarketingRow, value: string): Promise<void> {
|
|
672
|
+
const trimmed = value.trim();
|
|
673
|
+
const displayLabel = trimmed || row.plan.label;
|
|
674
|
+
await patch(row, { displayLabel });
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
async function patch(row: MarketingRow, partial: Partial<ResolvedMarketing>): Promise<void> {
|
|
678
|
+
if (!row.liveVersion) return;
|
|
679
|
+
busy.value = true;
|
|
680
|
+
pageError.value = null;
|
|
681
|
+
try {
|
|
682
|
+
if (row.projection) {
|
|
683
|
+
await projectionsApi.update(row.projection.id, partial);
|
|
684
|
+
} else {
|
|
685
|
+
const merged: ResolvedMarketing = { ...row.m, ...partial };
|
|
686
|
+
await projectionsApi.create({
|
|
687
|
+
projectKey: props.projectKey,
|
|
688
|
+
targetType: 'PLAN',
|
|
689
|
+
targetVersionId: row.liveVersion.id,
|
|
690
|
+
locale: activeLocale.value,
|
|
691
|
+
displayLabel: merged.displayLabel.trim() || row.plan.label,
|
|
692
|
+
description: merged.description.trim() || fallbackText(row),
|
|
693
|
+
visible: merged.visible,
|
|
694
|
+
badge: merged.badge,
|
|
695
|
+
topFeatures: merged.topFeatures,
|
|
696
|
+
trialEnabled: merged.trialEnabled,
|
|
697
|
+
trialDays: merged.trialDays,
|
|
698
|
+
ctaLabel: merged.ctaLabel,
|
|
699
|
+
priority: merged.priority,
|
|
700
|
+
highlight: merged.highlight,
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
} catch (err) {
|
|
704
|
+
pageError.value = err instanceof Error ? err.message : String(err);
|
|
705
|
+
await projectionsApi.load();
|
|
706
|
+
} finally {
|
|
707
|
+
busy.value = false;
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// ─── Expand / top-feature editor ───
|
|
712
|
+
function toggleExpand(row: MarketingRow): void {
|
|
713
|
+
if (expandedKey.value === row.plan.planKey) {
|
|
714
|
+
expandedKey.value = null;
|
|
715
|
+
editFeatures.value = [];
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
expandedKey.value = row.plan.planKey;
|
|
719
|
+
// Migration: legacy entries whose `label` is a known feature/quota
|
|
720
|
+
// key become key-referenced (auto-translated) entries.
|
|
721
|
+
editFeatures.value = row.m.topFeatures.map((f) => {
|
|
722
|
+
if (!f.key && f.label && knownComponentKeys.value.has(f.label)) {
|
|
723
|
+
return { key: f.label, label: '', strong: f.strong };
|
|
724
|
+
}
|
|
725
|
+
return { ...f };
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* Persists the local editing copy. Entries without `key` AND without `label`
|
|
731
|
+
* are dropped; key-referenced entries remain (label is resolved).
|
|
732
|
+
*/
|
|
733
|
+
async function persistFeatures(row: MarketingRow): Promise<void> {
|
|
734
|
+
const cleaned: MarketingTopFeature[] = editFeatures.value
|
|
735
|
+
.filter((f) => (f.key && f.key.trim().length > 0) || f.label.trim().length > 0)
|
|
736
|
+
.map((f) => ({
|
|
737
|
+
...(f.key && f.key.trim() ? { key: f.key.trim() } : {}),
|
|
738
|
+
label: f.label.trim(),
|
|
739
|
+
strong: f.strong.trim(),
|
|
740
|
+
}));
|
|
741
|
+
await patch(row, { topFeatures: cleaned });
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function addFeature(row: MarketingRow): void {
|
|
745
|
+
void row;
|
|
746
|
+
editFeatures.value.push({ label: '', strong: '' });
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function updateFeatureLabel(index: number, value: string): void {
|
|
750
|
+
const item = editFeatures.value[index];
|
|
751
|
+
if (item) item.label = value;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
function updateFeatureStrong(index: number, value: string): void {
|
|
755
|
+
const item = editFeatures.value[index];
|
|
756
|
+
if (item) item.strong = value;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
async function addSuggestion(row: MarketingRow, s: FeatureSuggestion): Promise<void> {
|
|
760
|
+
// `label` empty → the label is resolved from `key` depending on the locale.
|
|
761
|
+
editFeatures.value.push({ key: s.key, label: '', strong: s.strong });
|
|
762
|
+
await persistFeatures(row);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
async function removeFeature(row: MarketingRow, idx: number): Promise<void> {
|
|
766
|
+
editFeatures.value.splice(idx, 1);
|
|
767
|
+
await persistFeatures(row);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
async function moveFeature(row: MarketingRow, idx: number, dir: -1 | 1): Promise<void> {
|
|
771
|
+
const j = idx + dir;
|
|
772
|
+
if (j < 0 || j >= editFeatures.value.length) return;
|
|
773
|
+
const list = editFeatures.value;
|
|
774
|
+
[list[idx], list[j]] = [list[j], list[idx]];
|
|
775
|
+
await persistFeatures(row);
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
async function onLocaleChange(loc: string): Promise<void> {
|
|
779
|
+
if (loc === activeLocale.value) return;
|
|
780
|
+
activeLocale.value = loc;
|
|
781
|
+
expandedKey.value = null;
|
|
782
|
+
editFeatures.value = [];
|
|
783
|
+
busy.value = true;
|
|
784
|
+
try {
|
|
785
|
+
await projectionsApi.setFilter({
|
|
786
|
+
projectKey: props.projectKey,
|
|
787
|
+
targetType: 'PLAN',
|
|
788
|
+
locale: loc,
|
|
789
|
+
});
|
|
790
|
+
} catch (err) {
|
|
791
|
+
pageError.value = err instanceof Error ? err.message : String(err);
|
|
792
|
+
} finally {
|
|
793
|
+
busy.value = false;
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
</script>
|
|
797
|
+
|
|
798
|
+
<style>
|
|
799
|
+
.mc {
|
|
800
|
+
--mc-bg: #f6f7f9;
|
|
801
|
+
--mc-surface: #ffffff;
|
|
802
|
+
--mc-surface-2: #f8fafc;
|
|
803
|
+
--mc-border: #e5e7eb;
|
|
804
|
+
--mc-border-strong: #d1d5db;
|
|
805
|
+
--mc-text: #0f172a;
|
|
806
|
+
--mc-text-2: #475569;
|
|
807
|
+
--mc-text-3: #94a3b8;
|
|
808
|
+
--mc-primary: #2563eb;
|
|
809
|
+
--mc-primary-700: #1d4ed8;
|
|
810
|
+
--mc-primary-50: #eff6ff;
|
|
811
|
+
--mc-font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
|
812
|
+
--mc-font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, monospace;
|
|
813
|
+
|
|
814
|
+
padding: 22px 26px;
|
|
815
|
+
background: var(--mc-bg);
|
|
816
|
+
color: var(--mc-text);
|
|
817
|
+
font-family: var(--mc-font-sans);
|
|
818
|
+
min-height: 100%;
|
|
819
|
+
box-sizing: border-box;
|
|
820
|
+
}
|
|
821
|
+
.mc * {
|
|
822
|
+
box-sizing: border-box;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
.mc-page-head {
|
|
826
|
+
display: flex;
|
|
827
|
+
align-items: flex-end;
|
|
828
|
+
justify-content: space-between;
|
|
829
|
+
gap: 20px;
|
|
830
|
+
margin-bottom: 14px;
|
|
831
|
+
}
|
|
832
|
+
.mc-h-title {
|
|
833
|
+
font-size: 22px;
|
|
834
|
+
font-weight: 700;
|
|
835
|
+
letter-spacing: -0.02em;
|
|
836
|
+
margin: 0;
|
|
837
|
+
}
|
|
838
|
+
.mc-h-sub {
|
|
839
|
+
font-size: 12.5px;
|
|
840
|
+
color: var(--mc-text-2);
|
|
841
|
+
margin: 3px 0 0;
|
|
842
|
+
}
|
|
843
|
+
.mc-head-actions {
|
|
844
|
+
display: flex;
|
|
845
|
+
align-items: center;
|
|
846
|
+
gap: 10px;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
.mc-locale-switch {
|
|
850
|
+
display: inline-flex;
|
|
851
|
+
gap: 2px;
|
|
852
|
+
background: var(--mc-surface);
|
|
853
|
+
border: 1px solid var(--mc-border);
|
|
854
|
+
border-radius: 8px;
|
|
855
|
+
padding: 3px;
|
|
856
|
+
}
|
|
857
|
+
.mc-locale-btn {
|
|
858
|
+
padding: 5px 10px;
|
|
859
|
+
border: 0;
|
|
860
|
+
background: transparent;
|
|
861
|
+
border-radius: 6px;
|
|
862
|
+
font: 500 12px var(--mc-font-mono);
|
|
863
|
+
color: var(--mc-text-2);
|
|
864
|
+
cursor: pointer;
|
|
865
|
+
}
|
|
866
|
+
.mc-locale-btn.active {
|
|
867
|
+
background: var(--mc-primary-50);
|
|
868
|
+
color: var(--mc-primary-700);
|
|
869
|
+
font-weight: 600;
|
|
870
|
+
}
|
|
871
|
+
.mc-locale-mgr {
|
|
872
|
+
display: inline-flex;
|
|
873
|
+
align-items: center;
|
|
874
|
+
gap: 6px;
|
|
875
|
+
flex-wrap: wrap;
|
|
876
|
+
}
|
|
877
|
+
.mc-locale-mgr-label {
|
|
878
|
+
font-size: 11px;
|
|
879
|
+
color: var(--mc-text-3);
|
|
880
|
+
}
|
|
881
|
+
.mc-locale-pill {
|
|
882
|
+
display: inline-flex;
|
|
883
|
+
align-items: center;
|
|
884
|
+
border: 1px solid var(--mc-border);
|
|
885
|
+
border-radius: 7px;
|
|
886
|
+
overflow: hidden;
|
|
887
|
+
}
|
|
888
|
+
.mc-locale-pill.active {
|
|
889
|
+
border-color: var(--mc-primary-700);
|
|
890
|
+
background: var(--mc-primary-50);
|
|
891
|
+
}
|
|
892
|
+
.mc-locale-pill-btn {
|
|
893
|
+
border: 0;
|
|
894
|
+
background: transparent;
|
|
895
|
+
padding: 4px 8px;
|
|
896
|
+
font: 600 11px var(--mc-font-mono);
|
|
897
|
+
color: var(--mc-text-2);
|
|
898
|
+
cursor: pointer;
|
|
899
|
+
}
|
|
900
|
+
.mc-locale-pill.active .mc-locale-pill-btn {
|
|
901
|
+
color: var(--mc-primary-700);
|
|
902
|
+
}
|
|
903
|
+
.mc-locale-x {
|
|
904
|
+
border: 0;
|
|
905
|
+
background: transparent;
|
|
906
|
+
padding: 4px 7px;
|
|
907
|
+
color: var(--mc-text-3);
|
|
908
|
+
cursor: pointer;
|
|
909
|
+
font-size: 13px;
|
|
910
|
+
}
|
|
911
|
+
.mc-locale-x:hover {
|
|
912
|
+
color: #dc2626;
|
|
913
|
+
}
|
|
914
|
+
.mc-locale-add-wrap {
|
|
915
|
+
position: relative;
|
|
916
|
+
}
|
|
917
|
+
.mc-locale-add {
|
|
918
|
+
border: 1px dashed var(--mc-border);
|
|
919
|
+
background: transparent;
|
|
920
|
+
border-radius: 7px;
|
|
921
|
+
padding: 4px 9px;
|
|
922
|
+
font-size: 11px;
|
|
923
|
+
color: var(--mc-text-2);
|
|
924
|
+
cursor: pointer;
|
|
925
|
+
}
|
|
926
|
+
.mc-locale-add:disabled {
|
|
927
|
+
opacity: 0.4;
|
|
928
|
+
cursor: default;
|
|
929
|
+
}
|
|
930
|
+
.mc-locale-picker {
|
|
931
|
+
position: absolute;
|
|
932
|
+
top: 100%;
|
|
933
|
+
right: 0;
|
|
934
|
+
margin-top: 4px;
|
|
935
|
+
background: var(--mc-surface);
|
|
936
|
+
border: 1px solid var(--mc-border);
|
|
937
|
+
border-radius: 8px;
|
|
938
|
+
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
|
939
|
+
padding: 4px;
|
|
940
|
+
display: flex;
|
|
941
|
+
flex-direction: column;
|
|
942
|
+
gap: 2px;
|
|
943
|
+
z-index: 20;
|
|
944
|
+
min-width: 80px;
|
|
945
|
+
}
|
|
946
|
+
.mc-locale-picker-row {
|
|
947
|
+
border: 0;
|
|
948
|
+
background: transparent;
|
|
949
|
+
padding: 6px 10px;
|
|
950
|
+
text-align: left;
|
|
951
|
+
border-radius: 6px;
|
|
952
|
+
font: 600 11px var(--mc-font-mono);
|
|
953
|
+
cursor: pointer;
|
|
954
|
+
}
|
|
955
|
+
.mc-locale-picker-row:hover {
|
|
956
|
+
background: var(--mc-primary-50);
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
.mc-btn {
|
|
960
|
+
display: inline-flex;
|
|
961
|
+
align-items: center;
|
|
962
|
+
gap: 7px;
|
|
963
|
+
padding: 8px 14px;
|
|
964
|
+
border-radius: 7px;
|
|
965
|
+
font: 500 13px var(--mc-font-sans);
|
|
966
|
+
cursor: pointer;
|
|
967
|
+
border: 1px solid var(--mc-border-strong);
|
|
968
|
+
background: var(--mc-surface);
|
|
969
|
+
color: var(--mc-text);
|
|
970
|
+
transition:
|
|
971
|
+
background 0.12s,
|
|
972
|
+
border-color 0.12s;
|
|
973
|
+
}
|
|
974
|
+
.mc-btn:hover:not(:disabled) {
|
|
975
|
+
background: #f8fafc;
|
|
976
|
+
}
|
|
977
|
+
.mc-btn:disabled {
|
|
978
|
+
opacity: 0.5;
|
|
979
|
+
cursor: not-allowed;
|
|
980
|
+
}
|
|
981
|
+
.mc-btn--sm {
|
|
982
|
+
padding: 5px 9px;
|
|
983
|
+
font-size: 12px;
|
|
984
|
+
gap: 5px;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
.mc-banner {
|
|
988
|
+
border-radius: 8px;
|
|
989
|
+
padding: 10px 14px;
|
|
990
|
+
font-size: 12.5px;
|
|
991
|
+
margin-bottom: 14px;
|
|
992
|
+
display: flex;
|
|
993
|
+
align-items: center;
|
|
994
|
+
gap: 8px;
|
|
995
|
+
}
|
|
996
|
+
.mc-banner--error {
|
|
997
|
+
background: #fef2f2;
|
|
998
|
+
color: #b91c1c;
|
|
999
|
+
border: 1px solid #fca5a5;
|
|
1000
|
+
}
|
|
1001
|
+
.mc-banner--info {
|
|
1002
|
+
background: var(--mc-primary-50);
|
|
1003
|
+
color: #1e40af;
|
|
1004
|
+
border: 1px solid #bfdbfe;
|
|
1005
|
+
}
|
|
1006
|
+
.mc-banner-x {
|
|
1007
|
+
margin-left: auto;
|
|
1008
|
+
background: transparent;
|
|
1009
|
+
border: 0;
|
|
1010
|
+
cursor: pointer;
|
|
1011
|
+
font-size: 18px;
|
|
1012
|
+
line-height: 1;
|
|
1013
|
+
color: inherit;
|
|
1014
|
+
}
|
|
1015
|
+
.mc-loading {
|
|
1016
|
+
padding: 40px;
|
|
1017
|
+
text-align: center;
|
|
1018
|
+
color: var(--mc-text-3);
|
|
1019
|
+
font-size: 13px;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
.mc-toolbar {
|
|
1023
|
+
display: flex;
|
|
1024
|
+
gap: 12px;
|
|
1025
|
+
align-items: center;
|
|
1026
|
+
margin: 4px 0 16px;
|
|
1027
|
+
}
|
|
1028
|
+
.mc-tabbar {
|
|
1029
|
+
display: inline-flex;
|
|
1030
|
+
gap: 2px;
|
|
1031
|
+
background: var(--mc-surface);
|
|
1032
|
+
border: 1px solid var(--mc-border);
|
|
1033
|
+
border-radius: 8px;
|
|
1034
|
+
padding: 3px;
|
|
1035
|
+
}
|
|
1036
|
+
.mc-tab {
|
|
1037
|
+
padding: 6px 14px;
|
|
1038
|
+
border-radius: 6px;
|
|
1039
|
+
font: 500 13px var(--mc-font-sans);
|
|
1040
|
+
color: var(--mc-text-2);
|
|
1041
|
+
background: transparent;
|
|
1042
|
+
border: 0;
|
|
1043
|
+
cursor: pointer;
|
|
1044
|
+
transition:
|
|
1045
|
+
background 0.12s,
|
|
1046
|
+
color 0.12s;
|
|
1047
|
+
}
|
|
1048
|
+
.mc-tab:hover {
|
|
1049
|
+
color: var(--mc-text);
|
|
1050
|
+
}
|
|
1051
|
+
.mc-tab.active {
|
|
1052
|
+
background: var(--mc-primary-50);
|
|
1053
|
+
color: var(--mc-primary-700);
|
|
1054
|
+
font-weight: 600;
|
|
1055
|
+
}
|
|
1056
|
+
.mc-tab-count {
|
|
1057
|
+
display: inline-block;
|
|
1058
|
+
margin-left: 5px;
|
|
1059
|
+
background: #10b981;
|
|
1060
|
+
color: #fff;
|
|
1061
|
+
font-size: 10px;
|
|
1062
|
+
font-weight: 700;
|
|
1063
|
+
padding: 1px 6px;
|
|
1064
|
+
border-radius: 999px;
|
|
1065
|
+
}
|
|
1066
|
+
.mc-meta {
|
|
1067
|
+
margin-left: auto;
|
|
1068
|
+
font-size: 12px;
|
|
1069
|
+
color: var(--mc-text-2);
|
|
1070
|
+
display: flex;
|
|
1071
|
+
align-items: center;
|
|
1072
|
+
gap: 8px;
|
|
1073
|
+
}
|
|
1074
|
+
.mc-meta code {
|
|
1075
|
+
font: 500 11px var(--mc-font-mono);
|
|
1076
|
+
background: #f1f5f9;
|
|
1077
|
+
color: #475569;
|
|
1078
|
+
padding: 2px 7px;
|
|
1079
|
+
border-radius: 4px;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
/* ── Public catalog preview ── */
|
|
1083
|
+
.mc-window {
|
|
1084
|
+
background: var(--mc-surface);
|
|
1085
|
+
border: 1px solid var(--mc-border);
|
|
1086
|
+
border-radius: 12px;
|
|
1087
|
+
overflow: hidden;
|
|
1088
|
+
}
|
|
1089
|
+
.mc-chrome {
|
|
1090
|
+
height: 36px;
|
|
1091
|
+
background: #f1f5f9;
|
|
1092
|
+
border-bottom: 1px solid var(--mc-border);
|
|
1093
|
+
display: flex;
|
|
1094
|
+
align-items: center;
|
|
1095
|
+
padding: 0 14px;
|
|
1096
|
+
gap: 6px;
|
|
1097
|
+
}
|
|
1098
|
+
.mc-chrome-dot {
|
|
1099
|
+
width: 10px;
|
|
1100
|
+
height: 10px;
|
|
1101
|
+
border-radius: 50%;
|
|
1102
|
+
}
|
|
1103
|
+
.mc-chrome-url {
|
|
1104
|
+
margin-left: 18px;
|
|
1105
|
+
flex: 1;
|
|
1106
|
+
background: var(--mc-surface);
|
|
1107
|
+
border: 1px solid #e2e8f0;
|
|
1108
|
+
border-radius: 6px;
|
|
1109
|
+
padding: 4px 10px;
|
|
1110
|
+
font: 500 11.5px var(--mc-font-mono);
|
|
1111
|
+
color: #475569;
|
|
1112
|
+
max-width: 380px;
|
|
1113
|
+
}
|
|
1114
|
+
.mc-canvas {
|
|
1115
|
+
background: linear-gradient(180deg, #fbfbfd 0%, #fff 100%);
|
|
1116
|
+
padding: 36px 32px 28px;
|
|
1117
|
+
}
|
|
1118
|
+
.mc-eyebrow {
|
|
1119
|
+
font: 700 11px var(--mc-font-sans);
|
|
1120
|
+
letter-spacing: 0.14em;
|
|
1121
|
+
text-transform: uppercase;
|
|
1122
|
+
color: var(--mc-primary);
|
|
1123
|
+
text-align: center;
|
|
1124
|
+
}
|
|
1125
|
+
.mc-hero {
|
|
1126
|
+
font-size: 32px;
|
|
1127
|
+
font-weight: 700;
|
|
1128
|
+
letter-spacing: -0.025em;
|
|
1129
|
+
color: var(--mc-text);
|
|
1130
|
+
text-align: center;
|
|
1131
|
+
margin: 10px 0 6px;
|
|
1132
|
+
}
|
|
1133
|
+
.mc-sub {
|
|
1134
|
+
font-size: 14px;
|
|
1135
|
+
color: var(--mc-text-2);
|
|
1136
|
+
text-align: center;
|
|
1137
|
+
max-width: 540px;
|
|
1138
|
+
margin: 0 auto 26px;
|
|
1139
|
+
}
|
|
1140
|
+
.mc-grid {
|
|
1141
|
+
display: grid;
|
|
1142
|
+
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
|
1143
|
+
gap: 16px;
|
|
1144
|
+
}
|
|
1145
|
+
.mc-card {
|
|
1146
|
+
background: var(--mc-surface);
|
|
1147
|
+
border: 1px solid var(--mc-border);
|
|
1148
|
+
border-radius: 12px;
|
|
1149
|
+
padding: 22px 20px 20px;
|
|
1150
|
+
display: flex;
|
|
1151
|
+
flex-direction: column;
|
|
1152
|
+
position: relative;
|
|
1153
|
+
transition:
|
|
1154
|
+
transform 0.15s,
|
|
1155
|
+
box-shadow 0.15s,
|
|
1156
|
+
border-color 0.15s;
|
|
1157
|
+
}
|
|
1158
|
+
.mc-card:hover {
|
|
1159
|
+
transform: translateY(-2px);
|
|
1160
|
+
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
|
|
1161
|
+
}
|
|
1162
|
+
.mc-card.featured {
|
|
1163
|
+
border-color: #bfdbfe;
|
|
1164
|
+
box-shadow: 0 10px 32px rgba(37, 99, 235, 0.1);
|
|
1165
|
+
}
|
|
1166
|
+
.mc-card-badge {
|
|
1167
|
+
position: absolute;
|
|
1168
|
+
top: -10px;
|
|
1169
|
+
left: 50%;
|
|
1170
|
+
transform: translateX(-50%);
|
|
1171
|
+
background: var(--mc-primary);
|
|
1172
|
+
color: #fff;
|
|
1173
|
+
font: 700 10.5px var(--mc-font-sans);
|
|
1174
|
+
letter-spacing: 0.1em;
|
|
1175
|
+
text-transform: uppercase;
|
|
1176
|
+
padding: 4px 12px;
|
|
1177
|
+
border-radius: 999px;
|
|
1178
|
+
white-space: nowrap;
|
|
1179
|
+
}
|
|
1180
|
+
.mc-card-key {
|
|
1181
|
+
font: 700 10.5px var(--mc-font-mono);
|
|
1182
|
+
letter-spacing: 0.08em;
|
|
1183
|
+
color: var(--mc-text-3);
|
|
1184
|
+
text-transform: uppercase;
|
|
1185
|
+
}
|
|
1186
|
+
.mc-card-name {
|
|
1187
|
+
font-size: 22px;
|
|
1188
|
+
font-weight: 700;
|
|
1189
|
+
letter-spacing: -0.02em;
|
|
1190
|
+
color: var(--mc-text);
|
|
1191
|
+
margin: 2px 0 4px;
|
|
1192
|
+
}
|
|
1193
|
+
.mc-card-desc {
|
|
1194
|
+
font-size: 12.5px;
|
|
1195
|
+
color: var(--mc-text-2);
|
|
1196
|
+
line-height: 1.4;
|
|
1197
|
+
min-height: 36px;
|
|
1198
|
+
}
|
|
1199
|
+
.mc-card-price {
|
|
1200
|
+
margin: 18px 0 4px;
|
|
1201
|
+
display: flex;
|
|
1202
|
+
align-items: baseline;
|
|
1203
|
+
gap: 6px;
|
|
1204
|
+
}
|
|
1205
|
+
.mc-card-price-big {
|
|
1206
|
+
font-size: 32px;
|
|
1207
|
+
font-weight: 700;
|
|
1208
|
+
letter-spacing: -0.03em;
|
|
1209
|
+
color: var(--mc-text);
|
|
1210
|
+
}
|
|
1211
|
+
.mc-card-price-unit {
|
|
1212
|
+
font-size: 13px;
|
|
1213
|
+
color: var(--mc-text-3);
|
|
1214
|
+
}
|
|
1215
|
+
.mc-card-price-y {
|
|
1216
|
+
font-size: 11.5px;
|
|
1217
|
+
color: var(--mc-text-3);
|
|
1218
|
+
margin-bottom: 12px;
|
|
1219
|
+
}
|
|
1220
|
+
.mc-card-cta {
|
|
1221
|
+
width: 100%;
|
|
1222
|
+
margin-top: 14px;
|
|
1223
|
+
padding: 9px 12px;
|
|
1224
|
+
border-radius: 8px;
|
|
1225
|
+
border: 1px solid var(--mc-border);
|
|
1226
|
+
background: var(--mc-surface);
|
|
1227
|
+
color: var(--mc-text);
|
|
1228
|
+
font: 600 13px var(--mc-font-sans);
|
|
1229
|
+
cursor: pointer;
|
|
1230
|
+
transition:
|
|
1231
|
+
background 0.12s,
|
|
1232
|
+
border-color 0.12s;
|
|
1233
|
+
}
|
|
1234
|
+
.mc-card-cta:hover {
|
|
1235
|
+
background: #f8fafc;
|
|
1236
|
+
}
|
|
1237
|
+
.mc-card.featured .mc-card-cta {
|
|
1238
|
+
background: var(--mc-primary);
|
|
1239
|
+
border-color: var(--mc-primary);
|
|
1240
|
+
color: #fff;
|
|
1241
|
+
}
|
|
1242
|
+
.mc-card.featured .mc-card-cta:hover {
|
|
1243
|
+
background: var(--mc-primary-700);
|
|
1244
|
+
}
|
|
1245
|
+
.mc-card-trialnote {
|
|
1246
|
+
font-size: 11px;
|
|
1247
|
+
color: var(--mc-text-3);
|
|
1248
|
+
text-align: center;
|
|
1249
|
+
margin-top: 6px;
|
|
1250
|
+
}
|
|
1251
|
+
.mc-card.has-promo {
|
|
1252
|
+
border-color: #10b981;
|
|
1253
|
+
}
|
|
1254
|
+
.mc-promo-ribbon {
|
|
1255
|
+
position: absolute;
|
|
1256
|
+
top: 12px;
|
|
1257
|
+
right: -2px;
|
|
1258
|
+
color: #fff;
|
|
1259
|
+
font-size: 11px;
|
|
1260
|
+
font-weight: 700;
|
|
1261
|
+
padding: 3px 12px 3px 10px;
|
|
1262
|
+
border-radius: 4px 0 0 4px;
|
|
1263
|
+
}
|
|
1264
|
+
.mc-card-price-strike {
|
|
1265
|
+
display: flex;
|
|
1266
|
+
gap: 8px;
|
|
1267
|
+
align-items: baseline;
|
|
1268
|
+
margin-top: 2px;
|
|
1269
|
+
}
|
|
1270
|
+
.mc-card-price-strike s {
|
|
1271
|
+
color: var(--mc-text-3);
|
|
1272
|
+
font-size: 14px;
|
|
1273
|
+
}
|
|
1274
|
+
.mc-price-regular {
|
|
1275
|
+
font-size: 10px;
|
|
1276
|
+
text-transform: uppercase;
|
|
1277
|
+
letter-spacing: 0.5px;
|
|
1278
|
+
color: var(--mc-text-3);
|
|
1279
|
+
}
|
|
1280
|
+
.mc-card-fineprint {
|
|
1281
|
+
font-size: 10px;
|
|
1282
|
+
color: #059669;
|
|
1283
|
+
text-align: center;
|
|
1284
|
+
margin-top: 6px;
|
|
1285
|
+
}
|
|
1286
|
+
.mc-card-includes {
|
|
1287
|
+
margin-top: 18px;
|
|
1288
|
+
font-size: 11px;
|
|
1289
|
+
text-transform: uppercase;
|
|
1290
|
+
letter-spacing: 0.08em;
|
|
1291
|
+
color: var(--mc-text-3);
|
|
1292
|
+
font-weight: 700;
|
|
1293
|
+
}
|
|
1294
|
+
.mc-card-features {
|
|
1295
|
+
list-style: none;
|
|
1296
|
+
padding: 0;
|
|
1297
|
+
margin: 8px 0 0;
|
|
1298
|
+
display: flex;
|
|
1299
|
+
flex-direction: column;
|
|
1300
|
+
gap: 7px;
|
|
1301
|
+
font-size: 12.5px;
|
|
1302
|
+
color: #334155;
|
|
1303
|
+
}
|
|
1304
|
+
.mc-card-features li {
|
|
1305
|
+
display: flex;
|
|
1306
|
+
align-items: flex-start;
|
|
1307
|
+
gap: 8px;
|
|
1308
|
+
line-height: 1.35;
|
|
1309
|
+
}
|
|
1310
|
+
.mc-card-features .mc-tick {
|
|
1311
|
+
color: #10b981;
|
|
1312
|
+
flex: 0 0 14px;
|
|
1313
|
+
margin-top: 1px;
|
|
1314
|
+
}
|
|
1315
|
+
.mc-card-features b {
|
|
1316
|
+
color: var(--mc-text);
|
|
1317
|
+
font-weight: 700;
|
|
1318
|
+
}
|
|
1319
|
+
.mc-card-features-empty {
|
|
1320
|
+
margin-top: 8px;
|
|
1321
|
+
font-size: 12px;
|
|
1322
|
+
color: var(--mc-text-3);
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
/* ── Marketing administration ── */
|
|
1326
|
+
.mc-admin {
|
|
1327
|
+
background: var(--mc-surface);
|
|
1328
|
+
border: 1px solid var(--mc-border);
|
|
1329
|
+
border-radius: 10px;
|
|
1330
|
+
}
|
|
1331
|
+
.mc-admin-head {
|
|
1332
|
+
padding: 14px 16px;
|
|
1333
|
+
border-bottom: 1px solid #f1f5f9;
|
|
1334
|
+
display: flex;
|
|
1335
|
+
align-items: center;
|
|
1336
|
+
gap: 12px;
|
|
1337
|
+
}
|
|
1338
|
+
.mc-admin-title {
|
|
1339
|
+
font-size: 14px;
|
|
1340
|
+
font-weight: 700;
|
|
1341
|
+
letter-spacing: -0.01em;
|
|
1342
|
+
color: var(--mc-text);
|
|
1343
|
+
}
|
|
1344
|
+
.mc-admin-sub {
|
|
1345
|
+
font-size: 11.5px;
|
|
1346
|
+
color: var(--mc-text-2);
|
|
1347
|
+
}
|
|
1348
|
+
.mc-admin-grid {
|
|
1349
|
+
display: grid;
|
|
1350
|
+
grid-template-columns: 1.6fr 1fr 1.4fr 1fr 1fr 150px;
|
|
1351
|
+
align-items: stretch;
|
|
1352
|
+
}
|
|
1353
|
+
.mc-admin-thead {
|
|
1354
|
+
display: contents;
|
|
1355
|
+
}
|
|
1356
|
+
.mc-admin-thead > div {
|
|
1357
|
+
background: #fbfbfd;
|
|
1358
|
+
padding: 10px 14px;
|
|
1359
|
+
font-size: 10.5px;
|
|
1360
|
+
text-transform: uppercase;
|
|
1361
|
+
letter-spacing: 0.1em;
|
|
1362
|
+
color: var(--mc-text-2);
|
|
1363
|
+
font-weight: 700;
|
|
1364
|
+
border-bottom: 1px solid var(--mc-border);
|
|
1365
|
+
}
|
|
1366
|
+
.mc-admin-row {
|
|
1367
|
+
display: contents;
|
|
1368
|
+
}
|
|
1369
|
+
.mc-admin-row > div {
|
|
1370
|
+
padding: 12px 14px;
|
|
1371
|
+
border-bottom: 1px solid #f1f5f9;
|
|
1372
|
+
display: flex;
|
|
1373
|
+
align-items: center;
|
|
1374
|
+
font-size: 12.5px;
|
|
1375
|
+
color: var(--mc-text);
|
|
1376
|
+
}
|
|
1377
|
+
.mc-admin-row--disabled > div {
|
|
1378
|
+
background: #fcfcfd;
|
|
1379
|
+
}
|
|
1380
|
+
.mc-admin-row-end {
|
|
1381
|
+
justify-content: flex-end;
|
|
1382
|
+
gap: 8px;
|
|
1383
|
+
}
|
|
1384
|
+
.mc-plan-cell {
|
|
1385
|
+
display: flex;
|
|
1386
|
+
align-items: center;
|
|
1387
|
+
gap: 12px;
|
|
1388
|
+
}
|
|
1389
|
+
.mc-plan-mark {
|
|
1390
|
+
width: 32px;
|
|
1391
|
+
height: 32px;
|
|
1392
|
+
border-radius: 8px;
|
|
1393
|
+
display: grid;
|
|
1394
|
+
place-items: center;
|
|
1395
|
+
font: 700 10px var(--mc-font-mono);
|
|
1396
|
+
border: 1px solid;
|
|
1397
|
+
}
|
|
1398
|
+
.mc-plan-label {
|
|
1399
|
+
font-size: 13px;
|
|
1400
|
+
font-weight: 700;
|
|
1401
|
+
color: var(--mc-text);
|
|
1402
|
+
}
|
|
1403
|
+
.mc-plan-key {
|
|
1404
|
+
font: 500 11px var(--mc-font-mono);
|
|
1405
|
+
color: var(--mc-text-3);
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
.mc-field {
|
|
1409
|
+
width: 100%;
|
|
1410
|
+
padding: 5px 8px;
|
|
1411
|
+
font: 400 12px var(--mc-font-sans);
|
|
1412
|
+
color: var(--mc-text);
|
|
1413
|
+
background: var(--mc-surface);
|
|
1414
|
+
border: 1px solid var(--mc-border-strong);
|
|
1415
|
+
border-radius: 6px;
|
|
1416
|
+
}
|
|
1417
|
+
.mc-field:focus {
|
|
1418
|
+
outline: none;
|
|
1419
|
+
border-color: var(--mc-primary);
|
|
1420
|
+
box-shadow: 0 0 0 3px var(--mc-primary-50);
|
|
1421
|
+
}
|
|
1422
|
+
.mc-field:disabled {
|
|
1423
|
+
background: #f1f5f9;
|
|
1424
|
+
color: var(--mc-text-3);
|
|
1425
|
+
cursor: not-allowed;
|
|
1426
|
+
}
|
|
1427
|
+
.mc-field--area {
|
|
1428
|
+
resize: vertical;
|
|
1429
|
+
font-size: 12.5px;
|
|
1430
|
+
line-height: 1.4;
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
.mc-toggle {
|
|
1434
|
+
position: relative;
|
|
1435
|
+
display: inline-block;
|
|
1436
|
+
width: 36px;
|
|
1437
|
+
height: 20px;
|
|
1438
|
+
}
|
|
1439
|
+
.mc-toggle input {
|
|
1440
|
+
display: none;
|
|
1441
|
+
}
|
|
1442
|
+
.mc-toggle span {
|
|
1443
|
+
position: absolute;
|
|
1444
|
+
inset: 0;
|
|
1445
|
+
background: #cbd5e1;
|
|
1446
|
+
border-radius: 999px;
|
|
1447
|
+
transition: background 0.15s;
|
|
1448
|
+
cursor: pointer;
|
|
1449
|
+
}
|
|
1450
|
+
.mc-toggle span::before {
|
|
1451
|
+
content: '';
|
|
1452
|
+
position: absolute;
|
|
1453
|
+
width: 16px;
|
|
1454
|
+
height: 16px;
|
|
1455
|
+
background: #fff;
|
|
1456
|
+
border-radius: 50%;
|
|
1457
|
+
left: 2px;
|
|
1458
|
+
top: 2px;
|
|
1459
|
+
transition: transform 0.15s;
|
|
1460
|
+
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
|
1461
|
+
}
|
|
1462
|
+
.mc-toggle input:checked + span {
|
|
1463
|
+
background: var(--mc-primary);
|
|
1464
|
+
}
|
|
1465
|
+
.mc-toggle input:checked + span::before {
|
|
1466
|
+
transform: translateX(16px);
|
|
1467
|
+
}
|
|
1468
|
+
.mc-toggle.disabled span {
|
|
1469
|
+
opacity: 0.45;
|
|
1470
|
+
cursor: not-allowed;
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
.mc-chip {
|
|
1474
|
+
display: inline-flex;
|
|
1475
|
+
align-items: center;
|
|
1476
|
+
gap: 5px;
|
|
1477
|
+
padding: 2px 8px;
|
|
1478
|
+
border-radius: 999px;
|
|
1479
|
+
font-size: 11px;
|
|
1480
|
+
font-weight: 600;
|
|
1481
|
+
border: 1px solid;
|
|
1482
|
+
}
|
|
1483
|
+
.mc-chip--muted {
|
|
1484
|
+
background: #f1f5f9;
|
|
1485
|
+
color: #475569;
|
|
1486
|
+
border-color: #cbd5e1;
|
|
1487
|
+
}
|
|
1488
|
+
.mc-chip--featured {
|
|
1489
|
+
background: var(--mc-primary-50);
|
|
1490
|
+
color: var(--mc-primary-700);
|
|
1491
|
+
border-color: #bfdbfe;
|
|
1492
|
+
}
|
|
1493
|
+
.mc-chip--live {
|
|
1494
|
+
background: #ecfdf5;
|
|
1495
|
+
color: #047857;
|
|
1496
|
+
border-color: #a7f3d0;
|
|
1497
|
+
}
|
|
1498
|
+
.mc-chip--live::before {
|
|
1499
|
+
content: '';
|
|
1500
|
+
width: 6px;
|
|
1501
|
+
height: 6px;
|
|
1502
|
+
border-radius: 50%;
|
|
1503
|
+
background: currentColor;
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
.mc-expand-btn {
|
|
1507
|
+
width: 28px;
|
|
1508
|
+
height: 28px;
|
|
1509
|
+
display: grid;
|
|
1510
|
+
place-items: center;
|
|
1511
|
+
background: transparent;
|
|
1512
|
+
border: 1px solid transparent;
|
|
1513
|
+
border-radius: 6px;
|
|
1514
|
+
cursor: pointer;
|
|
1515
|
+
color: var(--mc-text-3);
|
|
1516
|
+
transition:
|
|
1517
|
+
background 0.12s,
|
|
1518
|
+
color 0.12s,
|
|
1519
|
+
border-color 0.12s;
|
|
1520
|
+
}
|
|
1521
|
+
.mc-expand-btn:hover {
|
|
1522
|
+
background: #f1f5f9;
|
|
1523
|
+
color: var(--mc-primary);
|
|
1524
|
+
border-color: #e2e8f0;
|
|
1525
|
+
}
|
|
1526
|
+
.mc-chev {
|
|
1527
|
+
display: inline-flex;
|
|
1528
|
+
transition: transform 0.15s;
|
|
1529
|
+
}
|
|
1530
|
+
.mc-chev.open {
|
|
1531
|
+
transform: rotate(90deg);
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
.mc-admin-expand {
|
|
1535
|
+
grid-column: 1 / -1;
|
|
1536
|
+
background: linear-gradient(180deg, #fbfbfd 0%, #fff 100%);
|
|
1537
|
+
border-bottom: 1px solid #f1f5f9;
|
|
1538
|
+
padding: 18px 20px 22px;
|
|
1539
|
+
}
|
|
1540
|
+
.mc-expand-grid {
|
|
1541
|
+
display: grid;
|
|
1542
|
+
grid-template-columns: minmax(0, 1fr) minmax(0, 1.3fr);
|
|
1543
|
+
gap: 24px;
|
|
1544
|
+
}
|
|
1545
|
+
.mc-expand-col {
|
|
1546
|
+
display: flex;
|
|
1547
|
+
flex-direction: column;
|
|
1548
|
+
gap: 14px;
|
|
1549
|
+
}
|
|
1550
|
+
.mc-expand-sec {
|
|
1551
|
+
display: flex;
|
|
1552
|
+
flex-direction: column;
|
|
1553
|
+
gap: 6px;
|
|
1554
|
+
}
|
|
1555
|
+
.mc-expand-label {
|
|
1556
|
+
font-size: 10.5px;
|
|
1557
|
+
text-transform: uppercase;
|
|
1558
|
+
letter-spacing: 0.1em;
|
|
1559
|
+
color: var(--mc-text-2);
|
|
1560
|
+
font-weight: 700;
|
|
1561
|
+
}
|
|
1562
|
+
.mc-expand-hint {
|
|
1563
|
+
font-size: 11px;
|
|
1564
|
+
color: var(--mc-text-3);
|
|
1565
|
+
}
|
|
1566
|
+
.mc-field-head {
|
|
1567
|
+
display: flex;
|
|
1568
|
+
align-items: baseline;
|
|
1569
|
+
gap: 8px;
|
|
1570
|
+
flex-wrap: wrap;
|
|
1571
|
+
}
|
|
1572
|
+
.mc-source-hint {
|
|
1573
|
+
font-size: 11px;
|
|
1574
|
+
color: var(--mc-text-3);
|
|
1575
|
+
}
|
|
1576
|
+
.mc-source-hint em {
|
|
1577
|
+
color: var(--mc-text-2);
|
|
1578
|
+
font-style: normal;
|
|
1579
|
+
}
|
|
1580
|
+
.mc-locked-value {
|
|
1581
|
+
display: flex;
|
|
1582
|
+
align-items: center;
|
|
1583
|
+
gap: 8px;
|
|
1584
|
+
padding: 8px 10px;
|
|
1585
|
+
background: var(--mc-surface);
|
|
1586
|
+
border: 1px solid var(--mc-border);
|
|
1587
|
+
border-radius: 6px;
|
|
1588
|
+
font-size: 13px;
|
|
1589
|
+
color: var(--mc-text-1);
|
|
1590
|
+
}
|
|
1591
|
+
.mc-locked-hint {
|
|
1592
|
+
font-size: 10px;
|
|
1593
|
+
text-transform: uppercase;
|
|
1594
|
+
color: var(--mc-text-3);
|
|
1595
|
+
background: rgba(148, 163, 184, 0.15);
|
|
1596
|
+
padding: 1px 6px;
|
|
1597
|
+
border-radius: 4px;
|
|
1598
|
+
margin-left: auto;
|
|
1599
|
+
}
|
|
1600
|
+
.mc-trial-row {
|
|
1601
|
+
display: flex;
|
|
1602
|
+
align-items: center;
|
|
1603
|
+
gap: 10px;
|
|
1604
|
+
background: var(--mc-surface);
|
|
1605
|
+
border: 1px solid var(--mc-border);
|
|
1606
|
+
border-radius: 8px;
|
|
1607
|
+
padding: 8px 12px;
|
|
1608
|
+
}
|
|
1609
|
+
.mc-trial-label {
|
|
1610
|
+
font-size: 13px;
|
|
1611
|
+
color: var(--mc-text);
|
|
1612
|
+
}
|
|
1613
|
+
.mc-trial-days {
|
|
1614
|
+
margin-left: auto;
|
|
1615
|
+
display: flex;
|
|
1616
|
+
align-items: center;
|
|
1617
|
+
gap: 6px;
|
|
1618
|
+
}
|
|
1619
|
+
.mc-trial-unit {
|
|
1620
|
+
font-size: 12px;
|
|
1621
|
+
color: var(--mc-text-2);
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
.mc-tf-head {
|
|
1625
|
+
display: flex;
|
|
1626
|
+
align-items: center;
|
|
1627
|
+
}
|
|
1628
|
+
.mc-tf-list {
|
|
1629
|
+
display: flex;
|
|
1630
|
+
flex-direction: column;
|
|
1631
|
+
gap: 6px;
|
|
1632
|
+
}
|
|
1633
|
+
.mc-tf-row {
|
|
1634
|
+
display: grid;
|
|
1635
|
+
grid-template-columns: 22px minmax(0, 1.4fr) minmax(0, 1fr) auto;
|
|
1636
|
+
gap: 8px;
|
|
1637
|
+
align-items: center;
|
|
1638
|
+
}
|
|
1639
|
+
.mc-tf-num {
|
|
1640
|
+
font: 600 11px var(--mc-font-mono);
|
|
1641
|
+
color: var(--mc-text-3);
|
|
1642
|
+
text-align: center;
|
|
1643
|
+
}
|
|
1644
|
+
.mc-tf-actions {
|
|
1645
|
+
display: flex;
|
|
1646
|
+
gap: 2px;
|
|
1647
|
+
}
|
|
1648
|
+
.mc-iconbtn {
|
|
1649
|
+
width: 24px;
|
|
1650
|
+
height: 24px;
|
|
1651
|
+
display: grid;
|
|
1652
|
+
place-items: center;
|
|
1653
|
+
background: var(--mc-surface);
|
|
1654
|
+
border: 1px solid var(--mc-border);
|
|
1655
|
+
border-radius: 5px;
|
|
1656
|
+
cursor: pointer;
|
|
1657
|
+
font: 600 10px var(--mc-font-sans);
|
|
1658
|
+
color: var(--mc-text-2);
|
|
1659
|
+
padding: 0;
|
|
1660
|
+
transition:
|
|
1661
|
+
background 0.12s,
|
|
1662
|
+
border-color 0.12s;
|
|
1663
|
+
}
|
|
1664
|
+
.mc-iconbtn:hover:not(:disabled) {
|
|
1665
|
+
background: #f1f5f9;
|
|
1666
|
+
border-color: #cbd5e1;
|
|
1667
|
+
}
|
|
1668
|
+
.mc-iconbtn:disabled {
|
|
1669
|
+
opacity: 0.35;
|
|
1670
|
+
cursor: not-allowed;
|
|
1671
|
+
}
|
|
1672
|
+
.mc-iconbtn--danger {
|
|
1673
|
+
color: #b91c1c;
|
|
1674
|
+
}
|
|
1675
|
+
.mc-iconbtn--danger:hover:not(:disabled) {
|
|
1676
|
+
background: #fef2f2;
|
|
1677
|
+
border-color: #fca5a5;
|
|
1678
|
+
}
|
|
1679
|
+
.mc-tf-empty {
|
|
1680
|
+
padding: 14px;
|
|
1681
|
+
text-align: center;
|
|
1682
|
+
font-size: 12px;
|
|
1683
|
+
color: var(--mc-text-3);
|
|
1684
|
+
background: var(--mc-surface);
|
|
1685
|
+
border: 1px dashed #cbd5e1;
|
|
1686
|
+
border-radius: 8px;
|
|
1687
|
+
}
|
|
1688
|
+
.mc-tf-add {
|
|
1689
|
+
display: flex;
|
|
1690
|
+
flex-direction: column;
|
|
1691
|
+
gap: 8px;
|
|
1692
|
+
margin-top: 4px;
|
|
1693
|
+
}
|
|
1694
|
+
.mc-tf-suggestions {
|
|
1695
|
+
display: flex;
|
|
1696
|
+
flex-wrap: wrap;
|
|
1697
|
+
gap: 6px;
|
|
1698
|
+
align-items: center;
|
|
1699
|
+
}
|
|
1700
|
+
.mc-tf-chip {
|
|
1701
|
+
display: inline-flex;
|
|
1702
|
+
align-items: center;
|
|
1703
|
+
gap: 5px;
|
|
1704
|
+
padding: 3px 9px;
|
|
1705
|
+
border-radius: 999px;
|
|
1706
|
+
background: var(--mc-surface);
|
|
1707
|
+
border: 1px dashed #cbd5e1;
|
|
1708
|
+
font-size: 11.5px;
|
|
1709
|
+
color: #475569;
|
|
1710
|
+
cursor: pointer;
|
|
1711
|
+
transition:
|
|
1712
|
+
background 0.12s,
|
|
1713
|
+
border-color 0.12s,
|
|
1714
|
+
color 0.12s;
|
|
1715
|
+
}
|
|
1716
|
+
.mc-tf-chip em {
|
|
1717
|
+
font-style: normal;
|
|
1718
|
+
color: var(--mc-text-3);
|
|
1719
|
+
font: 500 11px var(--mc-font-mono);
|
|
1720
|
+
}
|
|
1721
|
+
.mc-tf-chip:hover:not(:disabled) {
|
|
1722
|
+
background: var(--mc-primary-50);
|
|
1723
|
+
border-color: #93c5fd;
|
|
1724
|
+
border-style: solid;
|
|
1725
|
+
color: var(--mc-primary-700);
|
|
1726
|
+
}
|
|
1727
|
+
.mc-tf-chip:disabled {
|
|
1728
|
+
opacity: 0.5;
|
|
1729
|
+
cursor: not-allowed;
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
@media (max-width: 980px) {
|
|
1733
|
+
.mc-expand-grid {
|
|
1734
|
+
grid-template-columns: 1fr;
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
/* Version tabs below the plan name — visible only when the plan
|
|
1739
|
+
* has multiple published versions (e.g. v2 active + v3 planned).
|
|
1740
|
+
* Sim pattern: small pills, active version inverted. */
|
|
1741
|
+
.mc-version-tabs {
|
|
1742
|
+
display: flex;
|
|
1743
|
+
flex-wrap: wrap;
|
|
1744
|
+
gap: 4px;
|
|
1745
|
+
margin-top: 6px;
|
|
1746
|
+
}
|
|
1747
|
+
.mc-version-tab {
|
|
1748
|
+
border: 1px solid #e2e8f0;
|
|
1749
|
+
background: #fff;
|
|
1750
|
+
border-radius: 6px;
|
|
1751
|
+
padding: 2px 8px;
|
|
1752
|
+
font: 600 10.5px var(--sa-font-mono, ui-monospace, monospace);
|
|
1753
|
+
color: #64748b;
|
|
1754
|
+
cursor: pointer;
|
|
1755
|
+
letter-spacing: 0.02em;
|
|
1756
|
+
transition:
|
|
1757
|
+
border-color 0.1s,
|
|
1758
|
+
background 0.1s,
|
|
1759
|
+
color 0.1s;
|
|
1760
|
+
}
|
|
1761
|
+
.mc-version-tab:hover {
|
|
1762
|
+
border-color: #cbd5e1;
|
|
1763
|
+
background: #f8fafc;
|
|
1764
|
+
color: #475569;
|
|
1765
|
+
}
|
|
1766
|
+
.mc-version-tab--active {
|
|
1767
|
+
border-color: #1d4ed8;
|
|
1768
|
+
background: #eff6ff;
|
|
1769
|
+
color: #1d4ed8;
|
|
1770
|
+
}
|
|
1771
|
+
</style>
|