Funktionsmodule: gemeinsame Einschalt-Seite für das Abo-Modell

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-16 18:38:51 +00:00
1 parent a50c5ef6c9
commit 364ded1662
2 files changed
+205 -21

No files matched your search

+90 -21
View File
@@ -17,6 +17,10 @@ const RECHTLICHES = '/rechtliches/'
const KUNDENANSICHT = 'In der Kundenansicht kann nicht bestellt werden.'
const ZAHLUNGSSEITE = 'Die Zahlungsseite konnte nicht geöffnet werden. Bitte versuch es noch einmal oder schreib uns im Chat.'
const DANKE = 'Danke! Sobald die Zahlung da ist, legen wir los.'
// Abo-Modell (Texte E1, E2, E4): Einschalten statt Bestellen
const EINSCHALTEN_TITEL = 'Prüfen und einschalten'
const EINSCHALTEN_OFFEN = 'Du kannst einschalten, sobald die offenen Punkte erledigt sind.'
const EINSCHALTEN_FEHLT = 'Das Einschalten ist gerade nicht verfügbar. Bitte lade die Seite neu oder schreib uns im Chat.'
const SCHRITT_TITEL = new Map(FRAGEBOGEN_SCHRITTE)
/* ---------------------------------------------------------------- Links --- */
@@ -105,6 +109,35 @@ function ladeBeiRueckkehrNeu(element) {
window.addEventListener('pageshow', beiAnzeige)
}
/* ------------------------------------------- Zusammenfassung und offene Punkte (Bestellseite, Einschalt-Seite) --- */
/** Die Gruppen der Zusammenfassung (je Gruppe die Angaben als Text und „Ändern“, wenn es ein Ziel gibt). */
function gruppenKarten(gruppenFn, daten, springt, gehe) {
return (gruppenFn ? gruppenFn(daten.einstellung, daten.fragebogen, daten.branchen) : []).map((gruppe) => h('div', { class: 'tm-bestellen-gruppe', 'data-schritt': gruppe.schritt },
h('div', { class: 'tm-bestellen-gruppe-kopf' },
h('h4', null, gruppe.titel),
springt
? h('button', {
type: 'button', class: 'kb-btn-outline tm-knopf', 'data-aktion': 'aendern', 'data-schritt': gruppe.schritt, 'aria-label': `Ändern: ${gruppe.titel}`, onclick: () => gehe(gruppe.schritt),
}, 'Ändern')
: null),
gruppe.zeilen.length > 0 ? h('ul', null, gruppe.zeilen.map((zeile) => h('li', null, zeile))) : h('p', { class: 'tm-hinweis' }, 'Noch keine Angaben.')))
}
/** Die offenen Punkte als Block „Das fehlt noch“ (je Punkt „Zum Schritt“ nur mit Ziel); ohne Punkte null. anleitung = Satz unter der Überschrift oder null. */
function offenKarte(offen, springt, gehe, anleitung) {
if (offen.length === 0) return null
return h('div', { class: 'tm-bestellen-offen', role: 'note' },
h('h4', null, 'Das fehlt noch'),
anleitung ? h('p', { class: 'tm-hinweis' }, anleitung) : null,
h('ul', null, offen.map((punkt) => h('li', null,
h('span', null, punkt.text),
springt && SCHRITT_TITEL.has(punkt.schritt)
? h('button', {
type: 'button', class: 'kb-btn-outline tm-knopf', 'data-aktion': 'zum-schritt', 'data-schritt': punkt.schritt, 'aria-label': `Zum Schritt „${SCHRITT_TITEL.get(punkt.schritt)}“`, onclick: () => gehe(punkt.schritt),
}, 'Zum Schritt')
: null))))
}
/* ---------------------------------------------------------------- Bestellseite --- */
/**
* „Prüfen und bestellen“ in root zeichnen und die Seite zurückgeben. Reihenfolge: Zusammenfassung der Angaben (je Gruppe „Ändern“),
@@ -147,27 +180,10 @@ function baueSeite(root, { ctx, daten, zumSchritt, key, gruppenFn }, uebernahme)
const gehe = (schritt) => { if (springt) zumSchritt(schritt) }
// 1 Zusammenfassung: je Gruppe die Angaben als Text und „Ändern“
const gruppen = (gruppenFn ? gruppenFn(daten.einstellung, daten.fragebogen, daten.branchen) : []).map((gruppe) => h('div', { class: 'tm-bestellen-gruppe', 'data-schritt': gruppe.schritt },
h('div', { class: 'tm-bestellen-gruppe-kopf' },
h('h4', null, gruppe.titel),
springt
? h('button', {
type: 'button', class: 'kb-btn-outline tm-knopf', 'data-aktion': 'aendern', 'data-schritt': gruppe.schritt, 'aria-label': `Ändern: ${gruppe.titel}`, onclick: () => gehe(gruppe.schritt),
}, 'Ändern')
: null),
gruppe.zeilen.length > 0 ? h('ul', null, gruppe.zeilen.map((zeile) => h('li', null, zeile))) : h('p', { class: 'tm-hinweis' }, 'Noch keine Angaben.')))
const gruppen = gruppenKarten(gruppenFn, daten, springt, gehe)
// 2 offene Punkte: „Zum Schritt“ nur dort, wo es einen Schritt des Fragebogens gibt (der Abo-Punkt gehört zu dieser Seite)
const offenBlock = offen.length === 0 ? null : h('div', { class: 'tm-bestellen-offen', role: 'note' },
h('h4', null, 'Das fehlt noch'),
h('p', { class: 'tm-hinweis' }, 'Erledige diese Punkte, dann kannst du bestellen.'),
h('ul', null, offen.map((punkt) => h('li', null,
h('span', null, punkt.text),
springt && SCHRITT_TITEL.has(punkt.schritt)
? h('button', {
type: 'button', class: 'kb-btn-outline tm-knopf', 'data-aktion': 'zum-schritt', 'data-schritt': punkt.schritt, 'aria-label': `Zum Schritt „${SCHRITT_TITEL.get(punkt.schritt)}“`, onclick: () => gehe(punkt.schritt),
}, 'Zum Schritt')
: null))))
const offenBlock = offenKarte(offen, springt, gehe, 'Erledige diese Punkte, dann kannst du bestellen.')
// 3 Preis mit dem Hinweis des Servers und die Einrichtungsdauer
const preisBlock = h('div', { class: 'tm-bestellen-preis' },
@@ -289,12 +305,65 @@ function baueSeite(root, { ctx, daten, zumSchritt, key, gruppenFn }, uebernahme)
return seite
}
/* ------------------------------------------------------------ Einschalt-Seite (Abo-Modell) --- */
/**
* Die gemeinsame Seite der Funktionsmodule im Abo-Modell. Statt Preis, Belehrung, Haken und Knopf der Bestellseite: oben (nur wenn es etwas
* zu prüfen gibt) „Prüfen und einschalten“ mit der Zusammenfassung der Angaben (je Gruppe „Ändern“) und den offenen Punkten (je Punkt „Zum
* Schritt“), darunter der Platz, in den der Ablauf von P3 (`einschalten`, zeigeEinschalten aus public/neu/funktion-einschalten.js)
* Bestätigung und Knopf zeichnet. Bei offenen Punkten steht stattdessen der Hinweis und der Ablauf wird nicht gerufen.
* Die Seite kennt weder einen Preis noch den Checkout. daten wie bei renderBestellen (einstellung, fragebogen, branchen, offen, kundenansicht);
* einschalten(platz, { key, projectId, viewAs, onFertig }) ist eine Init-Option der Module (die Hülle gibt sie durch); fehlt sie, steht dort eine
* Fehlerkarte. onFertig wirkt höchstens einmal und nur, solange ctx.aktuell() nicht falsch ist; es ruft nachEinschalten() (die Ansicht
* zeichnet sich neu). Weitere Optionen wie renderBestellen: key, zusammenfassung, zumSchritt.
*/
export function renderEinschaltSeite(root, { ctx = {}, daten = {}, zumSchritt, key = STANDARD_KEY, zusammenfassung: gruppenFunktion, einschalten, nachEinschalten } = {}) {
const gruppenFn = typeof gruppenFunktion === 'function' ? gruppenFunktion : key === STANDARD_KEY ? zusammenfassung : null
const offen = offeneListe(daten.offen)
const springt = typeof zumSchritt === 'function'
const gehe = (schritt) => { if (springt) zumSchritt(schritt) }
const gruppen = gruppenKarten(gruppenFn, daten, springt, gehe)
const offenBlock = offenKarte(offen, springt, gehe, null)
const zeigeKopf = gruppen.length > 0 || offen.length > 0
const viewAs = daten.kundenansicht === true || ctx.kundenansicht === true
const platz = h('div', { class: 'tm-einschalten-platz', 'data-einschalten': key })
const seite = h('section', { class: 'kb-panel tm-bestellen tm-einschalten', 'data-key': key },
zeigeKopf ? h('h3', null, EINSCHALTEN_TITEL) : null,
zeigeKopf && gruppenFn ? h('p', { class: 'tm-abschnitt-text tm-hinweis' }, springt ? 'Lies alles in Ruhe durch. Mit „Ändern“ kommst du zu dem Schritt zurück, in dem du die Angabe gemacht hast.' : 'Lies alles in Ruhe durch.') : null,
gruppen.length > 0 ? h('div', { class: 'tm-bestellen-zusammenfassung' }, gruppen) : null,
offenBlock,
offen.length > 0 ? h('p', { class: 'tm-hinweis', 'data-hinweis': 'einschalten' }, EINSCHALTEN_OFFEN) : platz)
root.replaceChildren(seite)
if (offen.length > 0) return seite
// höchstens ein Abschluss: nur der erste Aufruf wirkt, und nur solange die Ansicht noch gilt
let fertig = false
const onFertig = () => {
if (fertig) return
fertig = true
if (typeof ctx.aktuell === 'function' && ctx.aktuell() === false) return
if (typeof nachEinschalten === 'function') Promise.resolve().then(() => nachEinschalten()).catch(() => {})
}
const fehlt = () => platz.replaceChildren(fehlerKarte(EINSCHALTEN_FEHLT))
if (typeof einschalten !== 'function') {
fehlt()
return seite
}
try {
Promise.resolve(einschalten(platz, { key, projectId: ctx.projectId, viewAs, onFertig })).catch(fehlt)
} catch {
fehlt()
}
return seite
}
/* ---------------------------------------------------------------- Statuskarte --- */
/**
* Karte für eine Bestellung im Zustand ordered („Zahlung offen“) oder setup („In Einrichtung“).
* projekt = Eintrag des Projekts aus GET /api/funktionen (projectId), bestellung = projekt.features.termine (state, orderedAt, ticketWoid,
* invoiceUrl), einrichtung = Text der Zeiteinschätzung (Register: setupEstimate), danke = Rückkehr von Stripe, kundenansicht = Sitzung in der
* Kundenansicht, meldung = Text, der am Knopf „Zahlung fortsetzen“ steht (z. B. der Fehler eines Versuchs vor dem Neuzeichnen).
* modell = 'abo' (strikt): die Faktenzeile heißt „Eingeschaltet am“ statt „Bestellt am“ (Abo-Modell, Text E5).
* key = Schlüssel der Funktion (Standard 'termine', Checkout-Pfad), name = Name der Funktion in
* den Texten (Standard 'Terminplaner'), hinweisEinrichtung = Satz unter den Fakten im Zustand setup (Standard: Einstellungen gesperrt).
* weiter(fall) wird gerufen, wenn „Zahlung fortsetzen“ scheitert (Ruling R110): fall = { bestellen, meldung }, bestellen ist wahr bei 409
@@ -303,7 +372,7 @@ function baueSeite(root, { ctx, daten, zumSchritt, key, gruppenFn }, uebernahme)
* inzwischen bezahlt ist und der Server 400 antwortet); wirft es, bleibt die Karte stehen und der Fehler steht am Knopf. Fehlt weiter,
* steht die Meldung des Servers am Knopf.
*/
export function statusKarte({ projekt, bestellung, einrichtung, danke, kundenansicht, meldung, key = STANDARD_KEY, name = 'Terminplaner', hinweisEinrichtung } = {}, { weiter } = {}) {
export function statusKarte({ projekt, bestellung, einrichtung, danke, kundenansicht, meldung, key = STANDARD_KEY, name = 'Terminplaner', hinweisEinrichtung, modell } = {}, { weiter } = {}) {
const bestellt = bestellung?.state === 'ordered'
const datum = datumTag(bestellung?.orderedAt)
const ticket = bestellung?.ticketWoid
@@ -312,7 +381,7 @@ export function statusKarte({ projekt, bestellung, einrichtung, danke, kundenans
const zeile = (name, wert) => h('div', null, h('dt', null, name), h('dd', null, wert))
const fakten = [
bestellt ? null : zeile('Ticketnummer', hatTicket ? `#${ticket}` : 'Wir legen dein Ticket gerade an.'),
datum ? zeile('Bestellt am', datum) : null,
datum ? zeile(modell === 'abo' ? 'Eingeschaltet am' : 'Bestellt am', datum) : null,
!bestellt && text(einrichtung) ? zeile('Zeiteinschätzung', einrichtung) : null,
].filter(Boolean)
@@ -0,0 +1,115 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { h } from '../public/termine-dom.js'
import { renderEinschaltSeite, statusKarte } from '../public/funktion-bestellen.js'
import { KEIN_PREIS, einschaltenAttrappe, mitDom } from './support/modulAbo.js'
// Gemeinsame Einschalt-Seite der Funktionsmodule im Abo-Modell (P4 Task 2): Zusammenfassung und offene Punkte, danach der Ablauf von P3
// (zeigeEinschalten) im Platz div.tm-einschalten-platz; keine Haken, kein Preis, kein Checkout.
function seite(optionen) {
const wurzel = h('div')
document.body.append(wurzel)
renderEinschaltSeite(wurzel, { ctx: { projectId: 'p1' }, daten: { offen: [], kundenansicht: false }, key: 'shop', ...optionen })
return wurzel
}
const GRUPPEN = () => [{ schritt: 'anfrage', titel: 'Deine Anfrage', zeilen: ['Was du verkaufst: Kleidung'] }]
test('mountet den Ablauf genau einmal in den Platz, mit key, projectId und viewAs; ohne Haken, Preis und Überschrift', mitDom(async (fx) => {
const a = einschaltenAttrappe()
const wurzel = seite({ einschalten: a.fn })
assert.equal(a.aufrufe.length, 1)
assert.deepEqual([a.aufrufe[0].key, a.aufrufe[0].projectId, a.aufrufe[0].viewAs], ['shop', 'p1', false])
assert.ok(a.aufrufe[0].wurzel === fx.finde(wurzel, '[data-einschalten="shop"]'))
assert.ok(fx.finde(wurzel, '.tm-einschalten-platz'))
assert.equal(fx.finde(wurzel, 'h3'), null)
assert.equal(fx.alle(wurzel, 'input').length, 0)
assert.doesNotMatch(fx.text(wurzel), KEIN_PREIS)
const hulle = fx.finde(wurzel, 'section.kb-panel.tm-bestellen.tm-einschalten')
assert.equal(hulle.getAttribute('data-key'), 'shop')
}))
test('Zusammenfassung mit E1; offene Punkte sperren den Ablauf (E2); „Ändern“ und „Zum Schritt“ rufen zumSchritt', mitDom(async (fx) => {
const a = einschaltenAttrappe()
const schritte = []
const wurzel = seite({ einschalten: a.fn, zusammenfassung: GRUPPEN, zumSchritt: (k) => schritte.push(k), daten: { offen: [{ schritt: 'zeiten', text: 'Bitte wähle das Bundesland.' }], kundenansicht: false } })
assert.equal(fx.text(fx.finde(wurzel, 'h3')), 'Prüfen und einschalten')
assert.equal(a.aufrufe.length, 0)
assert.equal(fx.finde(wurzel, '.tm-einschalten-platz'), null)
assert.match(fx.text(wurzel), /Du kannst einschalten, sobald die offenen Punkte erledigt sind\./)
assert.doesNotMatch(fx.text(wurzel), /bestellen/i)
await fx.klick(fx.finde(wurzel, '[data-aktion="zum-schritt"][data-schritt="zeiten"]'))
await fx.klick(fx.finde(wurzel, '[data-aktion="aendern"][data-schritt="anfrage"]'))
assert.deepEqual(schritte, ['zeiten', 'anfrage'])
}))
test('Zusammenfassung ohne offene Punkte: Gruppe, dann der Platz mit dem Ablauf', mitDom(async (fx) => {
const a = einschaltenAttrappe()
const wurzel = seite({ einschalten: a.fn, zusammenfassung: GRUPPEN })
assert.equal(fx.text(fx.finde(wurzel, 'h3')), 'Prüfen und einschalten')
assert.equal(fx.text(fx.finde(wurzel, '.tm-bestellen-gruppe h4')), 'Deine Anfrage')
assert.equal(a.aufrufe.length, 1)
assert.doesNotMatch(fx.text(wurzel), /Du kannst einschalten, sobald/)
assert.equal(fx.finde(wurzel, '[data-aktion="aendern"]'), null, 'ohne zumSchritt kein „Ändern“')
}))
test('Kundenansicht: der Ablauf bekommt viewAs true (daten und ctx)', mitDom(async () => {
const a = einschaltenAttrappe()
seite({ einschalten: a.fn, daten: { offen: [], kundenansicht: true } })
const b = einschaltenAttrappe()
seite({ einschalten: b.fn, ctx: { projectId: 'p1', kundenansicht: true } })
assert.equal(a.aufrufe[0].viewAs, true)
assert.equal(b.aufrufe[0].viewAs, true)
}))
test('onFertig: höchstens einmal, nicht nach ctx.aktuell() === false', mitDom(async (fx) => {
let n = 0
const a = einschaltenAttrappe()
seite({ einschalten: a.fn, nachEinschalten: () => { n += 1 } })
a.fertig(0)
a.fertig(0)
await fx.ruhe()
assert.equal(n, 1)
const b = einschaltenAttrappe()
seite({ einschalten: b.fn, ctx: { projectId: 'p1', aktuell: () => false }, nachEinschalten: () => { n += 1 } })
b.fertig(0)
await fx.ruhe()
assert.equal(n, 1)
}))
test('nachEinschalten darf scheitern, ohne dass ein Promise unbehandelt bleibt', mitDom(async (fx) => {
const a = einschaltenAttrappe()
seite({ einschalten: a.fn, nachEinschalten: async () => { throw new Error('Netz weg') } })
a.fertig(0)
await fx.ruhe()
}))
test('ohne Funktion einschalten: Fehlerkarte E4 statt Absturz', mitDom(async (fx) => {
const wurzel = seite({})
assert.match(fx.text(wurzel), /Das Einschalten ist gerade nicht verfügbar\. Bitte lade die Seite neu oder schreib uns im Chat\./)
assert.ok(fx.finde(wurzel, '[role="alert"]'))
}))
test('wirft oder scheitert einschalten, steht E4 im Platz', mitDom(async (fx) => {
const wurzel = seite({ einschalten: () => { throw new Error('kaputt') } })
assert.match(fx.text(fx.finde(wurzel, '.tm-einschalten-platz')), /Das Einschalten ist gerade nicht verfügbar\./)
const spaeter = seite({ einschalten: () => Promise.reject(new Error('kaputt')) })
await fx.ruhe()
assert.match(fx.text(fx.finde(spaeter, '.tm-einschalten-platz')), /Das Einschalten ist gerade nicht verfügbar\./)
}))
test('statusKarte: Abo-Modell „Eingeschaltet am“, sonst „Bestellt am“ (auch bei „Abo“ und leer)', mitDom(async (fx) => {
const args = { projekt: { projectId: 'p1' }, bestellung: { state: 'setup', orderedAt: '2026-10-16T09:00:00.000Z', ticketWoid: '100150' } }
assert.match(fx.text(statusKarte({ ...args, modell: 'abo' })), /Eingeschaltet am\s*16\.10\.2026/)
assert.match(fx.text(statusKarte(args)), /Bestellt am\s*16\.10\.2026/)
for (const modell of ['Abo', '', 'abo ']) assert.match(fx.text(statusKarte({ ...args, modell })), /Bestellt am/, JSON.stringify(modell))
assert.doesNotMatch(fx.text(statusKarte({ ...args, modell: 'abo' })), /Bestellt am/)
}))
test('Quelltext: renderEinschaltSeite kommt ohne neue Importe, href, innerHTML und Pin-Wörter aus', () => {
const roh = readFileSync(new URL('../public/funktion-bestellen.js', import.meta.url), 'utf8')
const code = roh.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '')
assert.deepEqual(code.match(/from '[^']+'/g), ["from './termine-dom.js'", "from './termine-view.js'"])
assert.doesNotMatch(code, /import\s*\(/)
})