diff --git a/public/app.js b/public/app.js
index 76c883d..64f8095 100644
--- a/public/app.js
+++ b/public/app.js
@@ -979,6 +979,160 @@ function renderOrderView(view, data, productKey) {
})
}
+/* Wechsel ins Wartungs-Abo (#wechsel): Vorschau vom Server, Bestellübersicht direkt über dem Knopf (§ 312j BGB), Zustimmung, Bestellung.
+ Der Wechsel wirkt erst mit der Zahlung der Wechselrechnung (Bedingungen § 9 Abs. 3). */
+const switchUi = { notice: '', loadedAt: 0, busy: false }
+
+function renderSwitchView(view, data) {
+ const sub = data.subscription
+ const wartung = productOf(data, 'wartung')
+ const head = ''
+ if (!sub?.active || sub.productKey !== 'hosting' || !sub.canSwitch || !wartung) {
+ switchUi.loadedAt = 0
+ view.innerHTML = `${head}
Ein Wechsel ist derzeit nicht möglich. Details finden Sie unter Verträge .
`
+ if (view.classList.contains('active')) showView('vertraege')
+ return
+ }
+ const units = Number(sub.storage?.units) || 0
+ const mail = data.mailStorage || {}
+ const unitCents = Number(mail.unitPriceCents) || 0
+ const state = { preview: null, error: '', loading: true, token: 0 }
+
+ const paint = () => {
+ const p = state.preview
+ const rows = []
+ if (p) {
+ const monthly = wartung.amountCents
+ rows.push(`Wartungs-Abo ${escapeHtml(fmtEuro(monthly / 100))} pro Monat (Gesamtpreis, gemäß § 19 UStG ohne Umsatzsteuer); enthalten sind Pflege Ihrer Website, beliebig viele Postfächer und 1 Stunde Arbeitszeit pro Monat `)
+ if (units > 0) {
+ rows.push(`Zusatzspeicher ${units} Paket${units === 1 ? '' : 'e'} (+${escapeHtml(String(units * (Number(mail.gbPerUnit) || 0)))} GB) bleiben gebucht: ${escapeHtml(fmtEuro(units * unitCents / 100))} pro Monat zusätzlich (gemäß § 19 UStG ohne Umsatzsteuer), insgesamt ${escapeHtml(fmtEuro((monthly + units * unitCents) / 100))} pro Monat `)
+ }
+ if ((Number(p.creditCents) || 0) > 0) {
+ rows.push(`Gutschrift für nicht genutzte Tage −${escapeHtml(fmtEuro(p.creditCents / 100))} aus dem bereits bezahlten Hosting-Abo (gemäß § 19 UStG ohne Umsatzsteuer) `)
+ }
+ rows.push(`Heute fällig ${escapeHtml(fmtEuro(p.amountDueCents / 100))} (gemäß § 19 UStG ohne Umsatzsteuer) `)
+ rows.push(`Neuer Abbuchungstag Ab dem Wechsel jeweils am ${escapeHtml(String(new Date(p.nextChargeAt).toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin', day: 'numeric' })))}. eines Monats; nächste Abbuchung am ${fmtDateShort(p.nextChargeAt)} `)
+ rows.push(`Laufzeit Mindestlaufzeit bis ${fmtDateShort(p.minTermEnd)} (12 Monate ab dem Wechsel), danach monatlich kündbar `)
+ rows.push('Beginn Der Wechsel wird erst mit der Zahlung wirksam. Bei Kreditkarte, sobald die Zahlung erfolgreich ist (ggf. nach Bestätigung bei Ihrer Bank); bei SEPA-Lastschrift sofort mit der Bestellung, wird die Lastschrift nicht eingelöst, gilt der Wechsel als nicht erfolgt. Einen späteren Beginn gibt es nicht. ')
+ }
+ const summary = state.error
+ ? `${escapeHtml(state.error)}
`
+ : state.loading || !p ? 'Ihre Übersicht wird berechnet …
'
+ : ``
+ const checked = (id) => view.querySelector(id)?.checked ? ' checked' : ''
+ const prevErr = view.querySelector('#switch-error')
+ const errText = prevErr && !prevErr.classList.contains('hidden') ? prevErr.textContent : ''
+ const busy = switchUi.busy
+ view.innerHTML = `${head}
+
+
Ihre Bestellung
+
Zusätzlich zum Hosting: Pflege Ihrer Website, beliebig viele Postfächer und 1 Stunde Arbeitszeit pro Monat. Das Hosting-Abo endet mit dem Wechsel im gegenseitigen Einvernehmen; Domain, Postfächer und gebuchter Speicher bleiben.
+
+ Ich habe die Vertragsbedingungen mit Leistungsbeschreibung und Auftragsverarbeitungsvertrag gelesen und stimme ihnen zu. Die Widerrufsbelehrung habe ich zur Kenntnis genommen.
+
+ Sofortbeginn. Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit dem Wartungs-Abo beginnt. Mir ist bekannt, dass ich bei einem Widerruf einen angemessenen Betrag für die bis dahin erbrachten Leistungen zahle (§ 357a Abs. 2 BGB; siehe Hinweise zum Widerruf). Firmenkunden haben kein Widerrufsrecht.
+
+
${escapeHtml(errText)}
+
+ ${busy ? 'Wird bestellt …' : 'Zahlungspflichtig bestellen'}
+ Bezahlt wird mit der bei Stripe hinterlegten Zahlungsart. Bis zum Klick können Sie abbrechen.
+
+
`
+ view.querySelector('#switch-buy')?.addEventListener('click', submit)
+ }
+
+ const loadPreview = async () => {
+ const token = ++state.token
+ state.loading = true
+ state.error = ''
+ paint()
+ try {
+ const p = await api('/api/abo/switch/preview', { method: 'POST', body: '{}' })
+ if (token !== state.token) return
+ state.preview = p
+ switchUi.loadedAt = Date.now()
+ } catch (err) {
+ if (token !== state.token) return
+ state.preview = null
+ state.error = err.message
+ }
+ state.loading = false
+ paint()
+ }
+
+ const submit = async () => {
+ if (switchUi.busy || !state.preview) return
+ const errEl = view.querySelector('#switch-error')
+ hideError(errEl)
+ if (!view.querySelector('#switch-terms').checked) {
+ showError(errEl, 'Bitte stimmen Sie den Vertragsbedingungen zu.')
+ return
+ }
+ if (!view.querySelector('#switch-early').checked) {
+ showError(errEl, 'Bitte bestätigen Sie den Sofortbeginn, sonst können wir den Wechsel nicht ausführen.')
+ return
+ }
+ const p = state.preview
+ switchUi.busy = true
+ paint()
+ try {
+ const res = await api('/api/abo/switch', {
+ method: 'POST',
+ body: JSON.stringify({ acceptTerms: true, earlyStart: true, termsVersion: data.termsVersion, prorationDate: p.prorationDate, amountDueCents: p.amountDueCents }),
+ })
+ if (res.paymentUrl) {
+ view.querySelector('#switch-buy').textContent = 'Weiter zur Zahlung …'
+ view.querySelector('#switch-error').classList.remove('hidden')
+ view.querySelector('#switch-error').classList.remove('error')
+ view.querySelector('#switch-error').textContent = 'Ihr Wechsel wartet auf die Zahlung. Wir leiten Sie zur Zahlung weiter.'
+ window.location.href = res.paymentUrl
+ return
+ }
+ switchUi.busy = false
+ const sepa = Boolean(res.subscription?.switchPending)
+ switchUi.notice = sepa
+ ? 'Ihr Wechsel ins Wartungs-Abo gilt ab sofort. Die Lastschrift wird noch eingezogen; wir bestätigen Ihnen den Wechsel per E-Mail.'
+ : 'Ihr Wechsel ins Wartungs-Abo ist abgeschlossen. Wir bestätigen ihn per E-Mail.'
+ if (res.subscription && aboState.data) aboState.data.subscription = res.subscription
+ const card = document.getElementById('abo-card')
+ const contracts = document.getElementById('kb-contracts')
+ if (aboState.data) {
+ if (card) renderSideCard(card, aboState.data)
+ if (contracts) renderContractsView(contracts, aboState.data)
+ renderSwitchView(view, aboState.data)
+ }
+ showView('vertraege')
+ contracts?.insertAdjacentHTML('afterbegin', `${escapeHtml(switchUi.notice)}
`)
+ switchUi.notice = ''
+ } catch (err) {
+ switchUi.busy = false
+ paint()
+ showError(view.querySelector('#switch-error'), err.message)
+ // Betrag oder Stichtag haben sich geändert: neue Vorschau laden, Fehlerhinweis bleibt stehen
+ if (err.status === 409) {
+ const msg = err.message
+ await loadPreview()
+ showError(view.querySelector('#switch-error'), msg)
+ }
+ }
+ }
+
+ view._switchReload = () => { if (!switchUi.busy) loadPreview() }
+ loadPreview()
+}
+
+/* Wer #wechsel öffnet, ohne wechseln zu können, landet bei "Verträge"; eine alte Vorschau (> 5 Minuten) wird neu berechnet */
+document.addEventListener('kb:view', (ev) => {
+ if (ev.detail.name !== 'wechsel') return
+ const view = document.getElementById('view-wechsel')
+ const sub = aboState.data?.subscription
+ if (aboState.data && (!sub?.active || sub.productKey !== 'hosting' || !sub.canSwitch)) { showView('vertraege'); return }
+ if (view?._switchReload && Date.now() - switchUi.loadedAt > 5 * 60 * 1000) view._switchReload()
+})
+
/* Ansicht "Wartungs-Abo": Stundenkonto, Verlauf, Vertrag (Bedingungen § 4: jederzeit im Portal einsehbar) */
function renderHoursView(view, data) {
const sub = data.subscription
@@ -1312,6 +1466,8 @@ async function loadContracts({ afterCheckout = false } = {}) {
const orderView = document.getElementById(`view-bestellen-${key}`)
if (orderView) renderOrderView(orderView, data, key)
}
+ const switchView = document.getElementById('view-wechsel')
+ if (switchView) renderSwitchView(switchView, data)
if (data.subscription?.active && data.subscription.productKey === 'wartung') renderHoursView(view, data)
else view.innerHTML = 'Ein Stundenkonto gibt es im Wartungs-Abo. Ihre Verträge finden Sie unter Verträge .
'
card.classList.remove('hidden')
diff --git a/public/kundenbereich.css b/public/kundenbereich.css
index b4c75d4..ef3a821 100644
--- a/public/kundenbereich.css
+++ b/public/kundenbereich.css
@@ -745,8 +745,8 @@ button.kb-avatar:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
.abo-summary td:first-child {
margin-bottom: 3px; font-size: 10.5px; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; color: var(--kb-faint);
}
- #abo-order .kb-actions { flex-direction: column; align-items: stretch; }
- #abo-order .kb-btn-primary { padding: 15px 22px; font-size: 15px; }
+ #abo-order .kb-actions, #switch-order .kb-actions { flex-direction: column; align-items: stretch; }
+ #abo-order .kb-btn-primary, #switch-order .kb-btn-primary { padding: 15px 22px; font-size: 15px; }
.abo-actions .abo-btn { flex: 1 1 auto; }
.kb-legal { justify-content: center; gap: 10px 16px; margin-top: 32px; text-align: center; }