diff --git a/public/app.js b/public/app.js index 329d373..76c883d 100644 --- a/public/app.js +++ b/public/app.js @@ -1027,9 +1027,152 @@ function renderSideCard(card, data) { else renderAboSideOffer(card, data, { ended: Boolean(data.subscription) }) } -/* Zusatzspeicher: Hülle, gefüllt in Aufgabe 17 */ +/* Zusatzspeicher: Plus/Minus mit Vorschau vom Server, Bestellübersicht (§ 312j BGB) und Speichern */ +const storageUi = { notice: '' } + function renderStoragePanel(container, data) { - container.dataset.units = String(data?.subscription?.storage?.units ?? 0) + const sub = data?.subscription + const conf = data?.mailStorage || {} + container.dataset.units = String(sub?.storage?.units ?? 0) + if (!sub?.active) { + container.innerHTML = '' + return + } + const includedGb = Number(conf.includedGb) || 0 + const gbPer = Number(conf.gbPerUnit) || 0 + const unitCents = Number(conf.unitPriceCents) || 0 + const maxUnits = Number(conf.maxUnits) || 10 + const gbOf = (u) => includedGb + u * gbPer + const storage = sub.storage || {} + const pendingReduction = storage.pendingReduction || null + 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 notice = storageUi.notice + storageUi.notice = '' + + const paint = () => { + const delta = state.target - start + const p = state.preview + let summary = '' + if (canChange && delta !== 0) { + const plus = delta > 0 + const totalCents = state.target * unitCents + const facts = [] + facts.push(`
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 Regeln zum Zusatzspeicher und in der Widerrufsbelehrung.
` + summary = ` +Ihr zusätzlicher Speicher wird eingerichtet. Das dauert in der Regel bis zum nächsten Werktag.
') + if (pendingReduction) status.push(`Geplant: ab ${fmtDayMonth(pendingReduction.effectiveAt)} ${gbOf(Number(pendingReduction.units) || 0)} GB.
`) + container.innerHTML = ` +${includedGb} GB inklusive. Jedes Zusatzpaket bringt ${gbPer} GB für ${fmtEuro(unitCents / 100)} im Monat (gemäß § 19 UStG ohne Umsatzsteuer), bis zu ${maxUnits} Pakete.
+ ${notice ? `${escapeHtml(notice)}
` : ''} + ${canChange ? ` +${state.target} Paket${state.target === 1 ? '' : 'e'}
`} += ${gbOf(state.target)} GB gesamt · + ${fmtEuro(state.target * unitCents / 100)} im Monat
+ ${status.join('')} + ${summary}` + container.querySelector('#storage-minus')?.addEventListener('click', () => step(-1)) + container.querySelector('#storage-plus')?.addEventListener('click', () => step(1)) + 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 + try { + const p = await api('/api/abo/storage/preview', { method: 'POST', body: JSON.stringify({ units: next }) }) + if (token !== state.token) return + state.preview = p + } catch (err) { + if (token !== state.token) return + state.previewError = err.message + } + state.loading = false + paint() + } + + const submit = async () => { + const errEl = container.querySelector('#storage-error') + hideError(errEl) + const plus = state.target > start + const terms = container.querySelector('#storage-terms') + if (plus && !terms?.checked) { + showError(errEl, 'Bitte stimmen Sie den Regeln zum Zusatzspeicher zu.') + return + } + const body = { units: state.target } + if (plus) Object.assign(body, { acceptTerms: true, termsVersion: data.termsVersion }) + const btn = container.querySelector('#storage-submit') + state.busy = true + btn.disabled = true + container.querySelectorAll('#storage-minus, #storage-plus, #storage-terms').forEach((el) => { el.disabled = true }) + btn.textContent = 'Wird gespeichert …' + try { + const res = await api('/api/abo/storage', { method: 'POST', body: JSON.stringify(body) }) + const ch = res.change || {} + storageUi.notice = plus + ? (ch.task === false ? 'Gespeichert. Es entstehen keine zusätzlichen Kosten, die Pakete waren schon bezahlt.' : 'Danke, Ihre Bestellung ist eingegangen. Der Speicher wird in der Regel innerhalb eines Werktags eingerichtet.') + : `Gespeichert. Die Verkleinerung wirkt zum ${fmtDateShort(ch.effectiveAt)}.` + if (res.subscription && aboState.data) { + aboState.data.subscription = res.subscription + const card = document.getElementById('abo-card') + const contracts = document.getElementById('kb-contracts') + if (card) renderSideCard(card, aboState.data) + if (contracts) renderContractsView(contracts, aboState.data) + return + } + loadContracts() + } catch (err) { + state.busy = false + paint() + const again = container.querySelector('#storage-error') + showError(again, err.status === 409 ? `${err.message} Bitte laden Sie die Seite neu, falls sich der Stand geändert hat.` : err.message) + if (plus) { const c = container.querySelector('#storage-terms'); if (c) c.checked = true } + } + } + + paint() } /* "Postfach anfragen": öffnet den Chat mit vorbereiteter Nachricht (Eingabefeld wird nicht automatisch gesendet) */ diff --git a/public/kundenbereich.css b/public/kundenbereich.css index 4441646..b4c75d4 100644 --- a/public/kundenbereich.css +++ b/public/kundenbereich.css @@ -837,3 +837,12 @@ button.kb-avatar:focus-visible { outline: 2px solid #fff; outline-offset: 2px; } #kb-contracts { margin-top: 16px; } } #storage-panel:empty { display: none; } +.kb-storage-stepper { display: flex; align-items: center; gap: 14px; margin: 14px 0 6px; } +.kb-storage-stepper .kb-side-btn { width: 44px; height: 44px; padding: 0; font-size: 22px; line-height: 1; justify-content: center; } +.kb-storage-stepper .kb-side-btn:disabled { opacity: 0.4; cursor: default; } +.kb-storage-count { font-size: 15px; min-width: 6em; text-align: center; } +.kb-storage-stepper + .kb-storage-total, .kb-storage-total { margin: 0; font-size: 14px; color: var(--kb-muted); } +.kb-storage-summary { margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--kb-border, rgba(127, 127, 127, 0.25)); } +.kb-storage-summary .kb-btn-primary { width: 100%; } +.kb-storage-error { color: #b3261e; } +@media (min-width: 881px) { .kb-storage-summary .kb-btn-primary { width: auto; } }