Funktionen: Bestellseite und Statuskarten
Terminplaner TP2 C8: public/funktion-bestellen.js mit sicherLink, bestellKoerper, fortsetzenKoerper, renderBestellen (Zusammenfassung, offene Punkte, Preis, vollstaendige Widerrufsbelehrung als scrollbarer Text, Unterlagen, Zustimmungs-Haken, Knopf mit bestellung.consent.knopf) und statusKarte (ordered/setup, Danke). request() in termine-dom.js behaelt den ganzen Body der Fehlerantwort (err.body), damit 409 bestellung_offen und fassung_veraltet offen/consent zeigen koennen. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
747be432c5
commit
20583424a3
4 files changed
+1051
-2
No files matched your search
@@ -0,0 +1,321 @@
|
||||
/* 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()))
|
||||
}
|
||||
@@ -34,6 +34,8 @@ export async function request(path, { method = 'GET', body } = {}) {
|
||||
err.status = response.status
|
||||
err.fehler = data.fehler || {}
|
||||
err.code = data.code
|
||||
// der ganze Body der Antwort (z. B. offen und consent der Bestellung in funktion-bestellen.js); immer ein Objekt
|
||||
err.body = data !== null && typeof data === 'object' ? data : {}
|
||||
throw err
|
||||
}
|
||||
return data
|
||||
|
||||
@@ -188,6 +188,45 @@
|
||||
.tm-status { margin: 0; }
|
||||
.tm-abschnitt details.tm-details { margin-top: 12px; }
|
||||
|
||||
/* ---------------------------------------- Bestellseite und Statuskarte (funktion-bestellen.js) --- */
|
||||
.tm-bestellen > * + * { margin-top: 18px; }
|
||||
.tm-bestellen h3 { margin-bottom: 0; }
|
||||
.tm-bestellen h4 { margin: 0 0 6px; font-size: 14.5px; }
|
||||
.tm-bestellen .tm-abschnitt-text { margin: 6px 0 0; font-size: 14px; line-height: 1.55; }
|
||||
.tm-bestellen-zusammenfassung { display: grid; gap: 10px; }
|
||||
.tm-bestellen-gruppe { padding: 12px 14px; border-radius: 12px; background: var(--kb-surface); border: 1px solid var(--kb-border); }
|
||||
.tm-bestellen-gruppe-kopf { display: flex; align-items: center; justify-content: space-between; gap: 8px 14px; flex-wrap: wrap; }
|
||||
.tm-bestellen-gruppe-kopf h4 { margin: 0; }
|
||||
.tm-bestellen-gruppe ul { margin: 8px 0 0; padding-left: 18px; font-size: 14px; line-height: 1.55; overflow-wrap: anywhere; }
|
||||
.tm-bestellen-gruppe .tm-hinweis { margin-top: 8px; }
|
||||
.tm-bestellen-offen { padding: 12px 16px; border-radius: 12px; background: rgba(251, 191, 36, 0.12); border: 1px solid rgba(251, 191, 36, 0.45); }
|
||||
.tm-bestellen-offen ul { display: grid; gap: 8px; margin: 10px 0 0; padding: 0; list-style: none; }
|
||||
.tm-bestellen-offen li { display: flex; align-items: center; justify-content: space-between; gap: 6px 14px; flex-wrap: wrap; font-size: 14px; line-height: 1.5; }
|
||||
.tm-bestellen-offen li > span { min-width: 0; overflow-wrap: anywhere; }
|
||||
.tm-bestellen-betrag { margin: 0 0 4px; font-size: 14px; }
|
||||
.tm-bestellen-betrag strong { font-size: 22px; }
|
||||
.tm-bestellen-preis p { margin: 0 0 4px; line-height: 1.5; }
|
||||
.tm-bestellen-belehrung .tm-hinweis { margin-bottom: 8px; }
|
||||
.tm-rechtstext {
|
||||
max-height: 320px; overflow-y: auto; padding: 14px 16px; border-radius: 12px; border: 1px solid var(--kb-border-strong); background: rgba(255, 255, 255, 0.03);
|
||||
font-size: 13.5px; line-height: 1.6; white-space: pre-wrap; overflow-wrap: anywhere;
|
||||
}
|
||||
.tm-rechtstext:focus-visible { outline: 3px solid #d4b4ff; outline-offset: 2px; }
|
||||
.tm-bestellen-dokumente { display: grid; gap: 6px; margin: 0; padding-left: 18px; font-size: 14px; }
|
||||
.tm-bestellen-dokumente a { color: var(--kb-lilac); overflow-wrap: anywhere; }
|
||||
.tm-bestellen-text { margin: 10px 0 0; font-size: 14px; line-height: 1.55; color: var(--kb-muted); }
|
||||
.tm-bestellen-aktion { display: grid; justify-items: start; gap: 8px; }
|
||||
.tm-bestellen-aktion .tm-fehler { font-size: 14px; line-height: 1.5; }
|
||||
.tm-bestellen-knopf { padding: 13px 22px; font-size: 15px; }
|
||||
.tm-statuskarte > * + * { margin-top: 12px; }
|
||||
.tm-statuskarte h3 { margin: 10px 0 0; }
|
||||
.tm-statuskarte p { margin: 0; line-height: 1.55; }
|
||||
.tm-statuskarte .tm-hinweis { font-size: 13.5px; }
|
||||
.tm-statuskarte .tm-fakten { margin: 0; }
|
||||
.tm-statuskarte .tm-aktionen { margin-top: 16px; }
|
||||
.tm-statuskarte a.kb-btn-outline { display: inline-flex; align-items: center; text-decoration: none; }
|
||||
.tm-danke { padding: 12px 16px; border-radius: 12px; background: rgba(74, 222, 128, 0.1); border: 1px solid rgba(74, 222, 128, 0.4); font-weight: 600; }
|
||||
|
||||
/* ----------------------------------------------------------------- Handy --- */
|
||||
@media (max-width: 880px), (pointer: coarse) {
|
||||
#view-termine button:not(.tm-link-knopf), #view-termine .kb-btn-outline, #view-termine .kb-btn-primary { min-height: 44px; }
|
||||
@@ -218,5 +257,7 @@
|
||||
.tm-kopf { flex-wrap: wrap; }
|
||||
.tm-feed { padding: 12px; }
|
||||
.tm-frage-knoepfe > * { flex: 1 1 auto; }
|
||||
.tm-bestellen-aktion { justify-items: stretch; }
|
||||
.tm-rechtstext { max-height: 260px; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) { #view-termine * { transition: none !important; animation: none !important; } }
|
||||
Reference in new issue
Block a user