Verträge: Mein Vertrag, Vergleich der Abos, Wechsel-Karte, Unterlagen in der zugestimmten Fassung

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-09-29 15:09:36 +00:00
1 parent 0129ac48e7
commit 19af93338b
3 files changed
+215 -53

No files matched your search

+186 -52
View File
@@ -760,16 +760,6 @@ async function pollChatBadge() {
// Wartungs-Abo: Karte in der Seitenleiste + Ansicht (Bestellung bzw. Stundenkonto)
// ---------------------------------------------------------------------------
const ABO_FEATURES = [
['Betrieb &amp; Pflege Ihrer Website/Anwendung', 'Server in Deutschland, Updates, Sicherheit, tägliche Datensicherung, Überwachung'],
['1 Domain inklusive', 'Registrierung auf Ihren Namen, DNS- und Zertifikatsverwaltung'],
['1 E-Mail-Postfach', 'mit Ihrer eigenen Domain, 5 GB Speicher'],
['1 Stunde Arbeitszeit pro Monat', 'nicht genutzte Zeit bleibt 12 Monate verfügbar'],
]
// Kurzfassung fuer die Karte in der Seitenleiste
const ABO_SIDE_FEATURES = ['Betrieb & Pflege, Server in Deutschland', '1 Domain inklusive', '1 E-Mail-Postfach, 5 GB', '1 Std. Arbeitszeit pro Monat']
function fmtHours(min) {
const n = Math.max(0, Number(min || 0))
const h = Math.floor(n / 60)
@@ -787,9 +777,19 @@ function fmtDayMonth(value) {
return Number.isNaN(d.getTime()) ? '' : d.toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit' })
}
function aboFeaturesHtml() {
return `<ul class="abo-features">${ABO_FEATURES.map(
([t, d]) => `<li>${t}<small>${d}</small></li>`
// Leistungen eines Produkts aus dem Katalog der Server-Antwort (data.products[].features = [Titel, Detail])
function productOf(data, key) {
return (data?.products || []).find((p) => p.key === key) || null
}
function productLabelOf(data, key) {
const product = productOf(data, key)
return (product?.name || (key === 'hosting' ? 'Hosting-Abo' : 'Wartungs-Abo')).replace(/^WEBklar\s+/, '')
}
function aboFeaturesHtml(features) {
return `<ul class="abo-features">${(features || []).map(
([t, d]) => `<li>${escapeHtml(t)}<small>${escapeHtml(d)}</small></li>`
).join('')}</ul>`
}
@@ -804,48 +804,57 @@ function aboTermText(sub) {
return 'Monatlich kündbar zum Ende des Abrechnungsmonats.'
}
/* Seitenleiste: Angebot (noch kein Abo) */
/* Seitenleiste: noch kein Abo (Auswahl steht in der Ansicht "Verträge") */
function renderAboSideOffer(card, data, { ended }) {
const offer = data.offer
const cheapest = Math.min(...(data.products || []).map((p) => p.amountCents).filter(Number.isFinite), 1500)
card.classList.add('kb-abo-offer')
card.innerHTML = `
<p class="kb-label">${ended ? 'Ihr Abo ist beendet' : 'Rundum versorgt'}</p>
<h3>Wartungs-Abo</h3>
${offer ? '<span class="kb-offer-tag">Ihr persönliches Angebot</span>' : ''}
<p class="kb-price">${offer
? `<strong>${euroShort(offer.monthlyPriceCents)}</strong><span>${offer.months === 1 ? 'im ersten Monat' : `pro Monat in den ersten ${offer.months} Monaten`}, danach 50&nbsp;€</span>`
: '<strong>50 €</strong><span>pro Monat</span>'}</p>
${offer?.validUntil ? `<p class="kb-offer-until">Angebot gültig bis <strong>${fmtDateShort(offer.validUntil)}</strong></p>` : ''}
<ul class="kb-checks">${ABO_SIDE_FEATURES.map((f) => `<li>${escapeHtml(f)}</li>`).join('')}</ul>
<button type="button" class="kb-btn-primary kb-btn-block" data-view="stunden"${data.available ? '' : ' disabled'}>Abo abschließen</button>
<p class="kb-label">Ihr Vertrag</p>
<h3>${ended ? 'Ihr Abo ist beendet' : 'Rundum versorgt'}</h3>
<p class="kb-price"><strong>ab ${euroShort(cheapest)}</strong><span>pro Monat</span></p>
<p class="kb-sub">Hosting-Abo oder Wartungs-Abo, 12 Monate Mindestlaufzeit.</p>
<button type="button" class="kb-btn-primary kb-btn-block" data-view="vertraege">Abos ansehen</button>
<p class="kb-fine">${data.available
? '12 Monate Mindestlaufzeit. Gemäß § 19 UStG ohne Umsatzsteuer.'
? 'Gemäß § 19 UStG ohne Umsatzsteuer.'
: 'Die Online-Bestellung wird gerade eingerichtet – schreiben Sie uns gern im Chat.'}</p>`
}
/* Seitenleiste: laufendes Abo */
function renderAboSideActive(card, data) {
const sub = data.subscription
const l = data.ledger || { balanceMinutes: 0 }
// Status nur bei Kündigung; Zahlungsproblem (Hinweis unten) und späterer Beginn (Datumszeile) stehen im Text
const status = sub.cancelAtPeriodEnd ? '<span class="kb-status kb-status-off">gekündigt</span>' : ''
const dateLine = sub.cancelAtPeriodEnd
function aboIsCancelled(sub) {
return Boolean(sub.cancelAtPeriodEnd || sub.cancelAt)
}
function aboDateLine(sub) {
return aboIsCancelled(sub)
? `Leistungen bis ${fmtDateShort(sub.cancelAt || sub.currentPeriodEnd)}`
: sub.serviceStartsAt
? `Beginn und erste Abbuchung am ${fmtDateShort(sub.serviceStartsAt)}`
: `nächste Abbuchung ${fmtDateShort(sub.currentPeriodEnd)}`
}
/* Seitenleiste: laufendes Abo ("Ihr Vertrag") */
function renderAboSideActive(card, data) {
const sub = data.subscription
const wartung = sub.productKey === 'wartung'
const l = data.ledger || { balanceMinutes: 0 }
// Status nur bei Kündigung; Zahlungsproblem (Hinweis unten) und späterer Beginn (Datumszeile) stehen im Text
const status = aboIsCancelled(sub) ? '<span class="kb-status kb-status-off">gekündigt</span>' : ''
card.classList.remove('kb-abo-offer')
card.innerHTML = `
<p class="kb-label">Ihre Wartung</p>
<p class="kb-label">Ihr Vertrag</p>
${status}
<h3>${escapeHtml(productLabelOf(data, sub.productKey))}</h3>
${wartung ? `
<button type="button" class="kb-hours" data-view="stunden" title="Stundenkonto und Verlauf ansehen" aria-label="${fmtHours(l.balanceMinutes)} Arbeitszeit verfügbar – Stundenkonto und Verlauf ansehen">
<strong>${fmtHours(l.balanceMinutes)}</strong><span>Arbeitszeit verfügbar</span><em aria-hidden="true">›</em>
</button>
<p class="kb-sub">${dateLine}</p>
</button>` : `
<p class="kb-price"><strong>${fmtEuro(sub.priceAmount / 100)}</strong><span>pro Monat, gemäß § 19 UStG ohne Umsatzsteuer</span></p>`}
<p class="kb-sub">${aboDateLine(sub)}</p>
${sub.switchPending ? '<p class="kb-side-alert">Ihr Wechsel wartet auf die Zahlung.</p>' : ''}
${sub.paymentProblem ? `
<p class="kb-side-alert">Die letzte Abbuchung ist fehlgeschlagen. Bitte aktualisieren Sie Ihre Zahlungsart, damit Ihre Website online bleibt.</p>
<button type="button" class="kb-side-btn" id="abo-manage">Zahlungsart aktualisieren ↗</button>` : ''}
<button type="button" class="kb-side-btn" data-view="takeover">Übernahmegutschrift beantragen <span class="kb-badge hidden" id="takeover-badge" title="Rückfrage offen"></span></button>
${sub.withdrawalOpen ? `<a class="kb-side-btn" href="/widerrufen.html">Vertrag widerrufen <small>· bis ${fmtDayMonth(sub.withdrawalUntil)}</small></a>` : ''}
${sub.withdrawalOpen ? `<a class="kb-side-btn kb-withdraw" href="/widerrufen.html">Vertrag widerrufen <small>· bis ${fmtDayMonth(sub.withdrawalUntil)}</small></a>` : ''}
<div class="kb-divider"></div>
<p class="kb-fine" style="margin-top:0">${aboTermText(sub)}</p>
<p class="error hidden" id="abo-error"></p>`
@@ -884,7 +893,7 @@ function renderAboOrderView(view, data) {
<p class="abo-note">Gültig bis ${fmtDateShort(offer.validUntil)} · wird beim Abschluss automatisch berücksichtigt</p>
</div>` : ''}
<div class="kb-panel">
${aboFeaturesHtml()}
${aboFeaturesHtml(productOf(data, 'wartung')?.features)}
<p class="abo-note" style="margin:0">Schon bei einem anderen Anbieter für Domain, Hosting oder E-Mail bezahlt? Nach dem Abschluss können Sie eine Übernahmegutschrift beantragen – wir rechnen die doppelt bezahlte Zeit anteilig an.</p>
</div>
${available ? `
@@ -955,7 +964,7 @@ function renderAboOrderView(view, data) {
}
/* Ansicht "Wartungs-Abo": Stundenkonto, Verlauf, Vertrag (Bedingungen § 4: jederzeit im Portal einsehbar) */
function renderAboAccountView(view, data) {
function renderHoursView(view, data) {
const sub = data.subscription
const l = data.ledger || { balanceMinutes: 0, usedMinutes: 0, credits: [], usage: [] }
const usageRows = (l.usage || []).map((u) => `
@@ -968,7 +977,7 @@ function renderAboAccountView(view, data) {
view.innerHTML = `
<header class="kb-head">
<h1>Wartungs-Abo</h1>
<h1>Stundenkonto</h1>
<p>Ihr Stundenkontingent, der Verlauf und Ihr Vertrag.</p>
</header>
${sub.paymentProblem ? '<div class="abo-alert">Die letzte Abbuchung ist fehlgeschlagen. Bitte aktualisieren Sie Ihre Zahlungsart über „Zahlungsart aktualisieren“ <span class="kb-desk-only">in der Seitenleiste</span><span class="kb-app-only">unter <a href="#vertraege" data-view="vertraege">Verträge</a></span>, damit Ihre Website online bleibt.</div>' : ''}
@@ -982,7 +991,7 @@ function renderAboAccountView(view, data) {
<p class="abo-note" style="margin-top:0">Jeden bezahlten Monat kommt 1 Stunde dazu. Angerechnet wird in 15-Minuten-Schritten, die ältesten Stunden zuerst. Aufträge schicken Sie uns einfach über den <a href="#chat" data-view="chat">Chat</a>.</p>
${sub.offerText ? `<p class="abo-note">Ihr Angebot: ${escapeHtml(sub.offerText)}.</p>` : ''}
<p class="abo-note">${aboTermText(sub)}</p>
<p class="abo-note" style="margin-bottom:0"><a href="/abo-bedingungen.html" target="_blank" rel="noopener">Leistungen &amp; Bedingungen</a></p>
<p class="abo-note" style="margin-bottom:0"><a href="${escapeAttr(sub.termsUrl || '/abo-bedingungen.html')}" target="_blank" rel="noopener">Leistungen &amp; Bedingungen</a></p>
</div>
<div class="kb-panel">
<h3>Verlauf</h3>
@@ -996,10 +1005,138 @@ function renderAboAccountView(view, data) {
const aboState = { data: null }
async function loadAboCard({ afterCheckout = false } = {}) {
/* Seitenleiste bzw. Karte in der App-Ansicht: "Ihr Vertrag" */
function renderSideCard(card, data) {
if (data.subscription?.active) renderAboSideActive(card, data)
else renderAboSideOffer(card, data, { ended: Boolean(data.subscription) })
}
/* Zusatzspeicher: Hülle, gefüllt in Aufgabe 17 */
function renderStoragePanel(container, data) {
container.dataset.units = String(data?.subscription?.storage?.units ?? 0)
}
/* "Postfach anfragen": öffnet den Chat mit vorbereiteter Nachricht (Eingabefeld wird nicht automatisch gesendet) */
function requestMailbox() {
showView('chat')
const box = document.querySelector('#chat-composer textarea')
if (!box) return
box.value = 'Bitte legen Sie ein neues Postfach an: '
box.dispatchEvent(new Event('input', { bubbles: true }))
box.focus()
box.setSelectionRange(box.value.length, box.value.length)
}
/* Vergleich der beiden Abos (kein laufendes Abo) */
function renderCompareCard(data, key) {
const product = productOf(data, key)
if (!product) return ''
const offer = data.offers?.[key]
return `
<article class="kb-panel kb-compare-card" data-product="${escapeAttr(key)}">
<h3>${escapeHtml(productLabelOf(data, key))}</h3>
${offer ? `<span class="kb-offer-tag">Ihr persönliches Angebot</span><p class="abo-note" style="margin:0 0 8px">${escapeHtml(offer.text)}${offer.validUntil ? ` · gültig bis ${fmtDateShort(offer.validUntil)}` : ''}</p>` : ''}
<p class="kb-price"><strong>${fmtEuro(product.amountCents / 100)}</strong><span>pro Monat</span></p>
<p class="abo-note">Gemäß § 19 UStG ohne Umsatzsteuer. ${product.minTermMonths} Monate Mindestlaufzeit, danach monatlich kündbar.</p>
${aboFeaturesHtml(product.features)}
<button type="button" class="kb-btn-primary kb-btn-block" data-view="bestellen-${escapeAttr(key)}"${data.available ? '' : ' disabled'}>Abo abschließen</button>
</article>`
}
/* Wechsel-Karte (nur Hosting) */
function renderSwitchCard(data) {
const sub = data.subscription
if (sub.productKey !== 'hosting') return ''
const wartung = productOf(data, 'wartung')
if (sub.switchPending) {
const url = sub.switchPending.paymentUrl
return `
<div class="kb-panel kb-switch-card" id="switch-card">
<h3>Ihr Wechsel wartet auf die Zahlung</h3>
<p class="abo-note" style="margin-top:0">Ihr Wechsel zum Wartungs-Abo ist erst abgeschlossen, wenn die Zahlung bestätigt ist.${url ? '' : ' Schreiben Sie uns gern im Chat, wenn Sie Hilfe brauchen.'}</p>
${url ? `<a class="kb-btn-primary" href="${escapeAttr(url)}">Jetzt bezahlen</a>` : ''}
</div>`
}
if (aboIsCancelled(sub)) {
return `
<div class="kb-panel kb-switch-card" id="switch-card">
<h3>Wartungs-Abo</h3>
<p class="abo-note" style="margin:0">Ihr Abo ist gekündigt. Bitte schreiben Sie uns im Chat, wenn Sie zum Wartungs-Abo wechseln möchten.</p>
</div>`
}
if (!sub.canSwitch || !wartung) return ''
return `
<div class="kb-panel kb-switch-card" id="switch-card">
<h3>Mehr als Hosting? Das Wartungs-Abo</h3>
<p class="kb-price"><strong>${fmtEuro(wartung.amountCents / 100)}</strong><span>pro Monat</span></p>
<p class="abo-note">Gemäß § 19 UStG ohne Umsatzsteuer. Zusätzlich zum Hosting: Pflege Ihrer Website, beliebig viele Postfächer und 1 Stunde Arbeitszeit pro Monat.</p>
<button type="button" class="kb-btn-primary" data-view="wechsel">Zum Wartungs-Abo wechseln</button>
</div>`
}
/* Ansicht "Verträge": Mein Vertrag, Wechsel-Karte, Stundenkonto; ohne Abo der Vergleich */
function renderContractsView(view, data) {
const sub = data.subscription
if (!sub?.active) {
view.innerHTML = `
${sub ? '<div class="abo-alert">Ihr bisheriges Abo ist beendet. Sie können jederzeit ein neues abschließen.</div>' : ''}
${data.available ? '' : '<div class="abo-alert">Die Online-Bestellung wird gerade eingerichtet – schreiben Sie uns gern im Chat.</div>'}
<div class="kb-compare">${renderCompareCard(data, 'hosting')}${renderCompareCard(data, 'wartung')}</div>
<p class="abo-note">Alle Preise gemäß § 19 UStG ohne Umsatzsteuer. Es gelten die <a href="/abo-bedingungen.html" target="_blank" rel="noopener">Vertragsbedingungen</a>.</p>`
return
}
const product = productOf(data, sub.productKey)
const wartung = sub.productKey === 'wartung'
const l = data.ledger || { balanceMinutes: 0 }
const units = Number(sub.storage?.units || 0)
const gb = (Number(data.mailStorage?.includedGb) || 0) + units * (Number(data.mailStorage?.gbPerUnit) || 0)
const cancelled = aboIsCancelled(sub)
const pill = cancelled
? '<span class="kb-status kb-status-off">gekündigt</span>'
: sub.paymentProblem ? '<span class="kb-status kb-status-warn">Zahlung offen</span>' : '<span class="kb-status kb-status-ok">aktiv</span>'
view.innerHTML = `
${sub.paymentProblem ? '<div class="abo-alert">Die letzte Abbuchung ist fehlgeschlagen. Bitte aktualisieren Sie Ihre Zahlungsart, damit Ihre Website online bleibt.</div>' : ''}
${sub.serviceStartsAt ? `<div class="abo-alert">Wie gewählt beginnen wir nach Ablauf der Widerrufsfrist am ${fmtDateShort(sub.serviceStartsAt)}. Dann wird auch zum ersten Mal abgebucht.</div>` : ''}
<div class="kb-panel" id="contract-card">
<div class="kb-contract-head"><h2>Mein Vertrag</h2>${pill}</div>
<h3>${escapeHtml(productLabelOf(data, sub.productKey))}</h3>
<p class="kb-price"><strong>${fmtEuro(sub.priceAmount / 100)}</strong><span>pro Monat</span></p>
<p class="abo-note">Gemäß § 19 UStG ohne Umsatzsteuer.</p>
<dl class="kb-contract-facts">
<div><dt>${cancelled ? 'Leistungen' : 'Abrechnung'}</dt><dd>${aboDateLine(sub)}</dd></div>
<div><dt>Laufzeit</dt><dd>${aboTermText(sub)}</dd></div>
${sub.offerText ? `<div><dt>Angebot</dt><dd>${escapeHtml(sub.offerText)}</dd></div>` : ''}
<div><dt>E-Mail-Speicher</dt><dd>${gb} GB${units ? ` (${units} Zusatzpaket${units === 1 ? '' : 'e'})` : ''}</dd></div>
</dl>
${aboFeaturesHtml(product?.features)}
<p class="abo-note"><a href="${escapeAttr(sub.termsUrl || '/abo-bedingungen.html')}" target="_blank" rel="noopener">Vertragsbedingungen</a>
· <a href="${escapeAttr(sub.termsPdfUrl || '/WEBklar-Abo-Vertragsunterlagen.pdf')}" target="_blank" rel="noopener">als PDF</a></p>
${sub.withdrawalOpen ? `<a class="kb-side-btn kb-withdraw" href="/widerrufen.html">Vertrag widerrufen <small>· bis ${fmtDayMonth(sub.withdrawalUntil)}</small></a>` : ''}
<div class="kb-contract-actions">
${sub.paymentProblem ? '<button type="button" class="kb-btn-primary" id="contract-manage">Zahlungsart aktualisieren</button>' : ''}
<button type="button" class="kb-side-btn" id="contract-mailbox">Postfach anfragen</button>
<a class="kb-side-btn" href="/kuendigen.html">Verträge hier kündigen</a>
</div>
<p class="error hidden" id="contract-error"></p>
</div>
${wartung ? `
<div class="kb-panel" id="hours-card">
<h3>Stundenkonto</h3>
<p class="kb-price"><strong>${fmtHours(l.balanceMinutes)}</strong><span>Arbeitszeit verfügbar</span></p>
<a href="#stunden" data-view="stunden">Stundenkonto</a> mit Verlauf ansehen
</div>` : ''}
${renderSwitchCard(data)}
<div class="kb-panel" id="storage-panel"></div>`
view.querySelector('#contract-manage')?.addEventListener('click', (ev) => openBillingPortal(ev.currentTarget, view.querySelector('#contract-error')))
view.querySelector('#contract-mailbox')?.addEventListener('click', requestMailbox)
renderStoragePanel(view.querySelector('#storage-panel'), data)
}
async function loadContracts({ afterCheckout = false } = {}) {
const card = document.getElementById('abo-card')
const view = document.getElementById('view-stunden')
if (!card || !view) return
const contracts = document.getElementById('kb-contracts')
if (!card || !view || !contracts) return
try {
let data = await api('/api/abo')
// Direkt nach dem Checkout kann der Stripe-Webhook noch unterwegs sein
@@ -1010,13 +1147,10 @@ async function loadAboCard({ afterCheckout = false } = {}) {
data = await api('/api/abo')
}
aboState.data = data
if (data.subscription?.active) {
renderAboSideActive(card, data)
renderAboAccountView(view, data)
} else {
renderAboSideOffer(card, data, { ended: Boolean(data.subscription) })
renderAboOrderView(view, data)
}
renderSideCard(card, data)
renderContractsView(contracts, data)
if (data.subscription?.active && data.subscription.productKey === 'wartung') renderHoursView(view, data)
else view.innerHTML = '<header class="kb-head"><h1>Stundenkonto</h1></header><div class="kb-panel"><p class="abo-note" style="margin:0">Ein Stundenkonto gibt es im Wartungs-Abo. Ihre Verträge finden Sie unter <a href="#vertraege" data-view="vertraege">Verträge</a>.</p></div>'
card.classList.remove('hidden')
document.getElementById('invoices-abo-note')?.classList.toggle('hidden', !data.subscription)
document.getElementById('kb-sheet-abo')?.classList.toggle('hidden', !data.subscription)
@@ -1506,7 +1640,7 @@ async function initDashboardPage() {
const aboParam = getQueryParam('abo')
if (aboParam) window.history.replaceState(null, '', `/dashboard.html${window.location.hash}`)
loadAboCard({ afterCheckout: aboParam === 'success' })
loadContracts({ afterCheckout: aboParam === 'success' })
loadCustomerInvoices()
const initialView = viewFromHash()
@@ -1954,8 +2088,8 @@ function initAppShell() {
renderInstallOffers()
updateAppBar()
// Nach der Bestellung bei Stripe: Bestaetigung steht auf der Abo-Karte im Tab "Verträge"
if (isAppLayout() && getQueryParam('abo') === 'success') showView('vertraege')
// Nach der Bestellung bei Stripe: Bestaetigung steht in der Ansicht "Verträge" (Handy und Desktop)
if (getQueryParam('abo') === 'success') showView('vertraege')
}
// iOS zoomt beim Tippen in ein Feld hinein, wenn dessen Schrift effektiv unter 16 px liegt – z. B. bei
+2 -1
View File
@@ -184,7 +184,7 @@
</div>
</section>
<!-- Tab "Verträge": die Abo-Karte der Seitenleiste als Übersicht (App-Ansicht);
<!-- Tab "Verträge": Mein Vertrag, Vergleich, Wechsel, Zusatzspeicher (app.js); in der App-Ansicht steht die Abo-Karte der Seitenleiste darüber;
Stundenkonto, Bestellung, Wechsel und Übernahmegutschrift öffnen sich als Unterseiten -->
<section class="kb-view" id="view-vertraege">
<header class="kb-head">
@@ -192,6 +192,7 @@
<p>Ihre Verträge mit WEBklar: Hosting, Wartung und Zusatzspeicher.</p>
</header>
<div id="kb-abo-slot"><p class="kb-empty">Lade…</p></div>
<div id="kb-contracts"></div>
</section>
<!-- Stundenkonto (Wartungs-Abo), Bestellungen und Wechsel (app.js, Aufgaben 15 bis 18) -->
+27
View File
@@ -810,3 +810,30 @@ button.kb-avatar:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
.kb-shell.dashboard-content-reveal, .kb-view.active, .kb-sheet[open] .kb-sheet-panel, #loading:not(.hidden), .pd-status::before { animation: none !important; }
.kb-header, .kb-nav, .kb-logo, .kb-app-title, .kb-sheet-panel, .kb-details-chev { transition: none !important; }
}
/* ------------------------------------------------ Ansicht "Verträge" (Aufgabe 15) --- */
#kb-contracts { display: grid; gap: 16px; margin-top: 16px; }
#kb-contracts > .kb-panel + .kb-panel, #kb-contracts .kb-compare .kb-panel + .kb-panel { margin-top: 0; }
#kb-abo-slot:has(> .kb-abo:not(.hidden)) + #kb-contracts { margin-top: 16px; }
.kb-contract-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 6px; }
.kb-contract-head h2 { margin: 0; font-size: 20px; }
#contract-card h3 { margin: 4px 0 6px; }
.kb-status-ok { color: #86efac; background: rgba(74, 222, 128, 0.1); border: 1px solid rgba(74, 222, 128, 0.4); }
.kb-status-warn { color: #fcd34d; background: rgba(251, 191, 36, 0.1); border: 1px solid rgba(251, 191, 36, 0.4); }
.kb-contract-facts { margin: 14px 0; display: grid; gap: 10px; }
.kb-contract-facts div { display: grid; grid-template-columns: 9.5em minmax(0, 1fr); gap: 12px; font-size: 14px; }
.kb-contract-facts dt { color: var(--kb-muted); }
.kb-contract-facts dd { margin: 0; }
.kb-contract-actions { display: grid; gap: 8px; margin-top: 12px; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); }
.kb-contract-actions .kb-side-btn { margin-top: 0; text-align: center; }
.kb-withdraw { margin-top: 12px; border-color: rgba(251, 191, 36, 0.65); background: rgba(251, 191, 36, 0.12); }
.kb-compare { display: grid; gap: 16px; grid-template-columns: repeat(2, minmax(0, 1fr)); }
.kb-compare .abo-features { grid-template-columns: 1fr; }
.kb-switch-card .kb-btn-primary { display: inline-block; text-decoration: none; }
@media (max-width: 880px), (pointer: coarse) and (max-height: 500px) {
.kb-compare { grid-template-columns: 1fr; }
.kb-contract-facts div { grid-template-columns: 1fr; gap: 2px; }
#view-vertraege .kb-abo.kb-abo-offer { display: none; }
#kb-contracts { margin-top: 16px; }
}
#storage-panel:empty { display: none; }