/* Buchungskalender im Kundenbereich (ES-Modul, von app.js beim Oeffnen der Ansicht #buchungen geladen). Aufbau wie shop.js: initBuchungen(rootEl, { projectId }): Projekt waehlen (GET /api/funktionen, features.unterkunft), Stand holen (GET /api/unterkunft/status) und je nach state die Reiter (REITER, lazy geladen, render(panel, ctx)) oder (state off) die Anfrage-/Bestellkarte (Task 14). ctx = { projectId, status, nurLesen, aktuell(), neuLaden(), zeigeMeldung(text, art), request }. Helfer: shop-dom.js (h, request, Karten), Styles: buchungen.css (bk-) und shop.css (sh-). Fremdtext nur ueber textContent. */ import { fehlerKarte, h, infoKarte, request } from './shop-dom.js' import { renderEinschaltSeite, statusKarte } from './funktion-bestellen.js' import { anfrageKarte, anfrageZeilen, renderAnfrageFormular } from './buchungen-anfrage.js' import { meldeReiter, reiterEintraege } from './fn-reiter.js' /** Die Reiter: jeder ein Modul mit render(panel, ctx), erst beim ersten Oeffnen geladen. */ export const REITER = [ { key: 'anfragen', label: 'Anfragen', laden: () => import('./buchungen-tab-anfragen.js') }, { key: 'kalender', label: 'Kalender', laden: () => import('./buchungen-tab-kalender.js') }, { key: 'unterkuenfte', label: 'Unterkünfte & Preise', laden: () => import('./buchungen-tab-unterkuenfte.js') }, { key: 'rechnungen', label: 'Rechnungen', laden: () => import('./buchungen-tab-rechnungen.js') }, { key: 'einstellungen', label: 'Einstellungen', laden: () => import('./buchungen-tab-einstellungen.js') }, ] const NICHT_VERFUEGBAR = 'Der Buchungskalender ist noch nicht verfügbar.' const auswahl = { projectId: '', reiter: 'anfragen' } /** Gemerkte Auswahl vergessen (fuer Tests). */ export function vergissAuswahl() { auswahl.projectId = '' auswahl.reiter = 'anfragen' } let S = null // aktuelle Sitzung; eine alte Antwort darf eine neuere nicht ueberschreiben const textOr = (x) => (typeof x === 'string' && x.trim() ? x : '') // Abo-Modell (Antwort von GET /api/funktionen, strikt): die Reiter zeichnet die Etage, die Ansicht meldet sie (fn-reiter.js) const istAbo = (s) => s.modell === 'abo' /** Mitarbeiter in der Kundenansicht (window.KbView, von der Hülle gesetzt): Einschalten bleibt gesperrt. */ const inKundenansicht = () => typeof window !== 'undefined' && Boolean(window.KbView?.id?.()) /** Eine Ansicht ohne Reiter meldet die leere Liste, damit die Etage nicht die Reiter der vorigen stehen laesst. */ const keineReiter = (s) => { if (istAbo(s)) meldeReiter(s.root, { eintraege: [] }) } const zustandVon = (projekt) => projekt?.features?.unterkunft?.state || 'off' export function initBuchungen(root, { projectId, einschalten } = {}) { if (!root) return Promise.resolve() const wunsch = projectId || root.dataset?.wunschProjekt || '' // die Kachel in Funktionen legt ihr Projekt hier ab if (root.dataset) delete root.dataset.wunschProjekt return starte(root, wunsch, einschalten) } async function starte(root, projectId, einschalten) { if (projectId) auswahl.projectId = projectId const s = { root, projects: [], modell: undefined, projekt: null, status: null, lauf: 0, buehne: null, meldungEl: null, einschalten } S = s let funktionen try { funktionen = await request('/api/funktionen') } catch (err) { if (S === s) root.replaceChildren(fehlerKarte(err.message, () => starte(root, auswahl.projectId, einschalten))) return } if (S !== s) return s.modell = funktionen?.modell if (!funktionen?.unterkunft?.verfuegbar) { keineReiter(s) root.replaceChildren(infoKarte(NICHT_VERFUEGBAR, 'Sobald er für dich bereit ist, findest du ihn hier.')) return } s.projects = Array.isArray(funktionen.projects) ? funktionen.projects : [] if (!s.projects.length) { keineReiter(s) root.replaceChildren(h('div', { class: 'kb-panel kb-empty' }, 'Noch keine Website-Projekte zugewiesen.')) return } const hat = (zustaende) => s.projects.find((p) => zustaende.includes(zustandVon(p))) s.projekt = s.projects.find((p) => p.projectId === auswahl.projectId) || hat(['active']) || hat(['setup']) || hat(['disabled']) || s.projects[0] auswahl.projectId = s.projekt.projectId s.meldungEl = h('div', { class: 'sh-meldung', role: 'status', 'aria-live': 'polite', tabindex: '-1' }) s.buehne = h('div', { id: 'buchungen-buehne' }) root.replaceChildren(...(s.projects.length > 1 ? [projektwahl(s)] : []), s.meldungEl, s.buehne) await zeigeProjekt(s) } function projektwahl(s) { const select = h('select', { id: 'buchungen-project' }, s.projects.map((p) => { const option = h('option', { value: p.projectId }, p.projectName) option.selected = p.projectId === s.projekt.projectId return option })) select.addEventListener('change', () => { auswahl.projectId = select.value s.projekt = s.projects.find((p) => p.projectId === select.value) s.neueAnfrage = false zeigeMeldung(s, '') zeigeProjekt(s) }) return h('div', { class: 'fn-project kb-panel' }, h('label', { for: 'buchungen-project' }, 'Website'), select) } function zeigeMeldung(s, meldung, art = 'ok') { const el = s.meldungEl if (!el) return el.textContent = meldung || '' el.className = `sh-meldung sh-meldung-${art}` el.setAttribute('role', art === 'fehler' ? 'alert' : 'status') el.setAttribute('aria-live', art === 'fehler' ? 'assertive' : 'polite') if (!meldung) return if (art === 'fehler') el.focus({ preventScroll: true }) el.scrollIntoView?.({ block: 'nearest' }) } const ladeStatus = (projectId) => request(`/api/unterkunft/status?projectId=${encodeURIComponent(projectId)}`) async function zeigeProjekt(s) { const lauf = ++s.lauf const aktuell = () => S === s && s.lauf === lauf keineReiter(s) s.buehne.replaceChildren(h('p', { class: 'kb-empty' }, 'Lade…')) let status try { status = await ladeStatus(s.projekt.projectId) } catch (err) { if (aktuell()) s.buehne.replaceChildren(fehlerKarte(err.message, () => zeigeProjekt(s))) return } if (!aktuell()) return zeichneAnsicht(s, { status, aktuell }) } function zeichneAnsicht(s, { status, aktuell }) { s.status = status keineReiter(s) // die Ansicht mit Reitern (zeigeReiter) meldet ihre Liste gleich danach const zustand = status?.state || zustandVon(s.projekt) if (zustand === 'setup' || zustand === 'active' || zustand === 'disabled') zeigeReiter(s, { status, aktuell, zustand }) else if (zustand === 'ordered') zeigeBestellung(s) else if (zustand === 'off') zeigeAnfrage(s, { status, aktuell }) else s.buehne.replaceChildren(infoKarte('Buchungskalender', 'Der Stand deines Buchungskalenders ist gerade nicht lesbar. Bitte lade die Seite neu.')) } /* Zustand off: Anfrageformular, Karte „wird geprueft“ / „abgelehnt“ oder (angenommen) der Hinweis auf die Bestellung. */ function zeigeAnfrage(s, { status, aktuell }) { const projectId = s.projekt.projectId const ansicht = status?.anfrage // nach einem 409 bleibt die Meldung des Servers sichtbar, waehrend die Ansicht neu laedt const neuLaden = async (o) => { await zeigeProjekt(s); if (o?.meldung && S === s) zeigeMeldung(s, o.meldung, 'fehler') } if (ansicht?.status === 'angenommen' && istAbo(s)) { // Abo-Modell: die Zusage fuehrt zum Einschalten (kein Preis, keine Haken, keine Bestellung) const wurzel = h('div', { class: 'sh-wurzel' }) s.buehne.replaceChildren(wurzel) renderEinschaltSeite(wurzel, { ctx: { projectId, aktuell }, daten: { offen: [], kundenansicht: inKundenansicht() }, key: 'unterkunft', zusammenfassung: () => (ansicht.anfrage ? [{ schritt: 'anfrage', titel: 'Deine Anfrage', zeilen: anfrageZeilen(ansicht.anfrage).map(({ name, wert }) => `${name}: ${wert}`) }] : []), einschalten: s.einschalten, nachEinschalten: () => zeigeProjekt(s), }) } else if (ansicht?.status === 'angenommen') { s.buehne.replaceChildren(infoKarte('Deine Anfrage wurde angenommen', 'Wir melden uns bei dir mit den nächsten Schritten zur Bestellung.', { chat: true })) } else if (ansicht?.status === 'offen') { s.buehne.replaceChildren(anfrageKarte(ansicht, { projectId, neuLaden })) } else if (ansicht?.status === 'abgelehnt' && !s.neueAnfrage) { s.buehne.replaceChildren(anfrageKarte(ansicht, { projectId, neu: () => { s.neueAnfrage = true; zeigeAnfrage(s, { status, aktuell }) } })) } else { const wurzel = h('div', { class: 'sh-wurzel' }) s.buehne.replaceChildren(wurzel) renderAnfrageFormular(wurzel, { projectId, vorgabe: ansicht?.anfrage || null, preis: textOr(s.projekt?.features?.unterkunft?.priceLabel) || '199 €', modell: s.modell, nachSenden: async (_a, o) => { s.neueAnfrage = false; if (aktuell()) await neuLaden(o) }, }) } } /* Zustand ordered: Statuskarte „Zahlung offen“ mit „Zahlung fortsetzen“ (generische Bestellseite, Checkout /api/funktionen/unterkunft/checkout). */ function zeigeBestellung(s) { const eintrag = { ...s.projekt.features?.unterkunft, state: 'ordered' } s.buehne.replaceChildren(statusKarte({ projekt: s.projekt, bestellung: eintrag, kundenansicht: false, key: 'unterkunft', name: 'Buchungskalender' }, { weiter: async ({ meldung }) => { await zeigeProjekt(s); if (S === s) zeigeMeldung(s, meldung, 'fehler') }, })) } function zeigeReiter(s, { status, aktuell, zustand }) { const neuLaden = async () => { const neu = await ladeStatus(s.projekt.projectId) if (!aktuell()) return neu s.status = neu ctx.status = neu if ((neu.state || 'off') !== zustand) zeichneAnsicht(s, { status: neu, aktuell }) return neu } const ctx = { projectId: s.projekt.projectId, status, nurLesen: zustand === 'disabled', abwickeln: true, // Anfragen und Stornos bleiben auch bei abgeschalteter Funktion bearbeitbar (Pflichten gegenueber Gaesten) aktuell, request, zeigeMeldung: (meldung, art) => { if (aktuell()) zeigeMeldung(s, meldung, art) }, neuLaden, } const abo = istAbo(s) // Abo-Modell: keine eigene Leiste, die Etage zeichnet sie (kn:reiter) const panel = h('div', { class: 'sh-panel', role: 'tabpanel', id: 'bk-panel', tabindex: '0' }) let aktiv = null let zaehler = 0 const knoepfe = abo ? [] : REITER.map((r) => h('button', { type: 'button', role: 'tab', class: 'sh-reiter', id: `bk-tab-${r.key}`, 'aria-controls': 'bk-panel', 'data-reiter': r.key, onclick: () => waehleReiter(r.key), }, r.label)) const leiste = abo ? null : h('div', { class: 'sh-reiterleiste', role: 'tablist', 'aria-label': 'Bereiche des Buchungskalenders' }, knoepfe) leiste?.addEventListener('keydown', (ev) => { const i = REITER.findIndex((r) => r.key === aktiv) const ziel = { ArrowRight: (i + 1) % REITER.length, ArrowLeft: (i + REITER.length - 1) % REITER.length, Home: 0, End: REITER.length - 1 }[ev.key] if (ziel === undefined) return ev.preventDefault() waehleReiter(REITER[ziel].key) knoepfe[ziel].focus() }) function waehleReiter(key) { const reiter = REITER.find((r) => r.key === key) || REITER[0] auswahl.reiter = reiter.key aktiv = reiter.key const nr = ++zaehler for (const k of knoepfe) { const an = k.dataset.reiter === reiter.key k.setAttribute('aria-selected', String(an)) k.tabIndex = an ? 0 : -1 } if (abo) { panel.setAttribute('aria-label', reiter.label) meldeReiter(s.root, { eintraege: reiterEintraege('buchungen', REITER), aktiv: reiter.key, bei: (neu) => waehleReiter(neu) }) } else panel.setAttribute('aria-labelledby', `bk-tab-${reiter.key}`) zeigeMeldung(s, '') panel.replaceChildren() reiter.laden() .then((modul) => (nr === zaehler && aktuell() ? modul.render(panel, ctx) : undefined)) .catch((err) => { if (nr === zaehler && aktuell()) panel.replaceChildren(fehlerKarte(err.message, () => waehleReiter(reiter.key))) }) } const hinweis = zustand === 'setup' ? 'Wir bauen deinen Buchungskalender gerade ein. Du kannst schon Unterkünfte, Preise und Einstellungen vorbereiten.' : zustand === 'disabled' ? 'Dein Buchungskalender ist abgeschaltet. Du kannst bestehende Buchungen noch ansehen und bearbeiten.' : '' const hinweisBox = h('div', { class: 'sh-hinweise' }, hinweis ? h('div', { class: `sh-box ${zustand === 'disabled' ? 'sh-box-warn' : 'sh-box-info'}`, role: 'note', 'data-hinweis': zustand }, hinweis) : null) s.buehne.replaceChildren(...[hinweisBox, leiste, panel].filter(Boolean)) waehleReiter(auswahl.reiter) }