Files
Webklar-Kundenbereich/public/neu/ansicht-speicher.js
T
ClaudeandClaude Sonnet 5.5 4c64edc057 Kundenansicht neu E-Fix M3: Fehlermeldungen der Bestellseiten und Formulare als Live-Region (role="alert")
abo-error, switch-error (und der Fehler der Wechsel-Vorschau), storage-error, tk-error samt Fehlerzeile der Rückfrage-Antwort, der Fehler von
"Zahlungsart aktualisieren" sowie login-error und form-error auf Login, Kündigen und Widerrufen waren einfache Absätze: Screenreader lasen
sie nicht vor, obwohl der Knopf wieder frei wurde. Jetzt role="alert", gepinnt in test/knEfixCss.test.js.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 21:42:07 +00:00

195 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Kundenansicht neu (P7): Reiter E-Mail in Verträge, der Zusatzspeicher, im schlanken Muster der Spec 5.6, Fassung 6.
Plus: Block „Was du bestellst“, eine Linkzeile zu den Regeln, der Knopf „Zahlungspflichtig bestellen“ (§ 312j BGB), darunter die
Kundenart-Zeile; kein Häkchen (der Klick auf den Knopf ist die Zustimmung). Minus: genau eine Warnzeile und der Knopf „Speichern“.
Die Sätze kommen vom Server (GET /api/abo → bestelltexte). Der Server entscheidet die Kundenart selbst, der Client sendet nie kundenart. */
import { api } from './api.js'
import { h, leeren } from './dom.js'
import { K, ersetze, fmtEuro, fmtDatum, fmtTagMonat, ladeAbo, meldeZaehlerNeu, navigiere, setzeChatVorgabe, ABO_NICHT_GELADEN } from './ansicht-hilfen.js'
import { baueBlock, baueKundenartZeile, baueLinkzeile, hatWortlaut, kundenartVon } from './bestell-bausteine.js'
const laeufe = new WeakMap() // Reiter-Inhalt → Nummer des jüngsten Aufrufs
const TEXT_ANSICHTSMODUS = 'Im Ansichtsmodus nicht möglich.'
const TEXT_NICHT_VERFUEGBAR = 'Die Online-Bestellung wird gerade eingerichtet – schreib uns gern im Chat.'
const KNOPF = 'Zahlungspflichtig bestellen'
const paket = (n) => `Paket${n === 1 ? '' : 'e'}`
// Der Zusatzspeicher: Plus/Minus mit Vorschau vom Server, Bestellübersicht (§ 312j BGB) und Speichern
function baueSpeicherPanel(daten, { viewAs, kundenart, notice, nachErfolg }) {
const sub = daten.subscription
const T = daten.bestelltexte
const wortlaut = hatWortlaut(daten, 'speicher')
const conf = daten.mailStorage || {}
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: start !== current, busy: false, error: '', token: 0 }
const gekuendigt = Boolean(sub.cancelAtPeriodEnd || sub.cancelAt)
const lockedReason = sub.switchPending ? 'Wechsel wartet auf die Zahlung'
: gekuendigt ? 'Abo gekündigt'
: sub.paymentProblem ? 'Zahlung offen' : 'bei Fragen schreib uns gern im Chat'
const panel = h('div', { class: 'kn-panel', 'data-panel': 'speicher' })
let fehlerText = ''
const paint = () => {
const delta = state.target - current
const p = state.preview
const sperre = viewAs || state.busy
let summary = null
if (canChange && delta !== 0) {
const plus = delta > 0
if (!wortlaut) {
// Ohne Wortlaut vom Server (Linkzeile beim Plus, Warnzeile beim Minus) gibt es keine Änderung: nur der Satz, kein Knopf
summary = h('div', { class: 'kn-speicher-zusammenfassung' }, h('p', { class: 'kn-hinweis' }, TEXT_NICHT_VERFUEGBAR))
} else {
const totalCents = state.target * unitCents
const zeilen = []
const proBad = plus && p && !state.loading && !(p.prorationCents != null && p.prorationCents !== '' && Number.isFinite(Number(p.prorationCents)))
zeilen.push({ label: 'Neue Gesamtgröße', text: `${gbOf(state.target)} GB` })
zeilen.push({ label: 'Preis', text: `${fmtEuro(totalCents / 100)} pro Monat für ${state.target} ${paket(state.target)} (${plus ? 'Mehrpreis' : 'Ersparnis'} ${fmtEuro(Math.abs(delta) * unitCents / 100)} pro Monat; Gesamtpreis, gemäß § 19 UStG ohne Umsatzsteuer)` })
if (state.previewError || proBad) {
zeilen.push({ label: 'Vorschau', text: state.previewError || 'Der Betrag konnte nicht berechnet werden. Bitte versuche es später erneut.', klasse: 'kn-fehler' })
} else if (!p || state.loading) {
zeilen.push({ label: 'Vorschau', text: 'wird berechnet …' })
} else if (plus) {
const pro = Number(p.prorationCents) || 0
zeilen.push({ label: 'Zusätzlich jetzt', text: pro > 0
? `${fmtEuro(pro / 100)} anteilig für den Rest des laufenden Abrechnungsmonats (gemäß § 19 UStG ohne Umsatzsteuer)`
: 'keine zusätzlichen Kosten, die Pakete sind für den laufenden Abrechnungsmonat schon bezahlt' })
zeilen.push({ label: 'Nächste Abbuchung', text: `${fmtDatum(p.nextChargeAt)}, danach monatlich ${fmtEuro(totalCents / 100)}` })
zeilen.push(pro === 0 && pendingReduction
? { label: 'Geplante Reduzierung', text: 'wird aufgehoben oder angepasst; es ist nichts neu einzurichten' }
: { label: 'Einrichtung', text: 'in der Regel innerhalb eines Werktags; bis dahin steht „wird eingerichtet“ da' })
} else {
zeilen.push({ label: 'Wirkt zum', text: `${fmtDatum(p.effectiveAt)}, zum Ende des Abrechnungsmonats` })
}
zeilen.push({ label: 'Laufzeit', text: 'monatlich, zum Ende jedes Abrechnungsmonats kündbar, endet spätestens mit dem Abo' })
const gesperrt = sperre || state.loading || !p || state.previewError || proBad
summary = h('div', { class: 'kn-speicher-zusammenfassung kn-bestellen' },
baueBlock(zeilen, { id: 'storage-block' }),
plus ? baueLinkzeile(T.speicher.linkzeile, { id: 'storage-linkzeile' }) : baueLinkzeile(T.speicher.warnung, { id: 'storage-warnung', klasse: 'kn-speicher-warnung' }),
h('p', { class: 'kn-fehler', id: 'storage-error', role: 'alert', hidden: !fehlerText }, fehlerText),
h('div', { class: 'kn-aktionen' },
h('button', { type: 'button', class: K.primaer, id: 'storage-submit', disabled: Boolean(gesperrt), on: { click: absenden } }, state.busy ? 'Wird gespeichert …' : plus ? (T.knopf || KNOPF) : 'Speichern')),
plus ? baueKundenartZeile(kundenart, T.kundenart, { id: 'storage-kundenart' }) : null)
}
}
const status = []
if (storage.pendingSetup) status.push(h('p', { class: 'kn-meldung kn-meldung-warn' }, 'Dein zusätzlicher Speicher wird eingerichtet. Das dauert in der Regel bis zum nächsten Werktag.'))
if (pendingReduction) status.push(h('p', { class: 'kn-hinweis' }, `Geplant: ab ${fmtTagMonat(pendingReduction.effectiveAt)} ${gbOf(Number(pendingReduction.units) || 0)} GB.`))
ersetze(panel,
h('h3', null, 'E-Mail-Speicher'),
h('p', { class: 'kn-hinweis' }, `${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 ? h('p', { class: 'kn-meldung kn-meldung-ok', role: 'status' }, notice) : null,
!canChange ? h('p', { class: 'kn-hinweis', id: 'storage-locked' }, `Änderungen sind derzeit nicht möglich – ${lockedReason}.`) : null,
canChange
? h('div', { class: 'kn-stepper' },
h('button', { type: 'button', class: K.knopf, id: 'storage-minus', 'aria-label': 'Ein Paket weniger', disabled: sperre || state.target <= 0, on: { click: () => schritt(-1) } }, '−'),
h('span', { class: 'kn-stepper-zahl', 'aria-live': 'polite' }, h('strong', null, state.target), ` ${paket(state.target)}`),
h('button', { type: 'button', class: K.knopf, id: 'storage-plus', 'aria-label': 'Ein Paket mehr', disabled: sperre || state.target >= maxUnits, on: { click: () => schritt(1) } }, '+'))
: h('p', { class: 'kn-stepper-zahl' }, h('strong', null, state.target), ` ${paket(state.target)}`),
h('p', { class: 'kn-speicher-summe' }, `= ${gbOf(state.target)} GB gesamt · + ${fmtEuro(state.target * unitCents / 100)} im Monat`),
canChange && viewAs ? h('p', { class: K.meldung }, TEXT_ANSICHTSMODUS) : null,
...status,
summary)
}
const ladeVorschau = async (token) => {
try {
state.preview = await api('/api/abo/storage/preview', { method: 'POST', body: { units: state.target } })
} catch (f) {
if (token !== state.token) return
state.previewError = f.message
}
if (token !== state.token) return
state.loading = false
paint()
}
const schritt = (d) => {
const next = state.target + d
if (viewAs || state.busy || next < 0 || next > maxUnits) return
state.target = next
state.preview = null
state.previewError = ''
fehlerText = ''
state.loading = next !== current
const token = ++state.token
paint()
if (next !== current) ladeVorschau(token)
}
const absenden = async () => {
if (viewAs) return
const plus = state.target > current
fehlerText = ''
// Plus: der Klick auf den Knopf ist die Zustimmung. Nie kundenart: die Kundenart bestimmt der Server aus dem Kundendokument.
const body = { units: state.target }
if (plus) Object.assign(body, { acceptTerms: true, termsVersion: daten.termsVersion })
state.busy = true
paint()
try {
const res = await api('/api/abo/storage', { method: 'POST', body })
const ch = res.change || {}
const hinweis = plus
? (ch.task === false ? 'Gespeichert. Es entstehen keine zusätzlichen Kosten, die Pakete waren schon bezahlt.' : 'Danke, deine Bestellung ist eingegangen. Der Speicher wird in der Regel innerhalb eines Werktags eingerichtet.')
: `Gespeichert. Die Verkleinerung wirkt zum ${fmtDatum(ch.effectiveAt)}.`
await nachErfolg(hinweis, res)
// Ein Plus eröffnet für Privatkunden eine neue Widerrufsfrist: die Hülle lädt Streifen und Pille „Frist läuft“ neu (E-Fix M1)
if (plus) meldeZaehlerNeu('vertraege')
} catch (f) {
state.busy = false
fehlerText = f.status === 409 ? `${f.message} Bitte lade die Seite neu, falls sich der Stand geändert hat.` : f.message
paint()
}
}
paint()
if (start !== current && canChange) ladeVorschau(++state.token)
return panel
}
/** Reiter-Inhalt E-Mail: Knopf "Postfach anfragen" und der Zusatzspeicher. daten (GET /api/abo) ist optional. */
export async function zeigeSpeicher(wurzel, { route, me, nav, daten } = {}) {
const nr = (laeufe.get(wurzel) ?? 0) + 1
laeufe.set(wurzel, nr)
const aktuell = () => laeufe.get(wurzel) === nr
const viewAs = me?.viewAs === true
leeren(wurzel).append(h('p', { class: K.leer }, 'Wird geladen …'))
let stand = daten ?? await ladeAbo()
if (!aktuell()) return
if (!stand) { leeren(wurzel).append(h('p', { class: K.fehler }, ABO_NICHT_GELADEN)); return }
if (!stand.subscription?.active) {
leeren(wurzel).append(h('p', { class: K.leer }, 'Den E-Mail-Speicher gibt es mit einem laufenden Abo.'))
return
}
const kundenart = kundenartVon(me, stand)
const anfrage = h('button', { type: 'button', class: K.knopf, on: { click: () => { setzeChatVorgabe('Bitte lege ein neues Postfach an: '); navigiere('#chat') } } }, 'Postfach anfragen')
const platz = h('div', { class: 'kn-speicher-platz' })
const zeichne = (notice = '') => {
leeren(platz).append(baueSpeicherPanel(stand, {
viewAs,
kundenart,
notice,
// Nach einem Erfolg: Abo neu laden (Rückfall: die Antwort der Bestellung) und den Hinweis oben im Panel zeigen
nachErfolg: async (hinweis, antwort) => {
const neu = await ladeAbo()
if (!aktuell()) return
stand = neu ?? (antwort?.subscription ? { ...stand, subscription: antwort.subscription } : stand)
zeichne(hinweis)
},
}))
}
zeichne()
leeren(wurzel).append(anfrage, platz)
}