/* Bestellseite und Statuskarten des Terminplaners im Kundenbereich (TP2, Task C8). Das Modul heißt bewusst nicht termine*.js: es ist die einzige Datei der Ansicht mit href-Links (Unterlagen, Rechnung) und wird relativ importiert (der Fragebogen zeigt nach dem letzten Schritt renderBestellen, die Ansicht #termine zeigt statusKarte). Jeder Link geht durch sicherLink, jede Navigation nur auf eine https-Adresse. Es gibt keine Texte des Registers im Client: Knopf, Haken, Belehrung, Preis und Unterlagen kommen als `bestellung` aus GET /api/termine/einstellungen (Server: bestellungAnsicht), und der Knopf zeigt genau bestellung.consent.knopf, denselben Text, den der Server im Nachweis speichert. Fremdtext kommt nur über textContent in die Seite (h aus termine-dom.js), nie über innerHTML. Zusammenfassung: zusammenfassung() aus termine-view.js. Checkout: POST /api/funktionen/termine/checkout (Server: routes/funktionen.js). */ import { fehlerKarte, h, request } from './termine-dom.js' import { FRAGEBOGEN_SCHRITTE, zusammenfassung } from './termine-view.js' const CHECKOUT = '/api/funktionen/termine/checkout' 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.' const SCHRITT_TITEL = new Map(FRAGEBOGEN_SCHRITTE) /* ---------------------------------------------------------------- Links --- */ // Leerraum (auch Tabulator und Zeilenumbruch, die ein Browser aus Adressen entfernt), Steuerzeichen und Rückwärtsschrägstrich const UNZULAESSIG = /[\s\u0000-\u001f\u007f-\u009f\\]/ /** Eine echte https-Adresse mit Rechnername, ohne Zugangsdaten und ohne Leerraum. */ function istHttps(url) { if (typeof url !== 'string' || UNZULAESSIG.test(url) || !/^https:\/\/[^/]/i.test(url)) return false try { const adresse = new URL(url) return adresse.protocol === 'https:' && adresse.hostname !== '' && adresse.username === '' && adresse.password === '' } catch { return false } } /** * Adresse, die als Link gesetzt werden darf: eine https-Adresse oder ein Pfad unter /rechtliches/ (die Rechtsunterlagen des Portals), sonst ''. * Abgewiesen: javascript:, data:, http:, //fremd.example, Leerraum und Steuerzeichen, Rückwärtsschrägstriche, Zugangsdaten in der Adresse, * Pfade mit .. und alles, was nicht unter /rechtliches/ liegt. Gültige Adressen kommen unverändert zurück. */ export function sicherLink(url) { if (typeof url !== 'string' || UNZULAESSIG.test(url)) return '' if (istHttps(url)) return url if (url.startsWith(RECHTLICHES) && url.length > RECHTLICHES.length && !/\.\.|%2e/i.test(url)) return url return '' } /** Zur Zahlungsseite gehen: nur auf eine https-Adresse (true), alles andere bleibt ohne Wirkung (false). */ function geheZu(url) { if (!istHttps(url)) return false window.location.href = url return true } /* ----------------------------------------------------------- Körper der Anfragen --- */ /** * Körper der Bestellung (der Vertrag des Checkouts): { projectId, zustimmung: { version, haken } }. version ist die Fassung der Erklärungen, * die die Seite zeigt (bestellung.consent.version); haken sind die IDs der angehakten Einträge mit haken: true in Registerreihenfolge, nie Texte. * gewaehlt: Array oder Set von IDs; unbekannte IDs, IDs reiner Texte und Doppelte fallen weg. */ export function bestellKoerper({ projectId, bestellung, gewaehlt }) { const gesetzt = new Set(Array.isArray(gewaehlt) || gewaehlt instanceof Set ? gewaehlt : []) const eintraege = Array.isArray(bestellung?.consent?.items) ? bestellung.consent.items : [] const haken = [] for (const eintrag of eintraege) { if (eintrag?.haken === true && gesetzt.has(eintrag.id) && !haken.includes(eintrag.id)) haken.push(eintrag.id) } return { projectId, zustimmung: { version: bestellung?.consent?.version, haken } } } /** Körper von „Zahlung fortsetzen“: die offene Sitzung weiterführen, ohne neue Zustimmung. */ export const fortsetzenKoerper = (projectId) => ({ projectId, fortsetzen: true }) /* -------------------------------------------------------------- kleine Bausteine --- */ const text = (wert) => (typeof wert === 'string' ? wert : '') /** Datum in Berlin als TT.MM.JJJJ, bei fehlendem oder ungültigem Wert ''. */ function datumTag(wert) { const tag = new Date(wert) if (!wert || Number.isNaN(tag.getTime())) return '' return tag.toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric' }) } const istConsent = (wert) => wert !== null && typeof wert === 'object' && typeof wert.version === 'string' && Array.isArray(wert.items) const offeneListe = (wert) => (Array.isArray(wert) ? wert.filter((punkt) => punkt !== null && typeof punkt === 'object' && typeof punkt.text === 'string') : []) const chatKnopf = () => h('button', { type: 'button', class: 'kb-btn-outline', 'data-view': 'chat' }, 'Frage dazu? Chat öffnen') /* ---------------------------------------------------------------- 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. * 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). */ export function renderBestellen(root, { ctx, daten, zumSchritt } = {}) { return baueSeite(root, { ctx, daten, zumSchritt }, {}) } function baueSeite(root, { ctx, daten, zumSchritt }, uebernahme) { const roh = daten?.bestellung if (!roh || !istConsent(roh.consent)) { const karte = fehlerKarte('Die Bestellseite konnte gerade nicht geladen werden. Bitte lade die Seite neu.') root.replaceChildren(karte) return karte } // nach einer veralteten Fassung gilt die Fassung aus der Antwort der Bestellung, nicht die der Daten const bestellung = istConsent(uebernahme.consent) ? { ...roh, consent: uebernahme.consent } : roh const uebernommen = offeneListe(uebernahme.offen) const offen = uebernommen.length > 0 ? uebernommen : offeneListe(daten.offen) const eintraege = bestellung.consent.items.filter((eintrag) => eintrag !== null && typeof eintrag === 'object') const hakenEintraege = eintraege.filter((eintrag) => eintrag.haken === true) const kundenansicht = daten.kundenansicht === true const gewaehlt = new Set() let laeuft = false const gehe = (schritt) => { if (typeof zumSchritt === 'function') 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')), 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) 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), 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)))) // 3 Preis mit dem Hinweis des Servers und die Einrichtungsdauer const preisBlock = h('div', { class: 'tm-bestellen-preis' }, h('h4', null, 'Preis und Einrichtung'), h('p', { class: 'tm-bestellen-betrag' }, h('strong', null, text(bestellung.preis)), ' ', h('span', null, text(bestellung.preisHinweis))), text(bestellung.einrichtung) ? h('p', null, `Einrichtung: ${bestellung.einrichtung}`) : null) // 4 die vollständige Widerrufsbelehrung samt Muster-Formular: ein Textbereich zum Scrollen, kein Aufklappen const belehrungBlock = text(bestellung.belehrung).trim() === '' ? null : h('div', { class: 'tm-bestellen-belehrung' }, h('h4', null, 'Widerrufsbelehrung'), h('p', { class: 'tm-hinweis' }, 'Lies die Belehrung bitte ganz. Das Muster-Widerrufsformular gehört dazu.'), h('div', { class: 'tm-rechtstext', tabindex: '0', role: 'region', 'aria-label': 'Widerrufsbelehrung mit Muster-Widerrufsformular' }, bestellung.belehrung)) // 5 Unterlagen: versionierte Adressen, nur sichere Links const dokumente = (Array.isArray(bestellung.dokumente) ? bestellung.dokumente : []).filter((dokument) => text(dokument?.titel) !== '' && sicherLink(dokument?.url) !== '') const unterlagenBlock = dokumente.length === 0 ? null : h('div', { class: 'tm-bestellen-unterlagen' }, h('h4', null, 'Unterlagen zu deiner Bestellung'), h('ul', { class: 'tm-bestellen-dokumente' }, dokumente.map((dokument) => h('li', null, h('a', { href: sicherLink(dokument.url), target: '_blank', rel: 'noopener noreferrer' }, dokument.titel))))) // 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 hinweisEl = h('p', { class: 'tm-hinweis', 'data-hinweis': 'bestellen' }) const hakenZeile = (eintrag) => { const feld = h('input', { type: 'checkbox', 'data-haken': eintrag.id }) feld.addEventListener('change', () => { if (feld.checked) gewaehlt.add(eintrag.id) else gewaehlt.delete(eintrag.id) aktualisiere() }) return h('label', { class: 'tm-haken' }, feld, h('span', null, text(eintrag.text))) } const zustimmungBlock = h('div', { class: 'tm-bestellen-zustimmung' }, h('h4', null, 'Deine Zustimmungen'), h('div', { class: 'tm-bestellen-haken' }, eintraege.map((eintrag) => (eintrag.haken === true ? hakenZeile(eintrag) : h('p', { class: 'tm-bestellen-text' }, text(eintrag.text)))))) const seite = h('section', { class: 'kb-panel tm-bestellen' }, h('h3', null, 'Prüfen und bestellen'), h('p', { class: 'tm-abschnitt-text tm-hinweis' }, 'Lies alles in Ruhe durch. Mit „Ändern“ kommst du zu dem Schritt zurück, in dem du die Angabe gemacht hast.'), h('div', { class: 'tm-bestellen-zusammenfassung' }, gruppen), offenBlock, preisBlock, belehrungBlock, unterlagenBlock, zustimmungBlock, h('div', { class: 'tm-bestellen-aktion' }, meldungEl, knopf, hinweisEl)) root.replaceChildren(seite) function zeigeMeldung(meldung) { meldungEl.textContent = meldung } function aktualisiere() { const hakenFehlen = hakenEintraege.some((eintrag) => !gewaehlt.has(eintrag.id)) knopf.disabled = laeuft || hakenFehlen || offen.length > 0 || kundenansicht if (kundenansicht) hinweisEl.textContent = KUNDENANSICHT else if (offen.length > 0) hinweisEl.textContent = 'Du kannst bestellen, sobald die offenen Punkte erledigt sind.' else hinweisEl.textContent = hakenFehlen ? 'Setze alle Haken, um zu bestellen.' : '' } // 409: Daten neu laden, die Seite mit leeren Haken neu zeichnen und die Meldung des Servers zeigen. Ist die Bestellung inzwischen // aufgegeben (die Daten haben keine bestellung mehr), bleibt nur die Meldung mit dem Hinweis, die Seite neu zu laden. async function nachKonflikt(err) { let neu = null try { neu = typeof ctx?.neuLaden === 'function' ? await ctx.neuLaden() : null } catch { neu = null } if (neu === null || typeof neu !== 'object') { zeigeMeldung(err.message) 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.'))) return } const neueSeite = baueSeite(root, { ctx, daten: neu, zumSchritt }, { offen: err.code === 'bestellung_offen' ? err.body?.offen : undefined, consent: err.code === 'fassung_veraltet' ? err.body?.consent : undefined, }) const neueMeldung = neueSeite.querySelector('[data-meldung="bestellen"]') if (neueMeldung) { neueMeldung.textContent = err.message neueMeldung.scrollIntoView({ block: 'nearest' }) } } async function bestelle() { if (knopf.disabled) return laeuft = true aktualisiere() zeigeMeldung('') try { const antwort = await request(CHECKOUT, { method: 'POST', body: bestellKoerper({ projectId: ctx?.projectId, bestellung, gewaehlt }) }) if (geheZu(antwort?.url)) return // die Seite wird verlassen, der Knopf bleibt gesperrt zeigeMeldung(ZAHLUNGSSEITE) } catch (err) { if (err?.status === 409) { await nachKonflikt(err) return } zeigeMeldung(err?.message || ZAHLUNGSSEITE) } laeuft = false aktualisiere() } aktualisiere() 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. weiter() wird gerufen, wenn „Zahlung fortsetzen“ mit 409 zustimmung_fehlt antwortet (keine offene Sitzung mit Nachweis): es * lädt die Seite neu und zeigt wieder das Bestellformular. Fehlt weiter, steht die Meldung des Servers am Knopf. */ export function statusKarte({ projekt, bestellung, einrichtung, danke, kundenansicht } = {}, { weiter } = {}) { const bestellt = bestellung?.state === 'ordered' const datum = datumTag(bestellung?.orderedAt) const ticket = bestellung?.ticketWoid const hatTicket = ticket !== null && ticket !== undefined && String(ticket) !== '' const rechnung = sicherLink(bestellung?.invoiceUrl) 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, !bestellt && text(einrichtung) ? zeile('Zeiteinschätzung', einrichtung) : null, ].filter(Boolean) const meldungEl = h('p', { class: 'tm-fehler', role: 'alert', 'data-meldung': 'fortsetzen' }) const fortsetzenKnopf = bestellt ? h('button', { type: 'button', class: 'kb-btn-primary', 'data-aktion': 'fortsetzen', disabled: kundenansicht === true, onclick: () => fortsetzen() }, 'Zahlung fortsetzen') : null async function fortsetzen() { if (fortsetzenKnopf.disabled) return fortsetzenKnopf.disabled = true meldungEl.textContent = '' try { const antwort = await request(CHECKOUT, { method: 'POST', body: fortsetzenKoerper(projekt?.projectId) }) if (geheZu(antwort?.url)) return // die Seite wird verlassen meldungEl.textContent = ZAHLUNGSSEITE } catch (err) { if (err?.status === 409 && err.code === 'zustimmung_fehlt' && typeof weiter === 'function') { try { await weiter() return // die Ansicht zeichnet sich neu } catch (fehler) { meldungEl.textContent = fehler?.message || ZAHLUNGSSEITE } } else { meldungEl.textContent = err?.message || ZAHLUNGSSEITE } } fortsetzenKnopf.disabled = false } return 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'), h('p', null, bestellt ? 'Sobald deine Zahlung bei uns ankommt, legen wir mit der Einrichtung los.' : 'Sobald die Einrichtung fertig ist, schalten wir deinen Terminplaner für dich frei.'), fakten.length > 0 ? h('dl', { class: 'tm-fakten' }, fakten) : null, bestellt ? h('p', { class: 'tm-hinweis' }, 'Zahlst du per SEPA-Lastschrift, kann es einige Tage dauern, bis die Zahlung bei uns ankommt.') : h('p', { class: 'tm-hinweis' }, 'Deine Einstellungen sind jetzt gesperrt, Änderungen laufen über den Chat.'), meldungEl, bestellt && kundenansicht === true ? h('p', { class: 'tm-hinweis' }, KUNDENANSICHT) : null, h('div', { class: 'tm-aktionen' }, fortsetzenKnopf, !bestellt && rechnung ? h('a', { class: 'kb-btn-outline', href: sicherLink(rechnung), target: '_blank', rel: 'noopener noreferrer' }, 'Rechnung ansehen') : null, chatKnopf())) }