From 9b00771d117f24cca7af69b8edeb11aea06e7ca6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 20:15:36 +0000 Subject: [PATCH] Kundenansicht neu P7: E-Mail-Speicher schlank, Plus mit Linkzeile, Warnung als eine Zeile Co-Authored-By: Claude Sonnet 5.5 --- public/neu/ansicht-speicher.js | 101 ++++++++++---------- public/neu/ansichten.css | 2 + public/neu/bestell-bausteine.js | 10 +- test/knAnsichtSpeicher.test.js | 161 ++++++++++++++++++++++++++------ 4 files changed, 188 insertions(+), 86 deletions(-) diff --git a/public/neu/ansicht-speicher.js b/public/neu/ansicht-speicher.js index 9a1577d..26d2d39 100644 --- a/public/neu/ansicht-speicher.js +++ b/public/neu/ansicht-speicher.js @@ -1,21 +1,24 @@ -/* Kundenansicht neu (P2): Reiter E-Mail in Verträge, der Zusatzspeicher. Text und Logik 1:1 aus app.js (renderStoragePanel, - Fassung 5), die Sie-Form bleibt (C14, N36). Ein geänderter Satz wäre eine unbemerkte Rechtsänderung: Das Wortlaut-Orakel - (test/support/knWortlautOrakel.js) vergleicht den sichtbaren Text Zeichen für Zeichen. Nur "Postfach anfragen" und die Sperrhinweise sind neu (du). */ +/* 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, 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 BEDINGUNGEN = '/abo-bedingungen.html' -const link = (href, text) => h('a', { href, target: '_blank', rel: 'noopener' }, text) +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'}` -const fakt = (titel, wert, klasse) => h('div', null, h('dt', null, titel), h('dd', { class: klasse }, wert)) // Der Zusatzspeicher: Plus/Minus mit Vorschau vom Server, Bestellübersicht (§ 312j BGB) und Speichern -function baueSpeicherPanel(daten, { viewAs, notice, nachErfolg }) { +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 @@ -31,10 +34,9 @@ function baueSpeicherPanel(daten, { viewAs, notice, nachErfolg }) { 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 schreiben Sie uns gern im Chat' + : sub.paymentProblem ? 'Zahlung offen' : 'bei Fragen schreib uns gern im Chat' const panel = h('div', { class: 'kn-panel', 'data-panel': 'speicher' }) let fehlerText = '' - let haken = false const paint = () => { const delta = state.target - current @@ -43,43 +45,44 @@ function baueSpeicherPanel(daten, { viewAs, notice, nachErfolg }) { let summary = null if (canChange && delta !== 0) { const plus = 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))) - facts.push(fakt('Neue Gesamtgröße', `${gbOf(state.target)} GB`)) - facts.push(fakt('Preis', `${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) { - facts.push(fakt('Vorschau', state.previewError || 'Der Betrag konnte nicht berechnet werden. Bitte versuchen Sie es später erneut.', 'kn-fehler')) - } else if (!p || state.loading) { - facts.push(fakt('Vorschau', 'wird berechnet …')) - } else if (plus) { - const pro = Number(p.prorationCents) || 0 - facts.push(fakt('Zusätzlich jetzt', 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')) - facts.push(fakt('Nächste Abbuchung', `${fmtDatum(p.nextChargeAt)}, danach monatlich ${fmtEuro(totalCents / 100)}`)) - facts.push(pro === 0 && pendingReduction - ? fakt('Geplante Reduzierung', 'wird aufgehoben oder angepasst; es ist nichts neu einzurichten') - : fakt('Einrichtung', 'in der Regel innerhalb eines Werktags; bis dahin steht „wird eingerichtet“ da')) + 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 { - facts.push(fakt('Wirkt zum', `${fmtDatum(p.effectiveAt)}, zum Ende des Abrechnungsmonats`)) + 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', 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) } - facts.push(fakt('Laufzeit', 'monatlich, zum Ende jedes Abrechnungsmonats kündbar, endet spätestens mit dem Abo')) - const hinweis = plus - ? h('label', { class: 'kn-check' }, - h('input', { type: 'checkbox', id: 'storage-terms', checked: haken, disabled: sperre }), - h('span', null, 'Ich habe die ', link(`${BEDINGUNGEN}#zusatzspeicher`, 'Regeln zum Zusatzspeicher'), ' gelesen und stimme ihnen zu. Die ', link(`${BEDINGUNGEN}#widerruf`, 'Widerrufsbelehrung'), ' habe ich zur Kenntnis genommen.')) - : h('p', { class: 'kn-meldung kn-meldung-warn' }, '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 ', - link(`${BEDINGUNGEN}#zusatzspeicher`, 'Regeln zum Zusatzspeicher'), ' und in der ', link(`${BEDINGUNGEN}#widerruf`, 'Widerrufsbelehrung'), '.') - const gesperrt = sperre || state.loading || !p || state.previewError || proBad - summary = h('div', { class: 'kn-speicher-zusammenfassung' }, - h('dl', { class: 'kn-fakten' }, facts), - hinweis, - h('p', { class: 'kn-fehler', id: 'storage-error', hidden: !fehlerText }, fehlerText), - h('button', { type: 'button', class: K.primaer, id: 'storage-submit', disabled: Boolean(gesperrt), on: { click: absenden } }, state.busy ? 'Wird gespeichert …' : plus ? 'Zahlungspflichtig bestellen' : 'Speichern')) } const status = [] - if (storage.pendingSetup) status.push(h('p', { class: 'kn-meldung kn-meldung-warn' }, 'Ihr zusätzlicher Speicher wird eingerichtet. Das dauert in der Regel bis zum nächsten Werktag.')) + 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'), @@ -126,14 +129,8 @@ function baueSpeicherPanel(daten, { viewAs, notice, nachErfolg }) { const absenden = async () => { if (viewAs) return const plus = state.target > current - const terms = panel.querySelector('#storage-terms') - haken = Boolean(terms?.checked) fehlerText = '' - if (plus && !haken) { - fehlerText = 'Bitte stimmen Sie den Regeln zum Zusatzspeicher zu.' - paint() - return - } + // 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 @@ -142,12 +139,12 @@ function baueSpeicherPanel(daten, { viewAs, notice, nachErfolg }) { 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, Ihre Bestellung ist eingegangen. Der Speicher wird in der Regel innerhalb eines Werktags eingerichtet.') + ? (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) } catch (f) { state.busy = false - fehlerText = f.status === 409 ? `${f.message} Bitte laden Sie die Seite neu, falls sich der Stand geändert hat.` : f.message + fehlerText = f.status === 409 ? `${f.message} Bitte lade die Seite neu, falls sich der Stand geändert hat.` : f.message paint() } } @@ -173,11 +170,13 @@ export async function zeigeSpeicher(wurzel, { route, me, nav, daten } = {}) { 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) => { diff --git a/public/neu/ansichten.css b/public/neu/ansichten.css index cbdd494..95bd680 100644 --- a/public/neu/ansichten.css +++ b/public/neu/ansichten.css @@ -334,6 +334,8 @@ .kn-bestellen .kn-aktionen { margin-top: 0; } .kn-sperrzeile { margin: 0 0 12px; font-size: 13px; line-height: 20px; color: var(--kn-danger); } .kn-speicher-warnung { margin: 0 0 12px; font-size: 13px; line-height: 20px; color: var(--kn-orange-fg); } +.kn-speicher-warnung a { color: inherit; text-decoration: underline; text-underline-offset: 3px; } +.kn-block dd.kn-fehler { color: var(--kn-danger); } .kn-gutschrift-bedingungen { max-width: 640px; } /* ------------------------------------------------------------------ Handy --- */ diff --git a/public/neu/bestell-bausteine.js b/public/neu/bestell-bausteine.js index 99fbc8a..42f9147 100644 --- a/public/neu/bestell-bausteine.js +++ b/public/neu/bestell-bausteine.js @@ -52,11 +52,11 @@ export function linkTeile(text, urls) { const ausserhalb = (href) => !href.startsWith('#') -/**

mit den Links der Vorlage; '/…' und http(s) öffnen in neuem Tab (target _blank, rel noopener), '#…' bleibt im Fenster. */ -export function baueLinkzeile({ text, urls } = {}, { id } = {}) { +/**

mit den Links der Vorlage; '/…' und http(s) öffnen in neuem Tab (target _blank, rel noopener), '#…' bleibt im Fenster. klasse: Vorgabe 'kn-linkzeile'. */ +export function baueLinkzeile({ text, urls } = {}, { id, klasse = 'kn-linkzeile' } = {}) { const kinder = linkTeile(text, urls).map((t) => t.href ? h('a', { href: t.href, ...(ausserhalb(t.href) ? { target: '_blank', rel: 'noopener' } : {}) }, t.text) : t.text) - return h('p', { id, class: 'kn-linkzeile' }, kinder) + return h('p', { id, class: klasse }, kinder) } /**

: „Du bestellst als Betrieb“ | „Du bestellst privat“, ' · ', dann der Hinweis zur Korrektur mit seinem Link. */ @@ -67,9 +67,9 @@ export function baueKundenartZeile(kundenart, texte, { id } = {}) { return h('p', { id, class: 'kn-kundenart-zeile' }, lage, ' · ', korrektur) } -/** Block „Was du bestellst“: zeilen [{ id?, label, text }] als Beschriftungsliste. Der Text ist immer Text. */ +/** Block „Was du bestellst“: zeilen [{ id?, label, text, klasse? }] als Beschriftungsliste. Der Text ist immer Text. */ export function baueBlock(zeilen, { id } = {}) { - return h('dl', { id, class: 'kn-block' }, (zeilen || []).map((z) => h('div', { class: 'kn-block-zeile' }, h('dt', null, z.label), h('dd', { id: z.id }, z.text)))) + return h('dl', { id, class: 'kn-block' }, (zeilen || []).map((z) => h('div', { class: 'kn-block-zeile' }, h('dt', null, z.label), h('dd', { id: z.id, class: z.klasse }, z.text)))) } const nichtLeer = (x) => typeof x === 'string' && x !== '' diff --git a/test/knAnsichtSpeicher.test.js b/test/knAnsichtSpeicher.test.js index 0157dd7..973f0cf 100644 --- a/test/knAnsichtSpeicher.test.js +++ b/test/knAnsichtSpeicher.test.js @@ -1,50 +1,151 @@ import { test } from 'node:test' import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' import { zeigeSpeicher } from '../public/neu/ansicht-speicher.js' import { nimmChatVorgabe } from '../public/neu/ansicht-hilfen.js' -import { sichtbarerText, ladeAlteApp } from './support/knWortlautOrakel.js' +import { klartext } from '../server/services/bestelltexte.js' import { mit, me, abo, aboAntwort, neueWurzel, VORSCHAU_SPEICHER } from './support/knDaten.js' +import { NEU, TERMS_URL, meMit, aboNeu } from './support/knBestellDaten.js' -const alt = ladeAlteApp() -const speicher = (o = {}, sub = {}) => aboAntwort({ subscription: abo({ storage: { units: 2, pendingSetup: false, pendingReduction: null }, canChangeStorage: true, ...sub }), ...o }) -const zeigeNeu = async (fx, daten, m = me()) => { const w = neueWurzel(fx); await zeigeSpeicher(w, { route: { bereich: 'vertraege', reiter: 'email', query: '' }, me: m, nav: 'tab', daten }); await fx.ruhe(); return w } -for (const [name, daten] of [['zwei Pakete', speicher()], ['gesperrt', speicher({}, { canChangeStorage: false })], ['wird eingerichtet', speicher({}, { storage: { units: 1, pendingSetup: true, pendingReduction: null } })], ['Reduzierung geplant', speicher({}, { storage: { units: 3, pendingSetup: false, pendingReduction: { units: 1, effectiveAt: '2026-11-01T00:00:00.000Z' } } })]]) { - test(`E-Mail-Speicher (${name}): Text wie in app.js`, mit({ 'POST /api/abo/storage/preview': VORSCHAU_SPEICHER }, async (fx) => { +// E-Mail-Speicher im schlanken Muster (Spec 5.6, Fassung 6): Plus mit Block, Linkzeile, Knopf und Kundenart-Zeile ohne Häkchen, +// Minus mit genau einer Warnzeile und dem Knopf „Speichern“ +const speicher = (o = {}, sub = {}) => aboNeu({ subscription: abo({ storage: { units: 2, pendingSetup: false, pendingReduction: null }, canChangeStorage: true, ...sub }), ...o }) +const PLUS = { direction: 'up', units: 3, totalGb: 20, monthlyCents: 300, deltaMonthlyCents: 100, prorationCents: 40, nextChargeAt: '2026-11-16T00:00:00.000Z', effectiveAt: null } +const IDS = ['storage-block', 'storage-linkzeile', 'storage-warnung', 'storage-error', 'storage-submit', 'storage-kundenart'] +const reihe = (fx, w) => fx.alle(fx.finde(w, '[data-panel="speicher"]'), (el) => IDS.includes(el.id)).map((el) => el.id) +const zeigeNeu = async (fx, daten, m = meMit('unternehmen')) => { + const w = neueWurzel(fx) + await zeigeSpeicher(w, { route: { bereich: 'vertraege', reiter: 'email', query: '' }, me: m, nav: 'tab', daten }) + await fx.ruhe() + return w +} + +test('Plus: Block, Linkzeile, Knopf, Kundenart-Zeile, kein Häkchen, Body ohne Kundenart', mit({ 'POST /api/abo/storage/preview': PLUS, 'POST /api/abo/storage': { change: { task: true }, subscription: abo() }, 'GET /api/abo': speicher() }, async (fx) => { + const w = await zeigeNeu(fx, speicher(), meMit('unternehmen')) + await fx.klick(fx.finde(w, '#storage-plus')) + await fx.ruhe() + assert.deepEqual(reihe(fx, w), ['storage-block', 'storage-linkzeile', 'storage-error', 'storage-submit', 'storage-kundenart']) + assert.deepEqual([fx.finde(w, '#storage-terms'), fx.finde(w, '#storage-submit').textContent, fx.finde(w, '#storage-warnung')], [null, 'Zahlungspflichtig bestellen', null]) + assert.equal(fx.alle(w, 'input[type="checkbox"], details, dialog').length, 0) + assert.equal(fx.finde(w, '#storage-linkzeile a').getAttribute('href'), `${TERMS_URL}#zusatzspeicher`) + const zeilen = fx.alle(fx.finde(w, '#storage-block'), 'dt').map((e) => fx.text(e)) + assert.deepEqual(zeilen, ['Neue Gesamtgröße', 'Preis', 'Zusätzlich jetzt', 'Nächste Abbuchung', 'Einrichtung', 'Laufzeit']) + assert.equal(fx.text(fx.finde(w, '#storage-kundenart')), 'Du bestellst als Betrieb · Stimmt nicht? Schreib uns im Chat') + await fx.klick(fx.finde(w, '#storage-submit')) + await fx.ruhe() + assert.deepEqual(fx.fetchLog.find((a) => a.url === '/api/abo/storage').body, { units: 3, acceptTerms: true, termsVersion: NEU }) + assert.match(fx.text(w), /Danke, deine Bestellung ist eingegangen\. Der Speicher wird in der Regel innerhalb eines Werktags eingerichtet\./) +})) + +test('Minus: genau eine Warnzeile aus dem Wortlaut, Knopf „Speichern“, Body nur mit units', mit({ 'POST /api/abo/storage/preview': VORSCHAU_SPEICHER, 'POST /api/abo/storage': { change: { effectiveAt: '2026-11-01T00:00:00.000Z' }, subscription: abo() } }, async (fx) => { + const w = await zeigeNeu(fx, speicher(), meMit('privat')) + await fx.klick(fx.finde(w, '#storage-minus')) + await fx.ruhe() + assert.equal(fx.alle(w, '#storage-warnung').length, 1) + assert.equal(fx.text(fx.finde(w, '#storage-warnung')), klartext(speicher().bestelltexte.speicher.warnung.text)) + assert.equal(fx.finde(w, '#storage-warnung a').getAttribute('href'), `${TERMS_URL}#zusatzspeicher`) + assert.deepEqual([fx.finde(w, '#storage-linkzeile'), fx.finde(w, '#storage-kundenart'), fx.finde(w, '#storage-submit').textContent], [null, null, 'Speichern']) + assert.deepEqual(reihe(fx, w), ['storage-block', 'storage-warnung', 'storage-error', 'storage-submit']) + await fx.klick(fx.finde(w, '#storage-submit')) + await fx.ruhe() + assert.deepEqual(fx.fetchLog.find((a) => a.url === '/api/abo/storage').body, { units: 1 }) + assert.match(fx.text(w), /Gespeichert\. Die Verkleinerung wirkt zum 01\.11\.2026\./) +})) + +test('Privat und leer: Kundenart-Zeile nennt privat; Ansichtsmodus sperrt Stepper und Knöpfe', mit({ 'POST /api/abo/storage/preview': PLUS }, async (fx) => { + for (const m of [meMit('privat'), meMit('')]) { + const w = await zeigeNeu(fx, speicher(), m) + await fx.klick(fx.finde(w, '#storage-plus')) + await fx.ruhe() + assert.ok(fx.text(fx.finde(w, '#storage-kundenart')).startsWith('Du bestellst privat · ')) + } + const v = await zeigeNeu(fx, speicher(), meMit('unternehmen', { viewAs: true })) + assert.ok(['#storage-minus', '#storage-plus'].every((s) => fx.finde(v, s).hasAttribute('disabled'))) + assert.match(fx.text(v), /Im Ansichtsmodus nicht möglich\./) +})) + +test('Ohne Wortlaut: Plus und Minus zeigen nur den Satz, keinen Knopf', mit({ 'POST /api/abo/storage/preview': PLUS }, async (fx) => { + const w = await zeigeNeu(fx, speicher({ bestelltexte: undefined }), meMit('unternehmen')) + for (const knopf of ['#storage-plus', '#storage-minus', '#storage-minus']) { + await fx.klick(fx.finde(w, knopf)) + await fx.ruhe() + if (fx.finde(w, '#storage-block')) assert.fail('kein Block ohne Wortlaut') + if (knopf === '#storage-plus') { + assert.match(fx.text(w), /Die Online-Bestellung wird gerade eingerichtet – schreib uns gern im Chat\./) + assert.equal(fx.finde(w, '#storage-submit'), null) + } + } +})) + +test('Unsichere Adressen in Linkzeile und Warnung werden Text', mit({ 'POST /api/abo/storage/preview': PLUS }, async (fx) => { + const boese = speicher() + boese.bestelltexte.speicher.linkzeile.urls = ['javascript:alert(1)', '//evil.example'] + const w = await zeigeNeu(fx, boese) + await fx.klick(fx.finde(w, '#storage-plus')) + await fx.ruhe() + assert.equal(fx.alle(w, '#storage-linkzeile a[href]').length, 0) +})) + +for (const [name, daten, erwartet] of [ + ['gesperrt', speicher({}, { canChangeStorage: false }), /Änderungen sind derzeit nicht möglich – bei Fragen schreib uns gern im Chat\./], + ['gesperrt (gekündigt)', speicher({}, { canChangeStorage: false, cancelAtPeriodEnd: true }), /Änderungen sind derzeit nicht möglich – Abo gekündigt\./], + ['gesperrt (Wechsel)', speicher({}, { canChangeStorage: false, switchPending: { type: 'wechsel' } }), /Änderungen sind derzeit nicht möglich – Wechsel wartet auf die Zahlung\./], + ['gesperrt (Zahlung)', speicher({}, { canChangeStorage: false, paymentProblem: true }), /Änderungen sind derzeit nicht möglich – Zahlung offen\./], + ['wird eingerichtet', speicher({}, { storage: { units: 1, pendingSetup: true, pendingReduction: null } }), /Dein zusätzlicher Speicher wird eingerichtet\. Das dauert in der Regel bis zum nächsten Werktag\./], + ['Reduzierung geplant', speicher({}, { storage: { units: 3, pendingSetup: false, pendingReduction: { units: 1, effectiveAt: '2026-11-01T00:00:00.000Z' } } }), /Geplant: ab 01\.11\. 10 GB\./], +]) { + test(`E-Mail-Speicher (${name}): Texte in du`, mit({ 'POST /api/abo/storage/preview': VORSCHAU_SPEICHER }, async (fx) => { const w = await zeigeNeu(fx, daten) - assert.equal(sichtbarerText(fx.finde(w, '[data-panel="speicher"]')), sichtbarerText(await alt.speicher(daten, VORSCHAU_SPEICHER))) + assert.match(fx.text(fx.finde(w, '[data-panel="speicher"]')), erwartet) + assert.doesNotMatch(fx.text(w), /\b(Sie|Ihr(e[nmrs]?)?|Ihnen)\b/) })) } -test('Plus: Vorschau, Zustimmung Pflicht, Body mit acceptTerms und termsVersion, Hinweis nach Erfolg', mit({ - 'POST /api/abo/storage/preview': { direction: 'up', units: 3, totalGb: 20, monthlyCents: 300, deltaMonthlyCents: 100, prorationCents: 40, nextChargeAt: '2026-11-16T00:00:00.000Z', effectiveAt: null }, - 'POST /api/abo/storage': { change: { task: true }, subscription: abo() }, 'GET /api/abo': speicher() }, async (fx) => { + +test('409 hängt den Hinweis zum Neuladen an', mit({ 'POST /api/abo/storage/preview': VORSCHAU_SPEICHER, 'POST /api/abo/storage': () => ({ status: 409, body: { error: 'Stand geändert.' } }) }, async (fx) => { const w = await zeigeNeu(fx, speicher()) - await fx.klick(fx.finde(w, '#storage-plus')); await fx.ruhe(); assert.deepEqual(fx.fetchLog.at(-1).body, { units: 3 }) - await fx.klick(fx.finde(w, '#storage-submit')); assert.match(fx.text(w), /Bitte stimmen Sie den Regeln zum Zusatzspeicher zu\./) - await fx.klick(fx.finde(w, '#storage-terms')); await fx.klick(fx.finde(w, '#storage-submit')); await fx.ruhe() - assert.deepEqual(fx.fetchLog.find((a) => a.url === '/api/abo/storage').body, { units: 3, acceptTerms: true, termsVersion: '2026-09-30-v5' }) - assert.match(fx.text(w), /Danke, Ihre Bestellung ist eingegangen\. Der Speicher wird in der Regel innerhalb eines Werktags eingerichtet\./) -})) -test('Minus ohne Haken; 409 hängt den Hinweis zum Neuladen an; Ansichtsmodus sperrt', mit({ - 'POST /api/abo/storage/preview': VORSCHAU_SPEICHER, 'POST /api/abo/storage': () => ({ status: 409, body: { error: 'Stand geändert.' } }) }, async (fx) => { - const w = await zeigeNeu(fx, speicher()); await fx.klick(fx.finde(w, '#storage-minus')); await fx.ruhe(); await fx.klick(fx.finde(w, '#storage-submit')); await fx.ruhe() - assert.deepEqual(fx.fetchLog.find((a) => a.url === '/api/abo/storage').body, { units: 1 }); assert.match(fx.text(w), /Stand geändert\. Bitte laden Sie die Seite neu, falls sich der Stand geändert hat\./) - const gesperrt = await zeigeNeu(fx, speicher(), me({ viewAs: true })); assert.ok(['#storage-minus', '#storage-plus'].every((s) => fx.finde(gesperrt, s).hasAttribute('disabled'))) - assert.match(fx.text(gesperrt), /Im Ansichtsmodus nicht möglich\./) + await fx.klick(fx.finde(w, '#storage-minus')) + await fx.ruhe() + await fx.klick(fx.finde(w, '#storage-submit')) + await fx.ruhe() + assert.match(fx.text(w), /Stand geändert\. Bitte lade die Seite neu, falls sich der Stand geändert hat\./) })) + test('Postfach anfragen setzt die Chat-Vorgabe und geht zum Chat', mit({}, async (fx) => { fx.window.history.pushState = (_s, _t, url) => { fx.window.location.hash = url } - const w = await zeigeNeu(fx, speicher()); await fx.klick(fx.finde(w, (e) => e.textContent === 'Postfach anfragen')) - assert.equal(nimmChatVorgabe(), 'Bitte lege ein neues Postfach an: '); assert.equal(fx.window.location.hash, '#chat') + const w = await zeigeNeu(fx, speicher()) + await fx.klick(fx.finde(w, (e) => e.textContent === 'Postfach anfragen')) + assert.equal(nimmChatVorgabe(), 'Bitte lege ein neues Postfach an: ') + assert.equal(fx.window.location.hash, '#chat') })) + test('Speicher: ohne Daten wird das Abo geladen, Ausfall und fehlendes Abo sagen es', mit({ 'GET /api/abo': () => ({ status: 500, body: { error: 'x' } }) }, async (fx) => { - const w = neueWurzel(fx); await zeigeSpeicher(w, { route: { bereich: 'vertraege', query: '' }, me: me(), nav: 'tab' }); await fx.ruhe() + const w = neueWurzel(fx) + await zeigeSpeicher(w, { route: { bereich: 'vertraege', query: '' }, me: me(), nav: 'tab' }) + await fx.ruhe() assert.match(fx.text(w), /Deine Verträge konnten gerade nicht geladen werden\./) - const ohne = await zeigeNeu(fx, aboAntwort()); assert.equal(fx.finde(ohne, '[data-panel="speicher"]'), null); assert.match(fx.text(ohne), /Den E-Mail-Speicher gibt es mit einem laufenden Abo\./) + const ohne = await zeigeNeu(fx, aboAntwort()) + assert.equal(fx.finde(ohne, '[data-panel="speicher"]'), null) + assert.match(fx.text(ohne), /Den E-Mail-Speicher gibt es mit einem laufenden Abo\./) })) -test('Speicher: Ansichtsmodus schickt nichts, ein Vorschau-Fehler sperrt den Knopf', mit({ 'POST /api/abo/storage/preview': () => ({ status: 500, body: { error: 'Vorschau kaputt' } }), 'POST /api/abo/storage': {} }, async (fx) => { - const w = await zeigeNeu(fx, speicher()); await fx.klick(fx.finde(w, '#storage-plus')); await fx.ruhe() - assert.match(fx.text(w), /Vorschau kaputt/); assert.equal(fx.finde(w, '#storage-submit').hasAttribute('disabled'), true) + +test('Ansichtsmodus schickt nichts, ein Vorschau-Fehler sperrt den Knopf', mit({ 'POST /api/abo/storage/preview': () => ({ status: 500, body: { error: 'Vorschau kaputt' } }), 'POST /api/abo/storage': {} }, async (fx) => { + const w = await zeigeNeu(fx, speicher()) + await fx.klick(fx.finde(w, '#storage-plus')) + await fx.ruhe() + assert.match(fx.text(w), /Vorschau kaputt/) + assert.equal(fx.finde(w, '#storage-submit').hasAttribute('disabled'), true) const vorher = fx.fetchLog.length - const v = await zeigeNeu(fx, speicher(), me({ viewAs: true })); await fx.klick(fx.finde(v, '#storage-plus')); await fx.ruhe() + const v = await zeigeNeu(fx, speicher(), meMit('unternehmen', { viewAs: true })) + await fx.klick(fx.finde(v, '#storage-plus')) + await fx.ruhe() assert.deepEqual(fx.fetchLog.slice(vorher), []) // im Ansichtsmodus geht nichts raus, auch keine Vorschau })) + +test('Quelltext: kein Sie, keine Klappe, kein Häkchen, Body ohne kundenart', () => { + const q = readFileSync(new URL('../public/neu/ansicht-speicher.js', import.meta.url), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '') + assert.doesNotMatch(q, /\b(Sie|Ihr(e[nmrs]?)?|Ihnen)\b/) + assert.doesNotMatch(q, /details|dialog|innerHTML|storage-terms|checkbox/) + const aufruf = q.slice(q.indexOf('const body = '), q.indexOf("api('/api/abo/storage'")) + assert.ok(aufruf.includes('acceptTerms: true')) + assert.doesNotMatch(aufruf, /kundenart|unternehmer|startChoice/) +})