Oberfläche: Speicher-Stepper bei geplanter Reduzierung, vollständige Bestellübersicht, Hinweis bei gesperrten Änderungen

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-09-29 15:37:03 +00:00
1 parent f7eeda8c19
commit 05a59c4d61
1 file changed
+60 -22
+60 -22
View File
@@ -890,6 +890,13 @@ function renderOrderView(view, data, productKey) {
const price = fmtEuro(product.amountCents / 100)
const lateStart = data.lateStartAt ? fmtDateShort(data.lateStartAt) : ''
const isWartung = productKey === 'wartung'
// Betrag der ersten Abbuchung: Angebotspreis während des Rabatts, sonst Katalogpreis
const offerMonthly = Number(offer?.monthlyPriceCents)
const offerRegular = Number(offer?.regularPriceCents) || product.amountCents
const offerMonths = Number(offer?.months)
const hasOfferNumbers = Boolean(offer) && Number.isFinite(offerMonthly) && offer.monthlyPriceCents != null && offerMonths > 0
const firstCents = hasOfferNumbers ? offerMonthly : product.amountCents
const firstAmount = fmtEuro(firstCents / 100)
view.innerHTML = `
<header class="kb-head">
<h1>${escapeHtml(label)}</h1>
@@ -925,9 +932,10 @@ function renderOrderView(view, data, productKey) {
<table class="abo-summary">
<tr><td>Leistung</td><td>${escapeHtml(product.name)}: ${(product.features || []).map(([t]) => escapeHtml(t)).join(', ')}</td></tr>
<tr><td>Preis</td><td>${offer
? `<strong>${escapeHtml(offer.text)}</strong> (individuelles Angebot; Gesamtpreis, gemäß § 19 UStG ohne Umsatzsteuer), keine weiteren Kosten`
? `${hasOfferNumbers ? `<strong>${escapeHtml(fmtEuro(offerMonthly / 100))} pro Monat für die ersten ${escapeHtml(String(offerMonths))} Monate</strong>, danach ${escapeHtml(fmtEuro(offerRegular / 100))} pro Monat` : ''} <span>(individuelles Angebot: ${escapeHtml(offer.text)}; Gesamtpreis, gemäß § 19 UStG ohne Umsatzsteuer)</span>, keine weiteren Kosten`
: `<strong>${escapeHtml(price)} pro Monat</strong> (Gesamtpreis, gemäß § 19 UStG ohne Umsatzsteuer), keine weiteren Kosten`}</td></tr>
<tr><td>Abrechnung</td><td>monatlich im Voraus, erstmals zum Leistungsbeginn</td></tr>
<tr><td>Fällig</td><td id="abo-due-summary">Bitte oben wählen: sofort oder nach Ablauf der 14-tägigen Widerrufsfrist</td></tr>
<tr><td>Leistungsbeginn</td><td id="abo-start-summary">Bitte oben wählen: sofort oder nach Ablauf der 14-tägigen Widerrufsfrist${lateStart ? ` (am ${lateStart})` : ''}</td></tr>
<tr><td>Laufzeit</td><td><strong>Mindestlaufzeit ${escapeHtml(String(product.minTermMonths))} Monate</strong> ab Leistungsbeginn, kündbar zum Ende der Mindestlaufzeit bis einen Monat vorher; danach unbefristet und jederzeit zum Ende des Abrechnungsmonats kündbar</td></tr>
<tr><td>Zahlung</td><td>Kreditkarte oder SEPA-Lastschrift über Stripe</td></tr>
@@ -944,6 +952,10 @@ function renderOrderView(view, data, productKey) {
view.querySelectorAll('input[name="abo-start-choice"]').forEach((radio) => radio.addEventListener('change', () => {
const el = view.querySelector('#abo-start-summary')
const due = view.querySelector('#abo-due-summary')
if (due) due.textContent = radio.value === 'sofort'
? `Heute fällig: ${firstAmount} (gemäß § 19 UStG ohne Umsatzsteuer)`
: `Erste Abbuchung am ${lateStart || 'Ende der 14-tägigen Widerrufsfrist'}: ${firstAmount} (gemäß § 19 UStG ohne Umsatzsteuer); heute wird nichts abgebucht`
if (el) el.textContent = radio.value === 'sofort'
? 'sofort, auf Ihren ausdrücklichen Wunsch vor Ablauf der Widerrufsfrist'
: `nach Ablauf der 14-tägigen Widerrufsfrist${lateStart ? ` (am ${lateStart})` : ''}; bis dahin keine Leistung und keine Abbuchung`
@@ -982,6 +994,13 @@ 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 }
// Zurück aus dem Bfcache (z. B. von der Zahlungsseite): eine hängende Bestellung nicht gesperrt lassen
window.addEventListener('pageshow', () => {
if (!switchUi.busy) return
switchUi.busy = false
const view = document.getElementById('view-wechsel')
if (view && aboState.data) renderSwitchView(view, aboState.data)
})
function renderSwitchView(view, data) {
const sub = data.subscription
@@ -1002,7 +1021,7 @@ function renderSwitchView(view, data) {
const p = state.preview
const rows = []
if (p) {
const monthly = wartung.amountCents
const monthly = Number.isFinite(Number(p.monthlyCents)) && p.monthlyCents != null ? Number(p.monthlyCents) : wartung.amountCents
rows.push(`<tr><td>Wartungs-Abo</td><td><strong>${escapeHtml(fmtEuro(monthly / 100))} pro Monat</strong> (Gesamtpreis, gemäß § 19 UStG ohne Umsatzsteuer); enthalten sind Pflege Ihrer Website, beliebig viele Postfächer und 1 Stunde Arbeitszeit pro Monat</td></tr>`)
if (units > 0) {
rows.push(`<tr><td>Zusatzspeicher</td><td>${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</td></tr>`)
@@ -1011,7 +1030,9 @@ function renderSwitchView(view, data) {
rows.push(`<tr><td>Gutschrift für nicht genutzte Tage</td><td>−${escapeHtml(fmtEuro(p.creditCents / 100))} aus dem bereits bezahlten Hosting-Abo (gemäß § 19 UStG ohne Umsatzsteuer)</td></tr>`)
}
rows.push(`<tr><td>Heute fällig</td><td><strong>${escapeHtml(fmtEuro(p.amountDueCents / 100))}</strong> (gemäß § 19 UStG ohne Umsatzsteuer)</td></tr>`)
rows.push(`<tr><td>Neuer Abbuchungstag</td><td>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)}</td></tr>`)
const nd = new Date(p.nextChargeAt)
const nextDay = Number.isNaN(nd.getTime()) ? '' : nd.toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin', day: 'numeric' })
rows.push(`<tr><td>Neuer Abbuchungstag</td><td>${nextDay ? `Ab dem Wechsel jeweils am ${escapeHtml(nextDay)}. eines Monats; ` : ''}nächste Abbuchung am ${fmtDateShort(p.nextChargeAt)}</td></tr>`)
rows.push(`<tr><td>Laufzeit</td><td><strong>Mindestlaufzeit bis ${fmtDateShort(p.minTermEnd)}</strong> (12 Monate ab dem Wechsel), danach monatlich kündbar</td></tr>`)
rows.push('<tr><td>Beginn</td><td>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.</td></tr>')
}
@@ -1202,22 +1223,31 @@ function renderStoragePanel(container, data) {
const canChange = sub.canChangeStorage === true
const current = Number(storage.units || 0)
const start = pendingReduction && Number.isInteger(pendingReduction.units) ? pendingReduction.units : current
const state = { target: start, preview: null, previewError: '', loading: false, busy: false, error: '', token: 0 }
const state = { target: start, preview: null, previewError: '', loading: start !== current, busy: false, error: '', token: 0 }
const lockedReason = sub.switchPending ? 'Wechsel wartet auf die Zahlung'
: aboIsCancelled(sub) ? 'Abo gekündigt'
: sub.paymentProblem ? 'Zahlung offen' : 'bei Fragen schreiben Sie uns gern im Chat'
const notice = storageUi.notice
storageUi.notice = ''
const paint = () => {
const delta = state.target - start
// Kosten, Beschriftung und Zustimmung richten sich nach den gebuchten Paketen; start ist nur die Anfangsstellung
const delta = state.target - current
const p = state.preview
let summary = ''
if (canChange && delta !== 0) {
if (canChange && (delta !== 0 || pendingReduction)) {
const plus = delta > 0
const same = delta === 0
const totalCents = state.target * unitCents
const facts = []
const proBad = plus && p && !state.loading && !(p.prorationCents != null && p.prorationCents !== '' && Number.isFinite(Number(p.prorationCents)))
if (same) {
facts.push(`<div><dt>Änderung</dt><dd>geplante Reduzierung aufheben: Sie behalten ${current} Paket${current === 1 ? '' : 'e'} (${gbOf(current)} GB), es entstehen keine Kosten</dd></div>`)
} else {
facts.push(`<div><dt>Neue Gesamtgröße</dt><dd>${gbOf(state.target)} GB</dd></div>`)
facts.push(`<div><dt>Preis</dt><dd>${fmtEuro(totalCents / 100)} pro Monat für ${state.target} Paket${state.target === 1 ? '' : 'e'} (${plus ? 'Mehrpreis' : 'Ersparnis'} ${fmtEuro(Math.abs(delta) * unitCents / 100)} pro Monat; Gesamtpreis, gemäß § 19 UStG ohne Umsatzsteuer)</dd></div>`)
if (state.previewError) {
facts.push(`<div><dt>Vorschau</dt><dd class="kb-storage-error">${escapeHtml(state.previewError)}</dd></div>`)
if (state.previewError || proBad) {
facts.push(`<div><dt>Vorschau</dt><dd class="kb-storage-error">${escapeHtml(state.previewError || 'Der Betrag konnte nicht berechnet werden. Bitte versuchen Sie es später erneut.')}</dd></div>`)
} else if (!p || state.loading) {
facts.push('<div><dt>Vorschau</dt><dd>wird berechnet …</dd></div>')
} else if (plus) {
@@ -1231,7 +1261,8 @@ function renderStoragePanel(container, data) {
facts.push(`<div><dt>Wirkt zum</dt><dd>${fmtDateShort(p.effectiveAt)}, zum Ende des Abrechnungsmonats</dd></div>`)
}
facts.push('<div><dt>Laufzeit</dt><dd>monatlich, zum Ende jedes Abrechnungsmonats kündbar, endet spätestens mit dem Abo</dd></div>')
const hint = plus
}
const hint = same ? '' : plus
? `<label class="abo-check"><input type="checkbox" id="storage-terms"${state.busy ? ' disabled' : ''}>
<span>Ich habe die <a href="/abo-bedingungen.html#zusatzspeicher" target="_blank" rel="noopener">Regeln zum Zusatzspeicher</a> gelesen und stimme ihnen zu. Die <a href="/abo-bedingungen.html#widerruf" target="_blank" rel="noopener">Widerrufsbelehrung</a> habe ich zur Kenntnis genommen.</span></label>`
: `<p class="abo-alert" style="margin:10px 0">Wichtig: Passen die gespeicherten E-Mails nach der Verkleinerung nicht mehr in den verbleibenden Speicher, nehmen die Postfächer keine neuen E-Mails mehr an. Bitte räumen Sie vorher auf. Mehr dazu in den <a href="/abo-bedingungen.html#zusatzspeicher" target="_blank" rel="noopener">Regeln zum Zusatzspeicher</a> und in der <a href="/abo-bedingungen.html#widerruf" target="_blank" rel="noopener">Widerrufsbelehrung</a>.</p>`
@@ -1240,7 +1271,7 @@ function renderStoragePanel(container, data) {
<dl class="kb-contract-facts">${facts.join('')}</dl>
${hint}
<p class="error hidden" id="storage-error"></p>
<button type="button" class="kb-btn-primary" id="storage-submit"${state.busy || state.loading || !p || state.previewError ? ' disabled' : ''}>${state.busy ? 'Wird gespeichert …' : plus ? 'Zahlungspflichtig bestellen' : 'Speichern'}</button>
<button type="button" class="kb-btn-primary" id="storage-submit"${state.busy || (!same && (state.loading || !p || state.previewError || proBad)) ? ' disabled' : ''}>${state.busy ? 'Wird gespeichert …' : plus ? 'Zahlungspflichtig bestellen' : 'Speichern'}</button>
</div>`
}
const status = []
@@ -1250,6 +1281,7 @@ function renderStoragePanel(container, data) {
<h3>E-Mail-Speicher</h3>
<p class="abo-note" style="margin-top:0">${includedGb} GB inklusive. Jedes Zusatzpaket bringt ${gbPer} GB für ${fmtEuro(unitCents / 100)} im Monat (gemäß § 19 UStG ohne Umsatzsteuer), bis zu ${maxUnits} Pakete.</p>
${notice ? `<p class="abo-alert kb-storage-notice" role="status">${escapeHtml(notice)}</p>` : ''}
${!canChange ? `<p class="abo-note kb-storage-locked" id="storage-locked">Änderungen sind derzeit nicht möglich – ${lockedReason}.</p>` : ''}
${canChange ? `
<div class="kb-storage-stepper">
<button type="button" class="kb-side-btn" id="storage-minus" aria-label="Ein Paket weniger"${state.busy || state.target <= 0 ? ' disabled' : ''}>−</button>
@@ -1264,18 +1296,9 @@ function renderStoragePanel(container, data) {
container.querySelector('#storage-submit')?.addEventListener('click', submit)
}
const step = async (d) => {
const next = state.target + d
if (state.busy || next < 0 || next > maxUnits) return
state.target = next
state.preview = null
state.previewError = ''
state.loading = next !== start
const token = ++state.token
paint()
if (next === start) return
const loadPreview = async (token) => {
try {
const p = await api('/api/abo/storage/preview', { method: 'POST', body: JSON.stringify({ units: next }) })
const p = await api('/api/abo/storage/preview', { method: 'POST', body: JSON.stringify({ units: state.target }) })
if (token !== state.token) return
state.preview = p
} catch (err) {
@@ -1286,10 +1309,22 @@ function renderStoragePanel(container, data) {
paint()
}
const step = (d) => {
const next = state.target + d
if (state.busy || next < 0 || next > maxUnits) return
state.target = next
state.preview = null
state.previewError = ''
state.loading = next !== current
const token = ++state.token
paint()
if (next !== current) loadPreview(token)
}
const submit = async () => {
const errEl = container.querySelector('#storage-error')
hideError(errEl)
const plus = state.target > start
const plus = state.target > current
const terms = container.querySelector('#storage-terms')
if (plus && !terms?.checked) {
showError(errEl, 'Bitte stimmen Sie den Regeln zum Zusatzspeicher zu.')
@@ -1327,6 +1362,9 @@ function renderStoragePanel(container, data) {
}
paint()
if (start !== current && canChange) {
loadPreview(++state.token)
}
}
/* "Postfach anfragen": öffnet den Chat mit vorbereiteter Nachricht (Eingabefeld wird nicht automatisch gesendet) */