diff --git a/public/neu/ansicht-bestellen.js b/public/neu/ansicht-bestellen.js index 0dfbb34..4b54ca8 100644 --- a/public/neu/ansicht-bestellen.js +++ b/public/neu/ansicht-bestellen.js @@ -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', () => { diff --git a/public/neu/ansicht-wechsel.js b/public/neu/ansicht-wechsel.js index eddad68..7e3650c 100644 --- a/public/neu/ansicht-wechsel.js +++ b/public/neu/ansicht-wechsel.js @@ -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 diff --git a/public/neu/ansichten.css b/public/neu/ansichten.css index 7826df3..beb5817 100644 --- a/public/neu/ansichten.css +++ b/public/neu/ansichten.css @@ -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; } diff --git a/public/neu/bestell-bausteine.js b/public/neu/bestell-bausteine.js index 7478b6f..93d2319 100644 --- a/public/neu/bestell-bausteine.js +++ b/public/neu/bestell-bausteine.js @@ -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 !== '' diff --git a/test/knAnsichtBestellen.test.js b/test/knAnsichtBestellen.test.js index 0ccb4f3..0f7fc88 100644 --- a/test/knAnsichtBestellen.test.js +++ b/test/knAnsichtBestellen.test.js @@ -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) diff --git a/test/knAnsichtStil.test.js b/test/knAnsichtStil.test.js index 2e8671b..126068e 100644 --- a/test/knAnsichtStil.test.js +++ b/test/knAnsichtStil.test.js @@ -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*\{/) +}) diff --git a/test/knAnsichtWechsel.test.js b/test/knAnsichtWechsel.test.js index d38fd9c..73f3321 100644 --- a/test/knAnsichtWechsel.test.js +++ b/test/knAnsichtWechsel.test.js @@ -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) diff --git a/test/knBestellBausteine.test.js b/test/knBestellBausteine.test.js index 3a6c2f8..f10a587 100644 --- a/test/knBestellBausteine.test.js +++ b/test/knBestellBausteine.test.js @@ -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)