TP2 Abschlussprüfung: Fragebogen und Bestellseite (veralteter Tab, Abo-Hinweis, Zurück aus Stripe)

- PUT /fragebogen: ein Tab mit veralteter Branche bekommt 409 EINSTELLUNG_VERALTET (die Branche wechselt nur die Vorlage), bestaetigt wird mit
  dem gespeicherten Stand vereinigt; die ungeprüften Vorlagenpreise können nicht mehr als geprüft gelten
- GET /einstellungen liefert in off aboHinweis (aboMeldung, auch bei nicht lesbarem Abo); die Hinweiskarte zeigt ihn immer und hat „Neu laden“
- Bestellseite und Statuskarte laden bei pageshow (persisted) neu, wenn der Browser sie nach der Zahlungsseite aus dem bfcache holt
- renderBestellen zeichnet „Ändern“ und „Zum Schritt“ nur mit zumSchritt; nach einem 409 bekommt die Meldung den Fokus
- Zusammenfassung nennt „Kleinunternehmer: ja/nein“

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-05 06:35:42 +00:00
1 parent 2fc48df915
commit 5f99f08a39
8 files changed
+314 -39

No files matched your search

+39 -9
View File
@@ -84,13 +84,32 @@ const offeneListe = (wert) => (Array.isArray(wert) ? wert.filter((punkt) => punk
const chatKnopf = () => h('button', { type: 'button', class: 'kb-btn-outline', 'data-view': 'chat' }, 'Frage dazu? Chat öffnen')
/* ----------------------------------------------- Rückkehr von der Zahlungsseite --- */
/**
* Der Browser-Zurück-Knopf der Zahlungsseite stellt die Seite oft aus dem Speicher (bfcache) so wieder her, wie sie verlassen wurde: der Knopf bleibt
* gesperrt, es steht keine Meldung da. Darum laden Bestellseite und Statuskarte bei pageshow mit persisted neu (wie app.js beim Wechsel-Knopf).
* Gilt nur, solange das Element noch im Baum steht; danach entfernt sich der Handler beim nächsten pageshow selbst.
*/
function ladeBeiRueckkehrNeu(element) {
if (typeof window === 'undefined' || typeof window.addEventListener !== 'function') return
const beiAnzeige = (ev) => {
if (!element.isConnected) {
window.removeEventListener('pageshow', beiAnzeige)
return
}
if (ev?.persisted === true) window.location.reload()
}
window.addEventListener('pageshow', beiAnzeige)
}
/* ---------------------------------------------------------------- Bestellseite --- */
/**
* „Prüfen und bestellen“ in root zeichnen und die Seite zurückgeben. Reihenfolge: Zusammenfassung der Angaben (je Gruppe „Ändern“),
* offene Punkte (je Punkt „Zum Schritt“), Preis und Einrichtungsdauer, die vollständige Widerrufsbelehrung samt Muster-Formular als Text
* (ein scrollbarer Bereich, nie eingeklappt), die Unterlagen als Links, die Zustimmungen und zuletzt der Knopf.
* ctx = Kontext der Ansicht (projectId, aktuell(), neuLaden()), daten = Antwort von GET /api/termine/einstellungen im Zustand off
* (einstellung, fragebogen, branchen, offen, kundenansicht, bestellung), zumSchritt(key) springt in einen Schritt des Fragebogens.
* (einstellung, fragebogen, branchen, offen, kundenansicht, bestellung), zumSchritt(key) springt in einen Schritt des Fragebogens (ist es keine Funktion,
* zeichnet die Seite weder „Ändern“ noch „Zum Schritt“).
* Der Knopf ist gesperrt, solange ein Haken fehlt, offene Punkte bleiben oder die Kundenansicht läuft. Ein Klick sperrt ihn, sendet
* bestellKoerper und geht zur Zahlungsseite. Bei 409 lädt die Seite die Daten neu und setzt die Haken zurück (bestellung_offen zeigt die
* Punkte aus der Antwort, fassung_veraltet die neue Fassung aus der Antwort, ein Abo-Code den Text der Antwort).
@@ -116,15 +135,19 @@ function baueSeite(root, { ctx, daten, zumSchritt }, uebernahme) {
const gewaehlt = new Set()
let laeuft = false
const gehe = (schritt) => { if (typeof zumSchritt === 'function') zumSchritt(schritt) }
// „Ändern“ und „Zum Schritt“ gibt es nur, wo es ein Ziel gibt: ohne zumSchritt zeichnet die Seite keine Knöpfe, die nichts tun
const springt = typeof zumSchritt === 'function'
const gehe = (schritt) => { if (springt) zumSchritt(schritt) }
// 1 Zusammenfassung: je Gruppe die Angaben als Text und „Ändern“
const gruppen = zusammenfassung(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),
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')),
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.')))
// 2 offene Punkte: „Zum Schritt“ nur dort, wo es einen Schritt des Fragebogens gibt (der Abo-Punkt gehört zu dieser Seite)
@@ -133,7 +156,7 @@ function baueSeite(root, { ctx, daten, zumSchritt }, uebernahme) {
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),
SCHRITT_TITEL.has(punkt.schritt)
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')
@@ -160,7 +183,7 @@ function baueSeite(root, { ctx, daten, zumSchritt }, uebernahme) {
// 6 Zustimmungen in Registerreihenfolge: Einträge mit haken: true sind Haken, die anderen reiner Text
const knopf = h('button', { type: 'button', class: 'kb-btn-primary tm-bestellen-knopf', 'data-aktion': 'bestellen', disabled: true, onclick: () => bestelle() }, text(bestellung.consent.knopf))
const meldungEl = h('p', { class: 'tm-fehler tm-bestellen-meldung', role: 'alert', 'data-meldung': 'bestellen' })
const meldungEl = h('p', { class: 'tm-fehler tm-bestellen-meldung', role: 'alert', 'data-meldung': 'bestellen', tabindex: '-1' })
const hinweisEl = h('p', { class: 'tm-hinweis', 'data-hinweis': 'bestellen' })
const hakenZeile = (eintrag) => {
const feld = h('input', { type: 'checkbox', 'data-haken': eintrag.id })
@@ -186,6 +209,7 @@ function baueSeite(root, { ctx, daten, zumSchritt }, uebernahme) {
zustimmungBlock,
h('div', { class: 'tm-bestellen-aktion' }, meldungEl, knopf, hinweisEl))
root.replaceChildren(seite)
ladeBeiRueckkehrNeu(seite)
function zeigeMeldung(meldung) {
meldungEl.textContent = meldung
@@ -210,13 +234,16 @@ function baueSeite(root, { ctx, daten, zumSchritt }, uebernahme) {
}
if (neu === null || typeof neu !== 'object') {
zeigeMeldung(err.message)
meldungEl.focus({ preventScroll: true })
laeuft = false
aktualisiere()
return
}
if (typeof ctx.aktuell === 'function' && !ctx.aktuell()) return
if (!neu.bestellung) {
root.replaceChildren(h('div', { class: 'kb-panel tm-info', role: 'status' }, h('p', null, err.message), h('p', null, 'Lade die Seite neu, dann siehst du den aktuellen Stand.')))
const karte = h('div', { class: 'kb-panel tm-info', role: 'status', tabindex: '-1' }, h('p', null, err.message), h('p', null, 'Lade die Seite neu, dann siehst du den aktuellen Stand.'))
root.replaceChildren(karte)
karte.focus({ preventScroll: true })
return
}
const neueSeite = baueSeite(root, { ctx, daten: neu, zumSchritt }, {
@@ -226,6 +253,7 @@ function baueSeite(root, { ctx, daten, zumSchritt }, uebernahme) {
const neueMeldung = neueSeite.querySelector('[data-meldung="bestellen"]')
if (neueMeldung) {
neueMeldung.textContent = err.message
neueMeldung.focus({ preventScroll: true })
neueMeldung.scrollIntoView({ block: 'nearest' })
}
}
@@ -310,7 +338,7 @@ export function statusKarte({ projekt, bestellung, einrichtung, danke, kundenans
fortsetzenKnopf.disabled = false
}
return h('section', { class: 'kb-panel tm-statuskarte', 'data-state': bestellt ? 'ordered' : 'setup' },
const karte = h('section', { class: 'kb-panel tm-statuskarte', 'data-state': bestellt ? 'ordered' : 'setup' },
danke ? h('p', { class: 'tm-danke', role: 'status', 'data-danke': true }, DANKE) : null,
h('span', { class: 'tm-pille tm-pille-wait' }, bestellt ? 'Zahlung offen' : 'In Einrichtung'),
h('h3', null, bestellt ? 'Deine Bestellung wartet auf die Zahlung' : 'Wir richten deinen Terminplaner ein'),
@@ -325,4 +353,6 @@ export function statusKarte({ projekt, bestellung, einrichtung, danke, kundenans
fortsetzenKnopf,
!bestellt && rechnung ? h('a', { class: 'kb-btn-outline', href: sicherLink(rechnung), target: '_blank', rel: 'noopener noreferrer' }, 'Rechnung ansehen') : null,
chatKnopf()))
ladeBeiRueckkehrNeu(karte)
return karte
}
+1
View File
@@ -655,6 +655,7 @@ export function zusammenfassung(einstellung, fragebogen, branchen) {
zeile('branche', istText(branche?.name) && `Branche: ${branche.name}`)
zeile('branche', istText(betrieb.name) && `Betrieb: ${betrieb.name}`)
zeile('branche', istText(betrieb.adresse) && `Anschrift: ${betrieb.adresse}`)
zeile('branche', typeof betrieb.kleinunternehmer === 'boolean' && `Kleinunternehmer: ${betrieb.kleinunternehmer ? 'ja' : 'nein'}`)
// 2. Wer wird gebucht: Personen und Plaetze getrennt, in der Reihenfolge ihres ersten Auftretens
const nachTyp = new Map()
+14 -8
View File
@@ -176,13 +176,16 @@ const neueMeldung = () => h('div', { class: 'tm-meldung', role: 'status', 'aria-
/* ----------------------------------------------- Zustand off: Hinweis, Fragebogen --- */
/**
* Ohne laufendes Wartungs-Abo gibt es keinen Fragebogen, nur den Hinweis wie in der Kachel. Der Text kommt ausschliesslich vom Server
* (GET /api/funktionen, termine.hinweis = aboMeldung in server/services/featureOrder.js), nie aus einer Kopie im Browser; bei
* kein_abo kommt der Link zu den Vertraegen dazu (Quelle: vertraege-link.js). Fehlt der Text, weil sich das Abo seit dem Laden der Liste
* geaendert hat, wird die Liste noch einmal geholt.
* Ohne laufendes Wartungs-Abo gibt es keinen Fragebogen, nur den Hinweis. Der Text kommt ausschliesslich vom Server, nie aus einer Kopie im
* Browser: aboHinweis aus GET /api/termine/einstellungen (derselbe Aufruf, der den Zustand liefert, also auch dann gefuellt, wenn das Abo nicht
* lesbar war oder sich seit dem Laden der Liste geaendert hat), ersatzweise termine.hinweis aus GET /api/funktionen (beide: aboMeldung in
* server/services/featureOrder.js). Fehlt der Text auch dort, wird die Liste noch einmal geholt. Bei kein_abo kommt der Link zu den
* Vertraegen dazu (Quelle: vertraege-link.js). „Neu laden“ holt Daten und Ansicht neu, damit der Kunde nach einer Aenderung des Abos nicht
* an der Karte haengen bleibt.
*/
async function zeigeAboHinweis(s, { aktuell }) {
if (typeof s.termine?.hinweis !== 'string' || s.termine.hinweis === '') {
const textVon = (wert) => (typeof wert === 'string' ? wert : '')
if (textVon(s.daten?.aboHinweis) === '' && textVon(s.termine?.hinweis) === '') {
try {
await ladeFunktionen(s)
} catch {
@@ -190,13 +193,16 @@ async function zeigeAboHinweis(s, { aktuell }) {
}
if (!aktuell()) return
}
const hinweis = typeof s.termine?.hinweis === 'string' ? s.termine.hinweis : ''
const link = s.termine?.bestellbar === false && s.termine.grund === 'kein_abo'
const hinweis = textVon(s.daten?.aboHinweis) || textVon(s.termine?.hinweis)
const keinAbo = s.daten?.abo?.grund === 'kein_abo' || (s.termine?.bestellbar === false && s.termine.grund === 'kein_abo')
const link = keinAbo
? h('button', { type: 'button', class: ZU_VERTRAEGE.klasse, 'data-view': ZU_VERTRAEGE.view }, ZU_VERTRAEGE.text)
: null
s.buehne.replaceChildren(h('div', { class: 'kb-panel tm-info', 'data-hinweis': 'abo' },
h('h3', null, 'Terminplaner einrichten'),
hinweis || link ? h('p', null, hinweis, hinweis && link ? ' ' : null, link) : null))
hinweis || link ? h('p', null, hinweis, hinweis && link ? ' ' : null, link) : null,
h('div', { class: 'tm-aktionen' },
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'neu-laden', onclick: () => zeigeProjekt(s) }, 'Neu laden'))))
}
/** Der Fragebogen mit der Bestellseite danach (termine-fragebogen.js); die Meldung ueber ihm ist s.meldungEl (ctx.meldung). */