Funktionsmodule: gemeinsame Einschalt-Seite für das Abo-Modell
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a50c5ef6c9
commit
364ded1662
2 files changed
+205
-21
No files matched your search
@@ -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*\(/)
|
||||
})
|
||||
Reference in new issue
Block a user