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>
195 lines
11 KiB
JavaScript
195 lines
11 KiB
JavaScript
/* 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)
|
||
}
|