Kundenansicht neu E-Fix F1: Kerninfo und Knopf der Bestellseiten passen in den nutzbaren Bereich (390x844, 375x667)

Die Messung im Werkzeug nahm innerHeight; nutzbar ist am Handy nur der Bereich zwischen Kopf und fester Leiste (735 px bei 844, 558 px bei 667).
Gemessen vor der Korrektur (Blockanfang bis Knopfunterkante, 375x667): Abo bestellen privat 799 px, Wechsel privat 888 px, Zusatzspeicher 567 px.

- Block knapp: Leistung als eine Zeile mit Link "Leistungen ansehen" auf die Leistungsbeschreibung, Preis, Fällig, Beginn, Laufzeit und Zahlung in je ein bis zwei Zeilen.
- Die ausführlichen Sätze stehen unter dem Knopf und der Kundenart-Zeile (baueEinzelheiten), ohne Klappe.
- Handy: Beschriftung läuft in der Zeile mit, kleinere Überschrift, knappere Abstände.
- Die Wortlaute des Blocks sind gekürzt und gehören in die Rechtsrunde.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-16 21:25:48 +00:00
1 parent 779320c080
commit d4050f870a
8 files changed
+148 -41

No files matched your search

+20 -11
View File
@@ -1,6 +1,8 @@
/* Kundenansicht neu (P7): Bestellseite je Abo (#bestellen-hosting, #bestellen-wartung) im schlanken Muster der Spec 5.6, Fassung 6.
Reihenfolge ohne Klappe und ohne Scrollbereich: Block „Was du bestellst“, eine Linkzeile, bei Privatkunden ein Häkchen, der Knopf
„Zahlungspflichtig bestellen“ (§ 312j BGB: Kerninfo und Knopf auf demselben Bildschirm), darunter die Kundenart-Zeile.
Der Block ist knapp gehalten (Leistung als eine Zeile mit Link auf die Leistungsbeschreibung), damit er mit dem Knopf in den nutzbaren Bereich
zwischen Kopf und Leiste passt (375 x 667); die ausführlichen Sätze stehen unter dem Knopf (baueEinzelheiten), ohne Klappe.
Die Sätze (Linkzeile, Häkchen, Kundenart-Zeile) kommen vom Server (GET /api/abo → bestelltexte). Der Server entscheidet die Kundenart selbst:
der Client sendet nie kundenart oder startChoice, bei Privatkunden nur earlyStart (das Häkchen) und den gezeigten Leistungsbeginn.
Der Titel kommt von der Hülle (meldeTitel); die Seite hat keine eigene h1. */
@@ -8,7 +10,7 @@
import { api } from './api.js'
import { h, leeren } from './dom.js'
import { K, ladeAbo, sichereAdresse, meldeTitel, fmtEuro, fmtDatum, ABO_NICHT_GELADEN } from './ansicht-hilfen.js'
import { baueBlock, baueKundenartZeile, baueLinkzeile, hatWortlaut, kundenartVon } from './bestell-bausteine.js'
import { baueBlock, baueEinzelheiten, baueKundenartZeile, baueLinkzeile, hatWortlaut, kundenartVon, leistungLink } from './bestell-bausteine.js'
const laeufe = new WeakMap() // Wurzel → Nummer des jüngsten Aufrufs
const TEXT_SPERRE = 'Im Ansichtsmodus nicht möglich.'
@@ -35,10 +37,10 @@ function preise(product, offer) {
const mitZahlen = Boolean(offer) && Number.isFinite(offerMonthly) && offer.monthlyPriceCents != null && offerMonths > 0
const preis = fmtEuro(product.amountCents / 100)
const preisZeile = !offer
? `${preis} pro Monat (Gesamtpreis, ${UST}), keine weiteren Kosten`
? `${preis} pro Monat (Gesamtpreis, ${UST})`
: mitZahlen
? `${fmtEuro(offerMonthly / 100)} pro Monat für die ersten ${offerMonths} Monate, danach ${fmtEuro(offerRegular / 100)} pro Monat (individuelles Angebot: ${offer.text}; Gesamtpreis, ${UST}), keine weiteren Kosten`
: `${preis} pro Monat (individuelles Angebot: ${offer.text}; Gesamtpreis, ${UST}), keine weiteren Kosten`
? `${fmtEuro(offerMonthly / 100)} pro Monat für die ersten ${offerMonths} Monate, danach ${fmtEuro(offerRegular / 100)} pro Monat (individuelles Angebot: ${offer.text}; Gesamtpreis, ${UST})`
: `${preis} pro Monat (individuelles Angebot: ${offer.text}; Gesamtpreis, ${UST})`
return { preisZeile, erste: fmtEuro((mitZahlen ? offerMonthly : product.amountCents) / 100) }
}
@@ -46,13 +48,13 @@ function preise(product, offer) {
function faelligBeginn({ lage, erste, frist }) {
if (lage === 'frist') {
return {
faellig: `Erste Abbuchung am ${frist || 'Ende der 14-tägigen Widerrufsfrist'}: ${erste} (${UST}); heute wird nichts abgebucht`,
beginn: `nach Ablauf der 14-tägigen Widerrufsfrist${frist ? ` (am ${frist})` : ''}; bis dahin keine Leistung und keine Abbuchung`,
faellig: `erst am ${frist || 'Ende der Widerrufsfrist'}: ${erste}`,
beginn: `nach Ablauf der 14-tägigen Widerrufsfrist${frist ? ` (am ${frist})` : ''}`,
}
}
return {
faellig: `Heute fällig: ${erste} (${UST})`,
beginn: lage === 'wunsch' ? 'sofort, auf deinen ausdrücklichen Wunsch vor Ablauf der Widerrufsfrist' : 'sofort',
faellig: `heute ${erste}`,
beginn: lage === 'wunsch' ? 'sofort, auf deinen ausdrücklichen Wunsch' : 'sofort',
}
}
@@ -82,13 +84,19 @@ function bestellung(daten, productKey, { viewAs, kundenart }) {
const start = faelligBeginn({ lage: privat ? 'frist' : 'sofort', erste, frist })
const block = baueBlock([
{ label: 'Leistung', text: `${product.name}: ${(product.features || []).map(([t]) => t).join(', ')}` },
{ label: 'Leistung', text: labelVon(daten, productKey), link: { text: 'Leistungen ansehen', href: leistungLink(T.abo.linkzeile, productKey) } },
{ label: 'Preis', text: preisZeile },
{ id: 'abo-due-summary', label: 'Fällig', text: start.faellig },
{ id: 'abo-start-summary', label: 'Beginn', text: start.beginn },
{ label: 'Laufzeit', text: `Mindestlaufzeit ${product.minTermMonths} Monate, kündbar bis 1 Monat vor Ende; danach monatlich` },
{ label: 'Zahlung', text: 'Karte oder SEPA über Stripe' },
], { id: 'abo-block' })
// Die ausführlichen Sätze stehen unter dem Knopf
const einzelheiten = baueEinzelheiten([
{ label: 'Laufzeit', text: `Mindestlaufzeit ${product.minTermMonths} Monate ab Leistungsbeginn, kündbar zum Ende der Mindestlaufzeit bis einen Monat vorher; danach jederzeit zum Ende des Abrechnungsmonats kündbar` },
{ label: 'Zahlung', text: 'Kreditkarte oder SEPA-Lastschrift über Stripe; die Zahlungsart hinterlegst du im nächsten Schritt, bis zum Abschluss dort kannst du abbrechen' },
], { id: 'abo-block' })
{ label: 'Kosten', text: 'keine weiteren Kosten' },
], { id: 'abo-einzelheiten' })
const haken = privat ? h('input', { type: 'checkbox', id: 'abo-beginn', disabled: viewAs }) : null
const fehler = h('p', { class: K.fehler, id: 'abo-error', hidden: true })
@@ -103,7 +111,8 @@ function bestellung(daten, productKey, { viewAs, kundenart }) {
haken ? h('label', { class: K.check }, haken, h('span', { id: 'abo-beginn-text' }, T.abo.haken)) : null,
fehler,
h('div', { class: 'kn-aktionen' }, kaufen, viewAs ? h('span', { class: K.meldung }, TEXT_SPERRE) : null),
baueKundenartZeile(kundenart, T.kundenart, { id: 'abo-kundenart' }))
baueKundenartZeile(kundenart, T.kundenart, { id: 'abo-kundenart' }),
einzelheiten)
// Das Häkchen schaltet die Zeilen „Fällig“ und „Beginn“ um
haken?.addEventListener('change', () => {
+24 -7
View File
@@ -4,12 +4,14 @@
der Wechselrechnung (Bedingungen § 9 Abs. 3). Die Sätze kommen vom Server (GET /api/abo → bestelltexte); der Server entscheidet die
Kundenart selbst, der Client sendet nie kundenart. Ein Betrieb braucht kein Häkchen.
Die gemerkte Funktion (E14, C5): kam der Kunde aus dem Einschalten einer Funktion, geht es danach zurück zu den Funktionen.
Der Block ist knapp gehalten (Leistung als eine Zeile mit Link auf die Leistungsbeschreibung), damit er mit dem Knopf in den nutzbaren Bereich
zwischen Kopf und Leiste passt (375 x 667); die ausführlichen Sätze stehen unter dem Knopf (baueEinzelheiten), ohne Klappe.
Der Titel kommt von der Hülle (meldeTitel); die Seite hat keine eigene h1. */
import { api } from './api.js'
import { h, leeren } from './dom.js'
import { K, ladeAbo, wechselFunktionAus, sichereAdresse, merkeHinweis, navigiere, meldeTitel, meldeZaehlerNeu, zuAnsicht, fmtEuro, fmtDatum, berlinTag, ABO_NICHT_GELADEN } from './ansicht-hilfen.js'
import { baueBlock, baueKundenartZeile, baueLinkzeile, hatWortlaut, kundenartVon } from './bestell-bausteine.js'
import { baueBlock, baueEinzelheiten, baueKundenartZeile, baueLinkzeile, hatWortlaut, kundenartVon, leistungLink } from './bestell-bausteine.js'
const SCHLUESSEL = 'kn-wechsel-funktion'
const TEXT_SPERRE = 'Im Ansichtsmodus nicht möglich.'
@@ -75,18 +77,32 @@ export async function zeigeWechsel(wurzel, { route, me, nav, daten } = {}) {
const lauf = { busy: false, preview: null, error: '', loading: true, token: 0, early: false, fehler: '', fehlerKlasse: null, zeichne: null }
aktiv = lauf
/** Zeilen des Blocks aus der Vorschau des Servers (Leistung, Wartungs-Abo, Zusatzspeicher, Gutschrift, fällig, Abbuchungstag, Laufzeit, Beginn). */
/** Zeilen des Blocks aus der Vorschau des Servers, knapp: Leistung, Preis, Zusatzspeicher, Gutschrift, Fällig (mit Abbuchungstag), Laufzeit, Beginn. */
const zeilenVon = (p) => {
const monthly = Number.isFinite(Number(p.monthlyCents)) && p.monthlyCents != null ? Number(p.monthlyCents) : wartung.amountCents
const tag = berlinTag(p.nextChargeAt)
return [
{ label: 'Leistung', text: 'Zusätzlich zum Hosting: Pflege deiner Website, beliebig viele Postfächer und 1 Stunde Arbeitszeit pro Monat. Das Hosting-Abo endet mit dem Wechsel im gegenseitigen Einvernehmen; Domain, Postfächer und gebuchter Speicher bleiben.' },
{ label: 'Wartungs-Abo', text: `${fmtEuro(monthly / 100)} pro Monat (Gesamtpreis, ${UST})` },
{ label: 'Leistung', text: 'Wartungs-Abo statt Hosting-Abo', link: { text: 'Leistungen ansehen', href: leistungLink(T.wechsel.linkzeile, 'wartung') } },
{ label: 'Preis', text: `${fmtEuro(monthly / 100)} pro Monat (Gesamtpreis, ${UST})` },
...(units > 0
? [{ label: 'Zusatzspeicher', text: `${units} Paket${units === 1 ? '' : 'e'} (+${units * (Number(mail.gbPerUnit) || 0)} GB) bleiben gebucht: ${fmtEuro(units * unitCents / 100)} pro Monat zusätzlich (${UST}), insgesamt ${fmtEuro((monthly + units * unitCents) / 100)} pro Monat` }]
? [{ label: 'Zusatzspeicher', text: `${units} Paket${units === 1 ? '' : 'e'} (+${units * (Number(mail.gbPerUnit) || 0)} GB) bleiben gebucht, insgesamt ${fmtEuro((monthly + units * unitCents) / 100)} pro Monat` }]
: []),
...((Number(p.creditCents) || 0) > 0
? [{ label: 'Gutschrift für nicht genutzte Tage', text: `−${fmtEuro(p.creditCents / 100)} aus dem bereits bezahlten Hosting-Abo (${UST})` }]
? [{ label: 'Gutschrift', text: `−${fmtEuro(p.creditCents / 100)} für nicht genutzte Tage des Hosting-Abos` }]
: []),
{ label: 'Fällig', text: `heute ${fmtEuro(p.amountDueCents / 100)}; danach ab ${fmtDatum(p.nextChargeAt)}${tag ? ` jeweils am ${tag.t}.` : ''}` },
{ label: 'Laufzeit', text: `Mindestlaufzeit bis ${fmtDatum(p.minTermEnd)} (12 Monate), danach monatlich kündbar` },
{ label: 'Beginn', text: 'sofort mit der Zahlung; einen späteren Beginn gibt es nicht' },
]
}
/** Die ausführlichen Sätze unter dem Knopf (Wortlaut wie bisher im Block). */
const einzelheitenVon = (p) => {
const tag = berlinTag(p.nextChargeAt)
return [
{ label: 'Leistung', text: 'Zusätzlich zum Hosting: Pflege deiner Website, beliebig viele Postfächer und 1 Stunde Arbeitszeit pro Monat. Das Hosting-Abo endet mit dem Wechsel im gegenseitigen Einvernehmen; Domain, Postfächer und gebuchter Speicher bleiben.' },
...(units > 0
? [{ label: 'Zusatzspeicher', text: `${units} Paket${units === 1 ? '' : 'e'} (+${units * (Number(mail.gbPerUnit) || 0)} GB) bleiben gebucht: ${fmtEuro(units * unitCents / 100)} pro Monat zusätzlich (${UST})` }]
: []),
{ label: 'Heute fällig', text: `${fmtEuro(p.amountDueCents / 100)} (${UST})` },
{ label: 'Neuer Abbuchungstag', text: `${tag ? `Ab dem Wechsel jeweils am ${tag.t}. eines Monats; ` : ''}nächste Abbuchung am ${fmtDatum(p.nextChargeAt)}` },
@@ -119,7 +135,8 @@ export async function zeigeWechsel(wurzel, { route, me, nav, daten } = {}) {
h('div', { class: 'kn-aktionen' },
h('button', { type: 'button', class: K.primaer, id: 'switch-buy', disabled: lauf.busy || viewAs || lauf.loading || !p || gesperrt, on: { click: absenden } }, lauf.busy ? 'Wird bestellt …' : (T.knopf || KNOPF)),
viewAs ? h('span', { class: K.meldung }, TEXT_SPERRE) : null),
baueKundenartZeile(kundenart, T.kundenart, { id: 'switch-kundenart' })))
baueKundenartZeile(kundenart, T.kundenart, { id: 'switch-kundenart' }),
p ? baueEinzelheiten(einzelheitenVon(p), { id: 'switch-einzelheiten' }) : null))
}
lauf.zeichne = zeichne
+14 -2
View File
@@ -336,6 +336,9 @@
.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-einzelheiten { margin: 20px 0 0; }
.kn-einzelheiten > h3 { margin: 0 0 8px; font-size: 14px; line-height: 20px; font-weight: 600; color: var(--kn-text-2); }
.kn-einzelheiten .kn-block { color: var(--kn-text-2); }
.kn-gutschrift-bedingungen { max-width: 640px; }
/* ------------------------------------------------------------------ Handy --- */
@@ -353,8 +356,17 @@
.kn-pw { max-width: none; }
.kn-tk-feld input:not([type="checkbox"]):not([type="file"]) { height: 44px; font-size: 16px; }
.kn-stepper .kn-knopf { min-width: 44px; }
.kn-block-zeile { grid-template-columns: minmax(0, 1fr); gap: 0; padding: 6px 0; }
.kn-block dt { font-size: 12px; line-height: 16px; }
/* Bestellseiten (F1): Kerninfo und Knopf müssen im nutzbaren Bereich zwischen Kopf und Leiste zusammen stehen (375 x 667: gut 550 px).
Darum läuft die Beschriftung in der Zeile mit (Label: Text) statt eine eigene Zeile zu brauchen, und der Abstand ist knapp. */
.kn-bestellen > h2 { margin: 0 0 6px; font-size: 17px; line-height: 24px; font-weight: 600; }
.kn-block { margin-bottom: 10px; }
.kn-block-zeile { display: block; padding: 5px 0; }
.kn-block dt, .kn-block dd { display: inline; }
.kn-block dt { font-weight: 600; color: var(--kn-text); }
.kn-block dt::after { content: ": "; }
.kn-linkzeile { margin-bottom: 8px; }
.kn-bestellen .kn-check { margin-bottom: 8px; }
.kn-sperrzeile { margin-bottom: 8px; }
.kn-bestellen .kn-knopf { width: 100%; min-height: 44px; }
.kn .kn-chat .chat-bubble { max-width: 88%; }
.kn .kn-chat .chat-messages { min-height: 240px; max-height: 55vh; }
+23 -2
View File
@@ -70,9 +70,30 @@ export function baueKundenartZeile(kundenart, texte, { id } = {}) {
return h('p', { id, class: 'kn-kundenart-zeile' }, lage, ' · ', korrektur)
}
/** Block „Was du bestellst“: zeilen [{ id?, label, text, klasse? }] als Beschriftungsliste. Der Text ist immer Text. */
/**
* Block „Was du bestellst“: zeilen [{ id?, label, text, klasse?, link? }] als Beschriftungsliste. Der Text ist immer Text.
* link { text, href }: hinter dem Text ' · ' und ein Link (nur mit sicherer Adresse; Adressen außerhalb der Seite öffnen in neuem Tab).
*/
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, class: z.klasse }, z.text))))
return h('dl', { id, class: 'kn-block' }, (zeilen || []).map((z) => {
const href = z.link ? sicherLink(z.link.href) : ''
const link = href ? [' · ', h('a', { href, ...(ausserhalb(href) ? { target: '_blank', rel: 'noopener' } : {}) }, z.link.text)] : null
return h('div', { class: 'kn-block-zeile' }, h('dt', null, z.label), h('dd', { id: z.id, class: z.klasse }, z.text, link))
}))
}
/**
* Die ausführlichen Sätze UNTER dem Knopf (und der Kundenart-Zeile): Überschrift „Im Einzelnen“ und dieselbe Beschriftungsliste wie der Block.
* Der Block über dem Knopf trägt die Kerninfo knapp, damit sie mit dem Knopf auf einen Handy-Bildschirm passt; hier steht sie ausführlich.
*/
export function baueEinzelheiten(zeilen, { id } = {}) {
return h('section', { id, class: 'kn-einzelheiten' }, h('h3', null, 'Im Einzelnen'), baueBlock(zeilen))
}
/** Adresse der Leistungsbeschreibung: die Bedingungen (erste Adresse der Linkzeile) mit Anker ('wartung', 'hosting'); unsicher oder fehlend ''. */
export function leistungLink(linkzeile, anker) {
const basis = sicherLink(Array.isArray(linkzeile?.urls) ? linkzeile.urls[0] : '')
return basis && !basis.includes('#') ? `${basis}#${anker}` : ''
}
const nichtLeer = (x) => typeof x === 'string' && x !== ''
+25 -12
View File
@@ -8,7 +8,7 @@ import { NEU, TERMS_URL, meMit, aboNeu } from './support/knBestellDaten.js'
const route = (produkt) => ({ bereich: 'vertraege', etage: 'bestellen', produkt, query: '' })
// fmtEuro setzt ein geschütztes Leerzeichen vor das Euro-Zeichen
const eur = (text) => text.replaceAll(' €', '\u00a0€')
const IDS = ['abo-block', 'abo-linkzeile', 'abo-beginn', 'abo-error', 'abo-buy', 'abo-kundenart']
const IDS = ['abo-block', 'abo-linkzeile', 'abo-beginn', 'abo-error', 'abo-buy', 'abo-kundenart', 'abo-einzelheiten']
/** Seite zeigen; die Titel, die die Ansicht meldet (Ereignis kn:titel), sammelt fx.titel. */
async function zeige(fx, daten, meDaten, produkt = 'wartung') {
@@ -24,11 +24,11 @@ const reihe = (fx, w) => fx.alle(fx.finde(w, '#abo-order'), (el) => IDS.includes
test('Betrieb: Block, Linkzeile, Knopf, Kundenart-Zeile in dieser Reihenfolge, ohne Häkchen und ohne Klappe', mit({}, async (fx) => {
const w = await zeige(fx, aboNeu(), meMit('unternehmen'))
assert.deepEqual(reihe(fx, w), ['abo-block', 'abo-linkzeile', 'abo-error', 'abo-buy', 'abo-kundenart'])
assert.deepEqual(reihe(fx, w), ['abo-block', 'abo-linkzeile', 'abo-error', 'abo-buy', 'abo-kundenart', 'abo-einzelheiten'])
assert.equal(fx.finde(w, '#abo-buy').textContent, 'Zahlungspflichtig bestellen')
assert.equal(fx.alle(w, 'details, dialog, input[type="radio"], input[type="checkbox"]').length, 0)
const t = fx.text(fx.finde(w, '#abo-block'))
for (const s of ['Leistung', 'Preis', 'Fällig', 'Beginn', 'Laufzeit', 'Zahlung', '50,00', 'gemäß § 19 UStG ohne Umsatzsteuer', 'Heute fällig: 50,00']) assert.ok(t.includes(s), s)
for (const s of ['Leistung', 'Preis', 'Fällig', 'Beginn', 'Laufzeit', 'Zahlung', '50,00', 'gemäß § 19 UStG ohne Umsatzsteuer', 'heute 50,00']) assert.ok(t.includes(s), s)
// die Zeilen „Anbieter“ und „Abrechnung“ entfallen (N37); das Wort steht nur im Satz zur Kündigung („Abrechnungsmonats“)
assert.deepEqual(fx.alle(fx.finde(w, '#abo-block'), 'dt').map((e) => fx.text(e)), ['Leistung', 'Preis', 'Fällig', 'Beginn', 'Laufzeit', 'Zahlung'])
assert.equal(fx.text(fx.finde(w, '#abo-start-summary')), 'sofort')
@@ -37,14 +37,27 @@ test('Betrieb: Block, Linkzeile, Knopf, Kundenart-Zeile in dieser Reihenfolge, o
assert.equal(fx.text(fx.finde(w, '#abo-linkzeile')), 'Es gelten die Vertragsbedingungen mit Leistungsbeschreibung, Auftragsverarbeitung und Widerrufsbelehrung (Fassung ' + NEU + '). Alles kommt danach auch als PDF per Mail. Datenschutz')
}))
test('Block: feste Zeilentexte (Preis, Laufzeit, Zahlung)', mit({}, async (fx) => {
test('Block: knappe Zeilentexte über dem Knopf, die ausführlichen Sätze unter dem Knopf (F1)', mit({}, async (fx) => {
const w = await zeige(fx, aboNeu(), meMit('unternehmen'))
const zeilen = fx.alle(fx.finde(w, '#abo-block'), '.kn-block-zeile').map((z) => [fx.text(fx.finde(z, 'dt')), fx.text(fx.finde(z, 'dd'))])
assert.deepEqual(zeilen.map(([l]) => l), ['Leistung', 'Preis', 'Fällig', 'Beginn', 'Laufzeit', 'Zahlung'])
assert.deepEqual(zeilen[0], ['Leistung', 'WEBklar Wartungs-Abo: Wartung'])
assert.deepEqual(zeilen[1], ['Preis', eur('50,00 € pro Monat (Gesamtpreis, gemäß § 19 UStG ohne Umsatzsteuer), keine weiteren Kosten')])
assert.deepEqual(zeilen[4], ['Laufzeit', 'Mindestlaufzeit 12 Monate ab Leistungsbeginn, kündbar zum Ende der Mindestlaufzeit bis einen Monat vorher; danach jederzeit zum Ende des Abrechnungsmonats kündbar'])
assert.deepEqual(zeilen[5], ['Zahlung', 'Kreditkarte oder SEPA-Lastschrift über Stripe; die Zahlungsart hinterlegst du im nächsten Schritt, bis zum Abschluss dort kannst du abbrechen'])
// Leistung: eine kurze Zeile mit Link auf die Leistungsbeschreibung B (Wartung), kein Aufzählungstext
assert.deepEqual(zeilen[0], ['Leistung', 'Wartungs-Abo · Leistungen ansehen'])
assert.deepEqual([fx.finde(fx.finde(w, '#abo-block'), 'a').getAttribute('href'), fx.finde(fx.finde(w, '#abo-block'), 'a').getAttribute('target')], [`${TERMS_URL}#wartung`, '_blank'])
assert.deepEqual(zeilen[1], ['Preis', eur('50,00 € pro Monat (Gesamtpreis, gemäß § 19 UStG ohne Umsatzsteuer)')])
assert.deepEqual(zeilen[2], ['Fällig', eur('heute 50,00 €')])
assert.deepEqual(zeilen[4], ['Laufzeit', 'Mindestlaufzeit 12 Monate, kündbar bis 1 Monat vor Ende; danach monatlich'])
assert.deepEqual(zeilen[5], ['Zahlung', 'Karte oder SEPA über Stripe'])
// jede Zeile höchstens 110 Zeichen: sonst passt die Kerninfo am Handy nicht mehr auf den Bildschirm
for (const [l, t] of zeilen) assert.ok(t.length <= 110, `${l}: ${t.length} Zeichen`)
// unter dem Knopf und der Kundenart-Zeile: die ausführlichen Sätze, ohne Klappe
const ein = fx.finde(w, '#abo-einzelheiten')
assert.equal(fx.alle(w, '#abo-order > *').map((e) => e.id).filter(Boolean).at(-1), 'abo-einzelheiten')
const z2 = fx.alle(ein, '.kn-block-zeile').map((z) => [fx.text(fx.finde(z, 'dt')), fx.text(fx.finde(z, 'dd'))])
assert.deepEqual(z2.map(([l]) => l), ['Laufzeit', 'Zahlung', 'Kosten'])
assert.equal(z2[0][1], 'Mindestlaufzeit 12 Monate ab Leistungsbeginn, kündbar zum Ende der Mindestlaufzeit bis einen Monat vorher; danach jederzeit zum Ende des Abrechnungsmonats kündbar')
assert.equal(z2[1][1], 'Kreditkarte oder SEPA-Lastschrift über Stripe; die Zahlungsart hinterlegst du im nächsten Schritt, bis zum Abschluss dort kannst du abbrechen')
assert.equal(z2[2][1], 'keine weiteren Kosten')
}))
test('Titel: Produktname als Ereignis, keine eigene h1 (P2, P1 VF11)', mit({}, async (fx) => {
@@ -65,11 +78,11 @@ test('Betrieb: Body ohne earlyStart, lateStartAt, startChoice und Kundenart', mi
test('Privat und leer: ein Häkchen aus dem Wortlaut, ungehakt nach der Frist, gehakt sofort', mit({ 'POST /api/abo/checkout': { url: 'https://checkout.stripe.test/x' } }, async (fx) => {
for (const m of [meMit('privat'), meMit('')]) {
const w = await zeige(fx, aboNeu(), m)
assert.deepEqual(reihe(fx, w), ['abo-block', 'abo-linkzeile', 'abo-beginn', 'abo-error', 'abo-buy', 'abo-kundenart'])
assert.deepEqual(reihe(fx, w), ['abo-block', 'abo-linkzeile', 'abo-beginn', 'abo-error', 'abo-buy', 'abo-kundenart', 'abo-einzelheiten'])
assert.equal(fx.finde(w, '#abo-beginn').checked, false)
assert.equal(fx.text(fx.finde(w, '#abo-beginn-text')), aboNeu().bestelltexte.abo.haken)
assert.ok(fx.text(fx.finde(w, '#abo-start-summary')).startsWith('nach Ablauf der 14-tägigen Widerrufsfrist (am '))
assert.ok(fx.text(fx.finde(w, '#abo-due-summary')).startsWith('Erste Abbuchung am '))
assert.ok(fx.text(fx.finde(w, '#abo-due-summary')).startsWith('erst am '))
assert.ok(fx.text(fx.finde(w, '#abo-kundenart')).startsWith('Du bestellst privat · '))
await fx.klick(fx.finde(w, '#abo-buy'))
await fx.ruhe()
@@ -77,7 +90,7 @@ test('Privat und leer: ein Häkchen aus dem Wortlaut, ungehakt nach der Frist, g
const v = await zeige(fx, aboNeu(), m)
await fx.klick(fx.finde(v, '#abo-beginn'))
assert.ok(fx.text(fx.finde(v, '#abo-start-summary')).startsWith('sofort, auf deinen ausdrücklichen Wunsch'))
assert.ok(fx.text(fx.finde(v, '#abo-due-summary')).startsWith('Heute fällig: '))
assert.ok(fx.text(fx.finde(v, '#abo-due-summary')).startsWith('heute '))
await fx.klick(fx.finde(v, '#abo-buy'))
await fx.ruhe()
assert.equal(fx.fetchLog.at(-1).body.earlyStart, true)
@@ -101,7 +114,7 @@ test('Angebot: Angebotsnummer im Body, Text in Abschnitt und Preiszeile', mit({}
assert.ok(fx.text(fx.finde(w, '#abo-angebot')).includes(angebot().text))
assert.ok(fx.text(fx.finde(w, '#abo-block')).includes(`individuelles Angebot: ${angebot().text}`))
assert.ok(fx.text(fx.finde(w, '#abo-block')).includes(eur('25,00 € pro Monat für die ersten 2 Monate, danach 50,00 € pro Monat')))
assert.ok(fx.text(fx.finde(w, '#abo-due-summary')).startsWith('Heute fällig: 25,00'))
assert.ok(fx.text(fx.finde(w, '#abo-due-summary')).startsWith('heute 25,00'))
await fx.klick(fx.finde(w, '#abo-buy'))
await fx.ruhe()
assert.equal(fx.fetchLog.at(-1).body.offerId, angebot().id)
+7
View File
@@ -27,3 +27,10 @@ test('Stil: Aufträge-Abschnitt, Chat-Variablen, hidden und Handy-Abfrage', () =
for (const v of ['accent', 'accent-hover', 'accent-soft', 'accent-line', 'accent-text', 'on-accent', 'line', 'muted', 'surface']) assert.match(A, new RegExp(`\\.kn-chat[^{]*\\{[^}]*--chat-${v}:`), v)
assert.match(A, /\.kn \.hidden\s*\{[^}]*display:\s*none/); assert.ok(A.includes('(max-width: 880px), (pointer: coarse) and (max-height: 500px)'))
})
test('Stil: Bestellseiten am Handy (F1): Beschriftung läuft in der Zeile mit, die Einzelheiten stehen unter dem Knopf', () => {
const block = A.slice(A.lastIndexOf('(max-width: 880px), (pointer: coarse) and (max-height: 500px)'))
assert.match(block, /\.kn-block-zeile\s*\{[^}]*display:\s*block/)
assert.match(block, /\.kn-block dt,\s*\.kn-block dd\s*\{[^}]*display:\s*inline/)
assert.match(block, /\.kn-block dt::after\s*\{[^}]*content:\s*": "/)
assert.match(A, /\.kn-einzelheiten\s*\{/)
})
+19 -6
View File
@@ -4,12 +4,12 @@ import { readFileSync } from 'node:fs'
import { zeigeWechsel, gemerkteFunktion } from '../public/neu/ansicht-wechsel.js'
import { nimmHinweis } from '../public/neu/ansicht-hilfen.js'
import { mit, me, abo, aboAntwort, neueWurzel, VORSCHAU } from './support/knDaten.js'
import { NEU, meMit, aboNeu } from './support/knBestellDaten.js'
import { NEU, TERMS_URL, meMit, aboNeu } from './support/knBestellDaten.js'
// Wechselseite im schlanken Muster (Spec 5.6, Fassung 6): Block, Linkzeile, Häkchen nur privat mit Sperrzeile, Knopf, Kundenart-Zeile
const HOSTING = (o = {}) => aboNeu({ subscription: abo({ produkt: 'hosting', canSwitch: true, storage: { units: 2, pendingSetup: false, pendingReduction: null } }), ...o })
const SRV = { 'POST /api/abo/switch/preview': VORSCHAU, 'POST /api/abo/switch': () => ({ body: { paymentUrl: 'https://pay.stripe.test/x', subscription: abo() } }) }
const IDS = ['switch-block', 'switch-linkzeile', 'switch-beginn', 'switch-sperre', 'switch-error', 'switch-buy', 'switch-kundenart']
const IDS = ['switch-block', 'switch-linkzeile', 'switch-beginn', 'switch-sperre', 'switch-error', 'switch-buy', 'switch-kundenart', 'switch-einzelheiten']
const reihe = (fx, w) => fx.alle(fx.finde(w, '#switch-order'), (el) => IDS.includes(el.id)).map((el) => el.id)
const ROUTE = { bereich: 'vertraege', etage: 'wechsel', query: '' }
@@ -27,9 +27,22 @@ test('Betrieb: Block, Linkzeile, Knopf, kein Häkchen, Knopf nach der Vorschau f
const w = await zeige(fx, HOSTING(), meMit('unternehmen'))
assert.deepEqual(fx.titel, ['Wechsel ins Wartungs-Abo'])
assert.equal(fx.alle(w, 'h1').length, 0) // Titel kommt als Ereignis, keine eigene h1
assert.deepEqual(reihe(fx, w), ['switch-block', 'switch-linkzeile', 'switch-error', 'switch-buy', 'switch-kundenart'])
const t = fx.text(fx.finde(w, '#switch-block'))
for (const s of ['Leistung', 'Wartungs-Abo', 'Zusatzspeicher', '2 Pakete (+10 GB)', 'Gutschrift für nicht genutzte Tage', '−15,00', 'Heute fällig', '30,00', 'Neuer Abbuchungstag', 'Laufzeit', 'Beginn', 'bei deiner Bank']) assert.ok(t.includes(s), s)
assert.deepEqual(reihe(fx, w), ['switch-block', 'switch-linkzeile', 'switch-error', 'switch-buy', 'switch-kundenart', 'switch-einzelheiten'])
// Block über dem Knopf: knapp (F1); Leistung als eine Zeile mit Link auf die Leistungsbeschreibung B (Wartung)
const zeilen = fx.alle(fx.finde(w, '#switch-block'), '.kn-block-zeile').map((z) => [fx.text(fx.finde(z, 'dt')), fx.text(fx.finde(z, 'dd'))])
assert.deepEqual(zeilen.map(([l]) => l), ['Leistung', 'Preis', 'Zusatzspeicher', 'Gutschrift', 'Fällig', 'Laufzeit', 'Beginn'])
assert.deepEqual(zeilen[0], ['Leistung', 'Wartungs-Abo statt Hosting-Abo · Leistungen ansehen'])
assert.equal(fx.finde(fx.finde(w, '#switch-block'), 'a').getAttribute('href'), `${TERMS_URL}#wartung`)
assert.ok(zeilen[2][1].includes('2 Pakete (+10 GB)'))
assert.ok(zeilen[3][1].startsWith('−15,00'))
assert.ok(zeilen[4][1].startsWith('heute 30,00'))
assert.equal(zeilen[5][1], 'Mindestlaufzeit bis 16.10.2027 (12 Monate), danach monatlich kündbar')
assert.ok(zeilen[6][1].startsWith('sofort mit der Zahlung'))
for (const [l, text] of zeilen) assert.ok(text.length <= 120, `${l}: ${text.length} Zeichen`)
// unter dem Knopf: die ausführlichen Sätze, ohne Klappe
assert.equal(fx.alle(w, '#switch-order > *').map((e) => e.id).filter(Boolean).at(-1), 'switch-einzelheiten')
const ein = fx.text(fx.finde(w, '#switch-einzelheiten'))
for (const x of ['Hosting-Abo endet mit dem Wechsel im gegenseitigen Einvernehmen', 'Neuer Abbuchungstag', 'nächste Abbuchung am', 'Heute fällig', 'bei deiner Bank', 'Einen späteren Beginn gibt es nicht', '12 Monate ab dem Wechsel']) assert.ok(ein.includes(x), x)
assert.equal(fx.finde(w, '#switch-buy').textContent, 'Zahlungspflichtig bestellen')
assert.equal(fx.finde(w, '#switch-buy').hasAttribute('disabled'), false)
assert.equal(fx.alle(w, 'details, dialog, input[type="checkbox"]').length, 0)
@@ -44,7 +57,7 @@ test('Betrieb: Block, Linkzeile, Knopf, kein Häkchen, Knopf nach der Vorschau f
test('Privat und leer: Knopf gesperrt mit Sperrzeile, nach dem Häkchen frei, Body mit earlyStart true', mit(SRV, async (fx) => {
for (const m of [meMit('privat'), meMit('')]) {
const w = await zeige(fx, HOSTING(), m)
assert.deepEqual(reihe(fx, w), ['switch-block', 'switch-linkzeile', 'switch-beginn', 'switch-sperre', 'switch-error', 'switch-buy', 'switch-kundenart'])
assert.deepEqual(reihe(fx, w), ['switch-block', 'switch-linkzeile', 'switch-beginn', 'switch-sperre', 'switch-error', 'switch-buy', 'switch-kundenart', 'switch-einzelheiten'])
assert.equal(fx.finde(w, '#switch-buy').hasAttribute('disabled'), true)
assert.equal(fx.text(fx.finde(w, '#switch-sperre')), HOSTING().bestelltexte.wechsel.gesperrt)
assert.equal(fx.text(fx.finde(w, '#switch-beginn-text')), HOSTING().bestelltexte.wechsel.haken)
+16 -1
View File
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { mitDom } from './support/knDaten.js'
import { TERMS_URL, meMit, aboNeu } from './support/knBestellDaten.js'
import { kundenartVon, sicherLink, linkTeile, baueLinkzeile, baueKundenartZeile, baueBlock, hatWortlaut } from '../public/neu/bestell-bausteine.js'
import { kundenartVon, sicherLink, linkTeile, baueLinkzeile, baueKundenartZeile, baueBlock, baueEinzelheiten, leistungLink, hatWortlaut } from '../public/neu/bestell-bausteine.js'
test('kundenartVon: nur exakt unternehmen ist Betrieb (E4)', () => {
for (const m of [undefined, null, {}, { customer: {} }, meMit(''), meMit('privat'), { customer: { kundenartWirksam: 'Unternehmen' } }, { customer: { kundenart: 'unternehmen' } }]) {
@@ -52,6 +52,21 @@ test('baueLinkzeile, baueKundenartZeile, baueBlock', () =>
for (const el of [p, dl, baueKundenartZeile('privat', T, { id: 'k' })]) assert.equal(fx.alle(el, 'h1').length, 0)
}))
test('baueBlock: Link in der Zeile, nur sichere Adressen; baueEinzelheiten steht unter dem Knopf mit eigener Überschrift', () =>
mitDom((fx) => {
const dl = baueBlock([{ label: 'Leistung', text: 'Wartungs-Abo', link: { text: 'Leistungen ansehen', href: `${TERMS_URL}#wartung` } }, { label: 'Preis', text: '50', link: { text: 'x', href: 'javascript:alert(1)' } }], { id: 'blk' })
const [erste, zweite] = fx.alle(dl, 'dd')
assert.equal(fx.text(erste), 'Wartungs-Abo · Leistungen ansehen')
assert.deepEqual([fx.finde(erste, 'a').getAttribute('href'), fx.finde(erste, 'a').getAttribute('target'), fx.finde(erste, 'a').getAttribute('rel')], [`${TERMS_URL}#wartung`, '_blank', 'noopener'])
assert.equal(fx.text(zweite), '50', 'unsichere Adresse: kein Link, auch kein Trenner')
assert.equal(fx.alle(zweite, 'a').length, 0)
const e = baueEinzelheiten([{ label: 'Zahlung', text: 'Karte' }], { id: 'ein' })
assert.deepEqual([e.tagName, e.id, fx.text(fx.finde(e, 'h3')), fx.alle(e, 'dt').length, fx.alle(e, 'details, dialog, h1').length], ['SECTION', 'ein', 'Im Einzelnen', 1, 0])
// Adresse der Leistungsbeschreibung: die Bedingungen mit Anker; unsichere oder fehlende Adresse ergibt ''
assert.equal(leistungLink({ urls: [TERMS_URL, '/datenschutz.html'] }, 'wartung'), `${TERMS_URL}#wartung`)
for (const schlecht of [undefined, {}, { urls: [] }, { urls: ['javascript:alert(1)'] }, { urls: ['//evil.example/x'] }]) assert.equal(leistungLink(schlecht, 'wartung'), '')
}))
test('hatWortlaut', () => {
assert.equal(hatWortlaut(aboNeu()), true)
for (const d of [null, undefined, {}, { bestelltexte: {} }, { bestelltexte: { status: 'entwurf', knopf: 'x' } }, { bestelltexte: { status: 'entwurf', abo: { linkzeile: { text: 'a' } } } }]) assert.equal(hatWortlaut(d), false)