Kundenansicht neu P7: Bestellseite schlank (Block, Linkzeile, Häkchen nur privat)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-16 20:11:44 +00:00
1 parent fcf357c0b7
commit c6ecb5242c
2 files changed
+254 -120

No files matched your search

+99 -80
View File
@@ -1,17 +1,21 @@
/* Kundenansicht neu (P2): Bestellseite je Abo (#bestellen-hosting, #bestellen-wartung), Text und Logik 1:1 aus app.js renderOrderView
(Fassung 5, Sie-Form, C14). Die Bestellübersicht steht unmittelbar über dem Bestellknopf (§ 312j BGB), ohne Klappe dazwischen.
Ein geänderter Satz wäre eine unbemerkte Rechtsänderung: das Wortlaut-Orakel vergleicht den sichtbaren Text Zeichen für Zeichen.
Neu sind nur der entfallene Seitenkopf (Titel zeichnet die Hülle), der Titel-Hinweis und die Sperre im Ansichtsmodus. */
/* 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.
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. */
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'
const laeufe = new WeakMap() // Wurzel → Nummer des jüngsten Aufrufs
const BEDINGUNGEN = '/abo-bedingungen.html'
const TEXT_SPERRE = 'Im Ansichtsmodus nicht möglich.'
const TEXT_NICHT_VERFUEGBAR = 'Die Online-Bestellung wird gerade eingerichtet – schreiben Sie uns gern im Chat.'
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 TEXT_ZAHLUNGSSEITE = 'Die Zahlungsseite konnte nicht geöffnet werden. Bitte versuche es später noch einmal.'
const KNOPF = 'Zahlungspflichtig bestellen'
const UST = 'gemäß § 19 UStG ohne Umsatzsteuer'
/** 'hosting' | 'wartung' aus der Route, sonst aus dem Hash (#bestellen-hosting), sonst ''. */
export function produktAusRoute(route, hash = (typeof window !== 'undefined' ? window.location?.hash : '') || '') {
@@ -22,106 +26,120 @@ export function produktAusRoute(route, hash = (typeof window !== 'undefined' ? w
const produktVon = (daten, key) => (daten?.products || []).find((p) => p.key === key) || null
const labelVon = (daten, key) => (produktVon(daten, key)?.name || (key === 'hosting' ? 'Hosting-Abo' : 'Wartungs-Abo')).replace(/^WEBklar\s+/, '')
const reihe = (titel, ...inhalt) => h('tr', null, h('td', null, titel), ...inhalt)
function bestellung(daten, productKey, viewAs) {
const product = produktVon(daten, productKey)
const offer = daten.offers?.[productKey] || null
const price = fmtEuro(product.amountCents / 100)
const lateStart = daten.lateStartAt ? fmtDatum(daten.lateStartAt) : ''
// Betrag der ersten Abbuchung: Angebotspreis während des Rabatts, sonst Katalogpreis
/** Preiszeile und Betrag der ersten Abbuchung aus Katalog und Angebot (während des Rabatts der Angebotspreis, sonst der Katalogpreis). */
function preise(product, offer) {
const offerMonthly = Number(offer?.monthlyPriceCents)
const offerRegular = Number(offer?.regularPriceCents) || product.amountCents
const offerMonths = Number(offer?.months)
const hasOfferNumbers = Boolean(offer) && Number.isFinite(offerMonthly) && offer.monthlyPriceCents != null && offerMonths > 0
const firstCents = hasOfferNumbers ? offerMonthly : product.amountCents
const firstAmount = fmtEuro(firstCents / 100)
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`
: 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`
return { preisZeile, erste: fmtEuro((mitZahlen ? offerMonthly : product.amountCents) / 100) }
}
const start = (wert, fett, rest) => h('label', { class: 'kn-check' },
h('input', { type: 'radio', name: 'abo-start-choice', value: wert, disabled: viewAs }),
h('span', null, h('strong', null, fett), rest))
const dueZelle = h('td', { id: 'abo-due-summary' }, 'Bitte oben wählen: sofort oder nach Ablauf der 14-tägigen Widerrufsfrist')
const startZelle = h('td', { id: 'abo-start-summary' }, `Bitte oben wählen: sofort oder nach Ablauf der 14-tägigen Widerrufsfrist${lateStart ? ` (am ${lateStart})` : ''}`)
const terms = h('input', { type: 'checkbox', id: 'abo-terms', disabled: viewAs })
/** Texte der Zeilen „Fällig“ und „Beginn“ je nach Lage: sofort (Betrieb), sofort auf Wunsch (Privat mit Häkchen), nach der Frist (Privat ohne). */
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`,
}
}
return {
faellig: `Heute fällig: ${erste} (${UST})`,
beginn: lage === 'wunsch' ? 'sofort, auf deinen ausdrücklichen Wunsch vor Ablauf der Widerrufsfrist' : 'sofort',
}
}
function angebotAbschnitt(offer) {
if (!offer) return null
return h('section', { class: 'kn-angebot-kasten', id: 'abo-angebot' },
h('h2', null, 'Dein persönliches Angebot'),
h('p', null, `${offer.text}. Du sparst ${fmtEuro(offer.savingsCents / 100)}.`),
offer.note ? h('p', { class: 'kn-hinweis' }, offer.note) : null,
h('p', { class: 'kn-hinweis' }, `Gültig bis ${fmtDatum(offer.validUntil)} · wird beim Abschluss automatisch berücksichtigt`))
}
function leistungenAbschnitt(product) {
return h('section', { class: 'kn-panel', id: 'abo-leistungen' },
h('ul', { class: 'kn-leistungen' }, (product.features || []).map(([titel, detail]) => h('li', null, titel, h('small', null, detail)))),
h('p', { class: 'kn-hinweis' }, 'Schon bei einem anderen Anbieter für Domain, Hosting oder E-Mail bezahlt? Nach dem Abschluss kannst du eine Übernahmegutschrift beantragen – wir rechnen die doppelt bezahlte Zeit anteilig an.'))
}
function bestellung(daten, productKey, { viewAs, kundenart }) {
const product = produktVon(daten, productKey)
const T = daten.bestelltexte
const offer = daten.offers?.[productKey] || null
const privat = kundenart !== 'unternehmen'
const frist = daten.lateStartAt ? fmtDatum(daten.lateStartAt) : ''
const { preisZeile, erste } = preise(product, offer)
// Betrieb: sofort, ohne Auswahl. Privat und leer: ohne Häkchen nach der Frist, mit Häkchen sofort.
const start = faelligBeginn({ lage: privat ? 'frist' : 'sofort', erste, frist })
const block = baueBlock([
{ label: 'Leistung', text: `${product.name}: ${(product.features || []).map(([t]) => t).join(', ')}` },
{ 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 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' })
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 })
const kaufen = h('button', { type: 'button', class: K.primaer, id: 'abo-buy', disabled: viewAs }, 'Zahlungspflichtig bestellen')
const kaufen = h('button', { type: 'button', class: K.primaer, id: 'abo-buy', disabled: viewAs }, T.knopf || KNOPF)
const zeigeFehler = (text) => { fehler.textContent = text; fehler.hidden = false }
const rootInfo = h('div', { class: 'kn-panel', id: 'abo-order' },
h('h3', null, 'Ihre Bestellung'),
const abschnitt = h('section', { class: 'kn-panel kn-bestellen', id: 'abo-order' },
h('h2', null, 'Was du bestellst'),
daten.testAccount ? h('div', { class: 'kn-meldung kn-meldung-warn' }, `Testkonto: Bei Stripe wird ein Test-Rabatt verrechnet, abgebucht werden nur 0,50 € pro Monat.${offer ? ' Das Angebot wird nach dem Abschluss zusätzlich ans Abo gehängt.' : ''}`) : null,
h('label', { class: 'kn-check' }, terms,
h('span', null, 'Ich habe die ', link(BEDINGUNGEN, 'Vertragsbedingungen mit Leistungsbeschreibung und Auftragsverarbeitungsvertrag'), ' gelesen und stimme ihnen zu. Die ', link(`${BEDINGUNGEN}#widerruf`, 'Widerrufsbelehrung'), ' habe ich zur Kenntnis genommen.')),
h('fieldset', { class: 'kn-beginn' },
h('legend', null, 'Wann sollen wir mit der Leistung beginnen?'),
start('sofort', 'Sofort.', ' Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit der Leistung beginnt. Mir ist bekannt, dass ich bei einem Widerruf einen angemessenen Betrag für die bis dahin erbrachten Leistungen zahle.'),
start('nach_frist', 'Nach Ablauf der 14-tägigen Widerrufsfrist', `${lateStart ? ` (am ${lateStart})` : ''}. Bis dahin erbringt WEBklar keine Leistungen und bucht nichts ab.`),
h('p', { class: 'kn-hinweis' }, 'Firmenkunden haben kein Widerrufsrecht und wählen am besten „Sofort“.')),
h('div', { class: 'kn-uebersicht' },
h('table', { class: 'kn-zusammenfassung' }, h('tbody', null,
reihe('Leistung', h('td', null, `${product.name}: ${(product.features || []).map(([t]) => t).join(', ')}`)),
reihe('Preis', h('td', null, ...(offer
? [hasOfferNumbers ? h('strong', null, `${fmtEuro(offerMonthly / 100)} pro Monat für die ersten ${offerMonths} Monate`) : null,
hasOfferNumbers ? `, danach ${fmtEuro(offerRegular / 100)} pro Monat` : null,
' ', h('span', null, `(individuelles Angebot: ${offer.text}; Gesamtpreis, gemäß § 19 UStG ohne Umsatzsteuer)`), ', keine weiteren Kosten']
: [h('strong', null, `${price} pro Monat`), ' (Gesamtpreis, gemäß § 19 UStG ohne Umsatzsteuer), keine weiteren Kosten']))),
reihe('Abrechnung', h('td', null, 'monatlich im Voraus, erstmals zum Leistungsbeginn')),
reihe('Fällig', dueZelle),
reihe('Leistungsbeginn', startZelle),
reihe('Laufzeit', h('td', null, h('strong', null, `Mindestlaufzeit ${product.minTermMonths} Monate`), ' ab Leistungsbeginn, kündbar zum Ende der Mindestlaufzeit bis einen Monat vorher; danach unbefristet und jederzeit zum Ende des Abrechnungsmonats kündbar')),
reihe('Zahlung', h('td', null, 'Kreditkarte oder SEPA-Lastschrift über Stripe')),
reihe('Anbieter', h('td', null, 'WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler · Telefon 0176 23726355 · support@webklar.com')))),
h('p', { class: 'kn-hinweis' }, 'Es gilt das gesetzliche Mängelhaftungsrecht. Einzelheiten: ', link(BEDINGUNGEN, 'Vertragsbedingungen'), ' · ', link('/datenschutz.html', 'Datenschutz'))),
block,
baueLinkzeile(T.abo.linkzeile, { id: 'abo-linkzeile' }),
haken ? h('label', { class: K.check }, haken, h('span', { id: 'abo-beginn-text' }, T.abo.haken)) : null,
fehler,
h('div', { class: 'kn-aktionen' }, kaufen,
h('span', { class: 'kn-hinweis' }, 'Danach hinterlegen Sie bei unserem Zahlungsdienstleister Stripe Ihre Zahlungsart. Bis zum Abschluss dort können Sie jederzeit abbrechen.'),
viewAs ? h('span', { class: K.meldung }, TEXT_SPERRE) : null))
h('div', { class: 'kn-aktionen' }, kaufen, viewAs ? h('span', { class: K.meldung }, TEXT_SPERRE) : null),
baueKundenartZeile(kundenart, T.kundenart, { id: 'abo-kundenart' }))
// Die zwei Zeilen "Fällig" und "Leistungsbeginn" folgen der Wahl
rootInfo.querySelectorAll('input[name="abo-start-choice"]').forEach((radio) => radio.addEventListener('change', () => {
dueZelle.textContent = radio.value === 'sofort'
? `Heute fällig: ${firstAmount} (gemäß § 19 UStG ohne Umsatzsteuer)`
: `Erste Abbuchung am ${lateStart || 'Ende der 14-tägigen Widerrufsfrist'}: ${firstAmount} (gemäß § 19 UStG ohne Umsatzsteuer); heute wird nichts abgebucht`
startZelle.textContent = radio.value === 'sofort'
? 'sofort, auf Ihren ausdrücklichen Wunsch vor Ablauf der Widerrufsfrist'
: `nach Ablauf der 14-tägigen Widerrufsfrist${lateStart ? ` (am ${lateStart})` : ''}; bis dahin keine Leistung und keine Abbuchung`
}))
// Das Häkchen schaltet die Zeilen „Fällig“ und „Beginn“ um
haken?.addEventListener('change', () => {
const neu = faelligBeginn({ lage: haken.checked ? 'wunsch' : 'frist', erste, frist })
abschnitt.querySelector('#abo-due-summary').textContent = neu.faellig
abschnitt.querySelector('#abo-start-summary').textContent = neu.beginn
})
kaufen.addEventListener('click', async () => {
if (viewAs) return
fehler.hidden = true
const acceptTerms = terms.checked
const startChoice = rootInfo.querySelector('input[name="abo-start-choice"]:checked')?.value || ''
if (!acceptTerms) { zeigeFehler('Bitte stimmen Sie den Vertragsbedingungen zu.'); return }
if (!startChoice) { zeigeFehler('Bitte wählen Sie, wann wir mit der Leistung beginnen sollen.'); return }
kaufen.disabled = true
kaufen.textContent = 'Weiter zur Zahlung…'
try {
// Der Klick auf den Knopf ist die Zustimmung (acceptTerms). Nie kundenart oder startChoice: den Beginn bestimmt der Server aus der Kundenart.
const { url } = await api('/api/abo/checkout', {
method: 'POST',
body: { productKey, acceptTerms, startChoice, termsVersion: daten.termsVersion, offerId: offer?.id || '', lateStartAt: daten.lateStartAt || '' },
body: {
productKey,
acceptTerms: true,
termsVersion: daten.termsVersion,
offerId: offer?.id || '',
...(privat ? { earlyStart: haken.checked, lateStartAt: daten.lateStartAt || '' } : {}),
},
})
const ziel = sichereAdresse(url)
if (!ziel) throw new Error('Die Zahlungsseite konnte nicht geöffnet werden. Bitte später noch einmal versuchen.')
if (!ziel) throw new Error(TEXT_ZAHLUNGSSEITE)
window.location.href = ziel
} catch (f) {
zeigeFehler(f.message)
kaufen.disabled = false
kaufen.textContent = 'Zahlungspflichtig bestellen'
kaufen.textContent = T.knopf || KNOPF
}
})
const angebotKasten = offer
? h('div', { class: 'kn-angebot-kasten' },
h('strong', null, 'Ihr persönliches Angebot'),
h('p', null, `${offer.text}. Sie sparen ${fmtEuro(offer.savingsCents / 100)}.`),
offer.note ? h('p', { class: 'kn-hinweis' }, offer.note) : null,
h('p', { class: 'kn-hinweis' }, `Gültig bis ${fmtDatum(offer.validUntil)} · wird beim Abschluss automatisch berücksichtigt`))
: null
const leistungen = h('div', { class: 'kn-panel' },
h('ul', { class: 'kn-leistungen' }, (product.features || []).map(([titel, detail]) => h('li', null, titel, h('small', null, detail)))),
h('p', { class: 'kn-hinweis' }, 'Schon bei einem anderen Anbieter für Domain, Hosting oder E-Mail bezahlt? Nach dem Abschluss können Sie eine Übernahmegutschrift beantragen – wir rechnen die doppelt bezahlte Zeit anteilig an.'))
return [angebotKasten, leistungen, rootInfo]
return [angebotAbschnitt(offer), leistungenAbschnitt(product), abschnitt]
}
/** Bestellseite. Das Produkt kommt aus route.produkt, hilfsweise aus dem Hash; daten (GET /api/abo) ist optional. */
@@ -136,9 +154,10 @@ export async function zeigeBestellen(wurzel, { route, me, nav, daten } = {}) {
const key = produktAusRoute(route)
const product = key ? produktVon(stand, key) : null
if (key) meldeTitel(wurzel, labelVon(stand, key))
if (!product || !stand.available) {
// Ohne Wortlaut vom Server gibt es keinen Bestellknopf
if (!product || !stand.available || !hatWortlaut(stand)) {
leeren(wurzel).append(h('div', { class: 'kn-panel' }, h('p', { class: 'kn-hinweis' }, TEXT_NICHT_VERFUEGBAR)))
return
}
leeren(wurzel).append(...bestellung(stand, key, me?.viewAs === true).filter(Boolean))
leeren(wurzel).append(...bestellung(stand, key, { viewAs: me?.viewAs === true, kundenart: kundenartVon(me, stand) }).filter(Boolean))
}
+155 -40
View File
@@ -1,53 +1,168 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { zeigeBestellen, produktAusRoute } from '../public/neu/ansicht-bestellen.js'
import { sichtbarerText, ohneKopf, ladeAlteApp } from './support/knWortlautOrakel.js'
import { mit, me, aboAntwort, angebot, neueWurzel } from './support/knDaten.js'
import { NEU, TERMS_URL, meMit, aboNeu } from './support/knBestellDaten.js'
const alt = ladeAlteApp()
// Bestellseite im schlanken Muster (Spec 5.6, Fassung 6): Block, Linkzeile, Häkchen nur privat, Knopf, Kundenart-Zeile
const route = (produkt) => ({ bereich: 'vertraege', etage: 'bestellen', produkt, query: '' })
for (const key of ['hosting', 'wartung']) for (const [name, daten] of [['ohne Angebot', aboAntwort()], ['mit Angebot', aboAntwort({ offers: { hosting: angebot(), wartung: angebot() } })], ['Testkonto', aboAntwort({ testAccount: true })], ['nicht verfügbar', aboAntwort({ available: false })]]) {
test(`Bestellen ${key} (${name}): sichtbarer Text wie in app.js`, mit({}, async (fx) => {
const w = neueWurzel(fx); await zeigeBestellen(w, { route: route(key), me: me(), nav: 'push', daten }); await fx.ruhe()
assert.equal(sichtbarerText(w), sichtbarerText(ohneKopf(alt.bestellen(daten, key))))
}))
// 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']
/** Seite zeigen; die Titel, die die Ansicht meldet (Ereignis kn:titel), sammelt fx.titel. */
async function zeige(fx, daten, meDaten, produkt = 'wartung') {
fx.titel = fx.titel ?? []
const w = neueWurzel(fx)
w.addEventListener('kn:titel', (e) => fx.titel.push(e.detail.text))
await zeigeBestellen(w, { route: route(produkt), me: meDaten, nav: 'push', daten })
await fx.ruhe()
return w
}
test('produktAusRoute', () => { assert.equal(produktAusRoute({ produkt: 'wartung' }, ''), 'wartung'); assert.equal(produktAusRoute({}, '#bestellen-hosting'), 'hosting'); assert.equal(produktAusRoute({}, '#abo'), '') })
test('Bestellen meldet den Produktnamen als Titel', mit({}, async (fx) => {
const titel = []; const w = neueWurzel(fx); w.addEventListener('kn:titel', (e) => titel.push(e.detail.text))
await zeigeBestellen(w, { route: route('wartung'), me: me(), nav: 'push', daten: aboAntwort() }); assert.deepEqual(titel, ['Wartungs-Abo'])
/** Die Ids aus IDS in Dokumentreihenfolge unterhalb von #abo-order. */
const reihe = (fx, w) => fx.alle(fx.finde(w, '#abo-order'), (el) => IDS.includes(el.id)).map((el) => el.id)
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.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)
// 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')
assert.equal(fx.text(fx.finde(w, '#abo-kundenart')), 'Du bestellst als Betrieb · Stimmt nicht? Schreib uns im Chat')
assert.equal(fx.finde(w, '#abo-linkzeile a').getAttribute('href'), TERMS_URL)
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('Beginn-Auswahl ändert die Zeilen, Prüfungen wörtlich, Body wörtlich', mit({ 'POST /api/abo/checkout': { url: 'https://checkout.stripe.com/x' } }, async (fx) => {
const w = neueWurzel(fx); await zeigeBestellen(w, { route: route('wartung'), me: me(), nav: 'push', daten: aboAntwort() })
await fx.klick(fx.finde(w, '#abo-buy')); assert.match(fx.text(w), /Bitte stimmen Sie den Vertragsbedingungen zu\./)
await fx.klick(fx.finde(w, '#abo-terms')); await fx.klick(fx.finde(w, '#abo-buy')); assert.match(fx.text(w), /Bitte wählen Sie, wann wir mit der Leistung beginnen sollen\./)
await fx.klick(fx.finde(w, 'input[value="sofort"]'))
assert.ok(fx.text(w).includes('Heute fällig: 50,00 € (gemäß § 19 UStG ohne Umsatzsteuer)')); assert.ok(fx.text(w).includes('sofort, auf Ihren ausdrücklichen Wunsch vor Ablauf der Widerrufsfrist'))
await fx.klick(fx.finde(w, '#abo-buy')); await fx.ruhe()
assert.deepEqual(fx.fetchLog.at(-1), { method: 'POST', url: '/api/abo/checkout', body: { productKey: 'wartung', acceptTerms: true, startChoice: 'sofort', termsVersion: '2026-09-30-v5', offerId: '', lateStartAt: '2026-10-30T00:00:00.000Z' } })
assert.equal(fx.window.location.href, 'https://checkout.stripe.com/x')
test('Block: feste Zeilentexte (Preis, Laufzeit, Zahlung)', 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'])
}))
test('Bestellen: unsichere Adresse der Zahlungsseite wird nicht geöffnet, Ansichtsmodus sperrt', mit({ 'POST /api/abo/checkout': () => ({ body: { url: 'javascript:alert(1)' } }) }, async (fx) => {
const w = neueWurzel(fx); await zeigeBestellen(w, { route: route('wartung'), me: me(), nav: 'push', daten: aboAntwort() })
await fx.klick(fx.finde(w, '#abo-terms')); await fx.klick(fx.finde(w, 'input[value="nach_frist"]')); await fx.klick(fx.finde(w, '#abo-buy')); await fx.ruhe()
test('Titel: Produktname als Ereignis, keine eigene h1 (P2, P1 VF11)', mit({}, async (fx) => {
const w = await zeige(fx, aboNeu(), meMit('unternehmen'))
assert.deepEqual(fx.titel, ['Wartungs-Abo'])
assert.equal(fx.alle(w, 'h1').length, 0)
assert.deepEqual(fx.alle(w, 'h2').map((e) => fx.text(e)), ['Was du bestellst'])
}))
test('Betrieb: Body ohne earlyStart, lateStartAt, startChoice und Kundenart', mit({ 'POST /api/abo/checkout': { url: 'https://checkout.stripe.test/x' } }, async (fx) => {
const w = await zeige(fx, aboNeu(), meMit('unternehmen'))
await fx.klick(fx.finde(w, '#abo-buy'))
await fx.ruhe()
assert.deepEqual(fx.fetchLog.at(-1), { method: 'POST', url: '/api/abo/checkout', body: { productKey: 'wartung', acceptTerms: true, termsVersion: NEU, offerId: '' } })
assert.equal(fx.window.location.href, 'https://checkout.stripe.test/x')
}))
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.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-kundenart')).startsWith('Du bestellst privat · '))
await fx.klick(fx.finde(w, '#abo-buy'))
await fx.ruhe()
assert.deepEqual(fx.fetchLog.at(-1).body, { productKey: 'wartung', acceptTerms: true, earlyStart: false, termsVersion: NEU, offerId: '', lateStartAt: aboNeu().lateStartAt })
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: '))
await fx.klick(fx.finde(v, '#abo-buy'))
await fx.ruhe()
assert.equal(fx.fetchLog.at(-1).body.earlyStart, true)
await fx.klick(fx.finde(v, '#abo-beginn')) // Häkchen wieder weg: die Zeilen folgen
assert.ok(fx.text(fx.finde(v, '#abo-start-summary')).startsWith('nach Ablauf der 14-tägigen Widerrufsfrist'))
}
}))
test('Der Body enthält nie kundenart oder startChoice', mit({ 'POST /api/abo/checkout': { url: 'https://checkout.stripe.test/x' } }, async (fx) => {
for (const m of [meMit('unternehmen'), meMit('privat'), meMit('')]) {
const w = await zeige(fx, aboNeu(), m)
await fx.klick(fx.finde(w, '#abo-buy'))
await fx.ruhe()
assert.deepEqual(Object.keys(fx.fetchLog.at(-1).body).filter((k) => /kundenart|startChoice|unternehmer/i.test(k)), [])
}
}))
test('Angebot: Angebotsnummer im Body, Text in Abschnitt und Preiszeile', mit({}, async (fx) => {
const d = aboNeu({ offers: { hosting: null, wartung: angebot() } })
const w = await zeige(fx, d, meMit('unternehmen'))
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'))
await fx.klick(fx.finde(w, '#abo-buy'))
await fx.ruhe()
assert.equal(fx.fetchLog.at(-1).body.offerId, angebot().id)
}))
test('Ohne Wortlaut, nicht verfügbar oder unbekanntes Produkt: nur der Satz, kein Knopf; unsichere Linkadressen werden Text', mit({}, async (fx) => {
const satz = 'Die Online-Bestellung wird gerade eingerichtet – schreib uns gern im Chat.'
for (const d of [aboAntwort({ termsVersion: NEU }), aboNeu({ available: false })]) {
const w = await zeige(fx, d, meMit('unternehmen'))
assert.ok(fx.text(w).includes(satz))
assert.equal(fx.finde(w, '#abo-buy'), null)
}
assert.equal(fx.finde(await zeige(fx, aboNeu(), meMit('unternehmen'), 'gibtsnicht'), '#abo-buy'), null)
const boese = aboNeu()
boese.bestelltexte.abo.linkzeile.urls = ['javascript:alert(1)', '//evil.example']
assert.equal(fx.alle(await zeige(fx, boese, meMit('unternehmen')), '#abo-linkzeile a[href]').length, 0)
}))
test('produktAusRoute', () => {
assert.equal(produktAusRoute({ produkt: 'wartung' }, ''), 'wartung')
assert.equal(produktAusRoute({}, '#bestellen-hosting'), 'hosting')
assert.equal(produktAusRoute({}, '#abo'), '')
})
test('Fehler der Zahlungsseite: Text in #abo-error, Knopf zurück; unsichere Zahlungsadresse leitet nicht weiter', mit({ 'POST /api/abo/checkout': () => ({ status: 400, body: { error: 'Kaputt.' } }) }, async (fx) => {
const w = await zeige(fx, aboNeu(), meMit('unternehmen'))
await fx.klick(fx.finde(w, '#abo-buy'))
await fx.ruhe()
assert.equal(fx.text(fx.finde(w, '#abo-error')), 'Kaputt.')
assert.equal(fx.finde(w, '#abo-error').hasAttribute('hidden'), false)
assert.equal(fx.finde(w, '#abo-buy').hasAttribute('disabled'), false)
assert.equal(fx.finde(w, '#abo-buy').textContent, 'Zahlungspflichtig bestellen')
fx.tabelle['POST /api/abo/checkout'] = () => ({ body: { url: 'javascript:alert(1)' } })
await fx.klick(fx.finde(w, '#abo-buy'))
await fx.ruhe()
assert.notEqual(fx.window.location.href, 'javascript:alert(1)')
assert.equal(fx.finde(w, '#abo-buy').hasAttribute('disabled'), false); assert.equal(fx.finde(w, '#abo-buy').textContent, 'Zahlungspflichtig bestellen')
const v = neueWurzel(fx); await zeigeBestellen(v, { route: route('wartung'), me: me({ viewAs: true }), nav: 'push', daten: aboAntwort() })
assert.equal(fx.finde(v, '#abo-buy').hasAttribute('disabled'), true); assert.match(fx.text(v), /Im Ansichtsmodus nicht möglich\./)
assert.equal(fx.text(fx.finde(w, '#abo-error')), 'Die Zahlungsseite konnte nicht geöffnet werden. Bitte versuche es später noch einmal.')
assert.equal(fx.finde(w, '#abo-buy').hasAttribute('disabled'), false)
}))
test('Kerninfo und Knopf stehen ohne Klappe nebeneinander', mit({}, async (fx) => {
const w = neueWurzel(fx); await zeigeBestellen(w, { route: route('wartung'), me: me(), nav: 'push', daten: aboAntwort() })
assert.equal(fx.alle(w, 'details, dialog').length, 0); assert.ok(fx.finde(w, '#abo-order').textContent.includes('Zahlungspflichtig bestellen'))
test('Ansichtsmodus sperrt Knopf und Häkchen, Testkonto-Hinweis in du', mit({}, async (fx) => {
const v = await zeige(fx, aboNeu({ testAccount: true }), meMit('privat', { viewAs: true }))
assert.equal(fx.finde(v, '#abo-buy').hasAttribute('disabled'), true)
assert.equal(fx.finde(v, '#abo-beginn').hasAttribute('disabled'), true)
assert.match(fx.text(v), /Im Ansichtsmodus nicht möglich\./)
assert.match(fx.text(v), /Testkonto: Bei Stripe wird ein Test-Rabatt verrechnet, abgebucht werden nur 0,50 € pro Monat\./) // Literal im Quelltext mit normalem Leerzeichen
await fx.klick(fx.finde(v, '#abo-buy'))
assert.equal(fx.fetchLog.length, 0)
}))
test('Bestellen: Angebot geht als offerId mit, Fehler der Bestellung machen den Knopf wieder frei', mit({ 'POST /api/abo/checkout': () => ({ status: 400, body: { error: 'Kaputt.' } }) }, async (fx) => {
const daten = aboAntwort({ offers: { hosting: null, wartung: angebot({ id: 'o7' }) } })
const w = neueWurzel(fx); await zeigeBestellen(w, { route: route('wartung'), me: me(), nav: 'push', daten })
await fx.klick(fx.finde(w, '#abo-terms')); await fx.klick(fx.finde(w, 'input[value="nach_frist"]')); await fx.klick(fx.finde(w, '#abo-buy')); await fx.ruhe()
assert.equal(fx.fetchLog.at(-1).body.offerId, 'o7'); assert.match(fx.text(w), /Kaputt\./); assert.equal(fx.finde(w, '#abo-buy').hasAttribute('disabled'), false)
assert.match(fx.text(w), /Erste Abbuchung am 30\.10\.2026: 25,00 €/)
}))
test('Bestellen: ohne Produkt oder bei Ausfall des Abos steht nur ein Satz', mit({ 'GET /api/abo': () => ({ status: 500, body: { error: 'x' } }) }, async (fx) => {
const w = neueWurzel(fx); await zeigeBestellen(w, { route: { bereich: 'vertraege', etage: 'bestellen', query: '' }, me: me(), nav: 'push', daten: aboAntwort() }); await fx.ruhe()
assert.equal(sichtbarerText(w), sichtbarerText('Die Online-Bestellung wird gerade eingerichtet – schreiben Sie uns gern im Chat.'))
const v = neueWurzel(fx); await zeigeBestellen(v, { route: route('hosting'), me: me(), nav: 'push' }); await fx.ruhe(); assert.match(fx.text(v), /Deine Verträge konnten gerade nicht geladen werden\./)
test('Kein Abo geladen: nur der Hinweis', mit({ 'GET /api/abo': () => ({ status: 500, body: { error: 'x' } }) }, async (fx) => {
const v = await zeige(fx, undefined, me())
assert.match(fx.text(v), /Deine Verträge konnten gerade nicht geladen werden\./)
}))
test('Quelltext: kein Sie, keine Klappe, kein startChoice', async () => {
const { readFileSync } = await import('node:fs')
const q = readFileSync(new URL('../public/neu/ansicht-bestellen.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/)
const aufruf = q.slice(q.indexOf("api('/api/abo/checkout'"), q.indexOf('const ziel'))
assert.ok(aufruf.includes('acceptTerms: true'), 'der Aufruf der Bestellung ist gefunden')
assert.doesNotMatch(aufruf, /startChoice|kundenart|unternehmer/)
})