Die Ansicht #termine folgt dem Zustand aus GET /api/termine/einstellungen: off mit laufendem Abo zeigt den Fragebogen samt Bestellseite, ohne Abo den Hinweis des Servers (termine.hinweis, R111) und bei kein_abo den Link zu Vertraege aus einer gemeinsamen Quelle (vertraege-link.js); ordered und setup zeigen die Statuskarte und die Reiter Leistungen, Team & Plaetze und Einstellungen nur zum Lesen (ohne Reiter Termine); active und disabled bleiben wie bisher. Rueckkehr von Stripe: #termine?bestellt=1&projekt=<id> und #termine?projekt=<id> waehlen das Projekt, "Danke!" nur mit bestellt=1, die Adresse wird einmal gelesen und bereinigt. R110: GET /einstellungen liefert bestellung und offen auch in ordered (ohne Abo-Punkt, abo bleibt null); scheitert "Zahlung fortsetzen", zeichnet die Huelle Liste und Daten neu (bei 409 zustimmung_fehlt mit dem Bestellformular, sonst nach dem neuen Zustand, die Meldung bleibt am Knopf, solange die Bestellung offen ist). Dazu: "null" als Text im lesbaren Reiter Leistungen entfernt, Export und Status der Einstellungen erst ab der Freischaltung. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
421 lines
22 KiB
JavaScript
421 lines
22 KiB
JavaScript
/* Terminplaner im Kundenbereich (ES-Modul; app.js laedt es erst beim Oeffnen der Ansicht #termine). Das ist die Huelle:
|
|
initTermine(rootEl, { projectId }): Projektauswahl und je nach Zustand der Funktion (GET /api/termine/einstellungen, state) die Ansicht:
|
|
off: ohne laufendes Wartungs-Abo (daten.abo.ok) der Hinweis wie in der Kachel (Text vom Server, GET /api/funktionen termine.hinweis; kein
|
|
Fragebogen), sonst der Fragebogen mit der Bestellseite (termine-fragebogen.js); ordered und setup: die Statuskarte (funktion-bestellen.js)
|
|
und darunter die Reiter Leistungen, Team & Plaetze und Einstellungen nur zum Lesen (der Reiter Termine entfaellt, der Server antwortet dort
|
|
mit 409); active: die Reiter, ohne Einstellung die Vorlagenauswahl; disabled: der Banner der Abschaltung (abschaltText) und die Reiter nur
|
|
zum Lesen. Rueckkehr von Stripe: #termine?bestellt=1&projekt=<id> (Zahlung) und #termine?projekt=<id> (Abbruch) waehlen das Projekt,
|
|
„Danke!“ gibt es nur mit bestellt=1; beides wird einmal gelesen und aus der Adresse genommen.
|
|
Die Reiter stehen in REITER: jeder ist ein Modul mit render(panel, ctx) => void | Promise (termine-tab-termine.js, termine-tab-leistungen.js,
|
|
termine-tab-team.js, termine-tab-einstellungen.js; die drei letzten speichern ueber termine-speichern.js).
|
|
ctx = { s, projectId, daten, nurLesen, auswahl, aktuell(), api, meldung, hinweise, zumReiter, neuLaden } (baueCtx): Zustand und Pruefung "bin ich noch
|
|
aktuell?" bekommen die Reiter nur ueber ctx, nie ueber Modulvariablen. daten ist die Antwort von GET /api/termine/einstellungen
|
|
(die Reiter, die speichern, halten daten.einstellung und daten.version aktuell), nurLesen gilt fuer alles ausser off und active,
|
|
hinweise() zeichnet die Hinweisleiste ueber den Reitern neu (z. B. wenn Telefon und Anschrift nach dem Speichern nicht mehr fehlen). Server: server/routes/termine.js. Tage sind Berliner Kalendertage 'JJJJ-MM-TT';
|
|
gerechnet wird in termine-view.js. Helfer ohne Zustand: termine-dom.js. Styles: termine.css. */
|
|
|
|
import { abschaltText, fehltText, heuteBerlin } from './termine-view.js'
|
|
import { exportLink, fehlerKarte, h, infoKarte, request } from './termine-dom.js'
|
|
import { render as renderTermine } from './termine-tab-termine.js'
|
|
import { render as renderLeistungen } from './termine-tab-leistungen.js'
|
|
import { render as renderTeam } from './termine-tab-team.js'
|
|
import { render as renderEinstellungen } from './termine-tab-einstellungen.js'
|
|
import { renderFragebogen } from './termine-fragebogen.js'
|
|
import { renderBestellen, statusKarte } from './funktion-bestellen.js'
|
|
import { ZU_VERTRAEGE } from './vertraege-link.js'
|
|
|
|
// Auswahl bleibt beim Wechsel zwischen Funktionen und Terminplaner erhalten (der Tag nicht: er beginnt immer bei heute)
|
|
const auswahl = { projectId: '', reiter: 'termine', ansicht: 'tag', filter: 'alle' }
|
|
let S = null // aktuelle Sitzung der Ansicht; eine alte Antwort darf eine neuere nicht ueberschreiben (S !== s)
|
|
|
|
/* --------------------------------------------------------- Einstieg --- */
|
|
/**
|
|
* Rueckkehr von Stripe aus der Adresse lesen und die Adresse bereinigen (wie initNews): #termine?bestellt=1&projekt=<id> nach der Zahlung
|
|
* (success_url) und #termine?projekt=<id> nach dem Abbruch (cancel_url). Gelesen wird einmal, danach steht nur noch #termine da.
|
|
*/
|
|
function leseRueckkehr() {
|
|
const hash = window.location.hash || ''
|
|
if (!hash.startsWith('#termine?')) return { bestellt: false, projekt: '' }
|
|
const query = new URLSearchParams(hash.slice(hash.indexOf('?') + 1))
|
|
window.history.replaceState(null, '', `${window.location.pathname}${window.location.search}#termine`)
|
|
return { bestellt: query.get('bestellt') === '1', projekt: query.get('projekt') || '' }
|
|
}
|
|
|
|
const zustandVon = (projekt) => projekt?.features?.termine?.state || 'off'
|
|
const istBestellt = (zustand) => zustand === 'ordered' || zustand === 'setup'
|
|
// Entwurf: Funktionsdokument im Zustand Aus mit siteKey (wie die Kachel in funktionen.js)
|
|
const hatEntwurf = (projekt) => zustandVon(projekt) === 'off' && Boolean(projekt?.features?.termine?.siteKey)
|
|
|
|
export async function initTermine(root, { projectId } = {}) {
|
|
if (!root) return
|
|
const rueckkehr = leseRueckkehr()
|
|
const wunsch = rueckkehr.projekt || projectId || root.dataset.wunschProjekt || '' // die Kachel in Funktionen legt ihr Projekt hier ab
|
|
delete root.dataset.wunschProjekt
|
|
if (wunsch) auswahl.projectId = wunsch
|
|
const { projects = [], termine } = await request('/api/funktionen')
|
|
const s = { root, projects, termine: termine || null, projekt: null, daten: null, tag: heuteBerlin(), nurLesen: false, lauf: 0, buehne: null, meldungEl: null, danke: false }
|
|
S = s
|
|
if (!termine?.verfuegbar) {
|
|
root.replaceChildren(infoKarte('Der Terminplaner ist noch nicht verfügbar', 'Sobald er für dich bereit ist, findest du ihn hier.'))
|
|
return
|
|
}
|
|
if (!projects.length) {
|
|
root.replaceChildren(h('div', { class: 'kb-panel kb-empty' }, 'Noch keine Website-Projekte zugewiesen.'))
|
|
return
|
|
}
|
|
// Wahl wie bei der Kachel: das gewuenschte Projekt, dann aktiv, bestellt, abgeschaltet, Entwurf, das erste
|
|
s.projekt = projects.find((p) => p.projectId === auswahl.projectId)
|
|
|| projects.find((p) => zustandVon(p) === 'active') || projects.find((p) => istBestellt(zustandVon(p)))
|
|
|| projects.find((p) => zustandVon(p) === 'disabled') || projects.find(hatEntwurf) || projects[0]
|
|
auswahl.projectId = s.projekt.projectId
|
|
// „Danke!“ nur nach der Zahlung (bestellt=1) und nur fuer das Projekt, fuer das bezahlt wurde
|
|
s.danke = rueckkehr.bestellt && (!rueckkehr.projekt || rueckkehr.projekt === s.projekt.projectId)
|
|
s.buehne = h('div', { id: 'termine-buehne' })
|
|
root.replaceChildren(...(projects.length > 1 ? [projektwahl(s)] : []), s.buehne)
|
|
await zeigeProjekt(s)
|
|
}
|
|
|
|
function projektwahl(s) {
|
|
const select = h('select', { id: 'termine-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.tag = heuteBerlin()
|
|
s.danke = false // der Dank gilt dem Projekt, mit dem man von Stripe zurueckkam
|
|
zeigeProjekt(s)
|
|
})
|
|
return h('div', { class: 'fn-project kb-panel' }, h('label', { for: 'termine-project' }, 'Website'), select)
|
|
}
|
|
|
|
const ladeDaten = (projectId) => request(`/api/termine/einstellungen?projectId=${encodeURIComponent(projectId)}`)
|
|
|
|
/**
|
|
* Liste der Funktionen neu holen (GET /api/funktionen): Projekte, das aktuelle Projekt darin, Preis, Einrichtungsdauer und Abo-Hinweis des
|
|
* Servers. Wirft bei einem Fehler und aendert dann nichts.
|
|
*/
|
|
async function ladeFunktionen(s) {
|
|
const antwort = await request('/api/funktionen')
|
|
const projects = Array.isArray(antwort.projects) ? antwort.projects : []
|
|
s.projects = projects
|
|
s.termine = antwort.termine || null
|
|
s.projekt = projects.find((p) => p.projectId === s.projekt?.projectId) || s.projekt
|
|
}
|
|
|
|
/**
|
|
* Hinweis fuer abgeschaltete Funktionen: Grund, Loeschdatum und (ausser vor der Einrichtung) der Export-Link, der einzige Link im Hinweis.
|
|
* abgeschaltet: { grund, seit, loeschungAm } aus GET /einstellungen (fehlt bei einer aelteren Antwort: dann der bisherige Satz).
|
|
*/
|
|
function hinweisAbgeschaltet(projectId, abgeschaltet) {
|
|
const { davor, danach, mitExport } = abschaltText(abgeschaltet)
|
|
// ein einziger Textblock in der Flex-Zeile der Box, sonst zerfaellt der Satz in drei Spalten (Text, Link, Punkt)
|
|
return h('div', { class: 'tm-box tm-box-warn', role: 'note' }, h('span', null, davor, mitExport && exportLink(projectId, 'exportieren'), danach))
|
|
}
|
|
|
|
/**
|
|
* Je nach Zustand der Funktion die Ansicht zeigen (siehe Kopf der Datei). Jeder Aufruf zaehlt s.lauf hoch: die Antwort
|
|
* einer frueheren Website (langsam, dann zur anderen gewechselt) wird verworfen und erscheint nie unter der neuen.
|
|
*/
|
|
async function zeigeProjekt(s, optionen = {}) {
|
|
const projekt = s.projekt
|
|
const lauf = ++s.lauf
|
|
const aktuell = () => S === s && s.lauf === lauf
|
|
s.buehne.replaceChildren(h('p', { class: 'kb-empty' }, 'Lade…'))
|
|
let daten
|
|
try {
|
|
daten = await ladeDaten(projekt.projectId)
|
|
} catch (err) {
|
|
if (aktuell()) s.buehne.replaceChildren(fehlerKarte(err.message, () => zeigeProjekt(s)))
|
|
return
|
|
}
|
|
if (!aktuell()) return
|
|
await zeichneAnsicht(s, { daten, aktuell, ...optionen })
|
|
}
|
|
|
|
/**
|
|
* Ansicht nach einer gescheiterten Aktion der Statuskarte neu aufbauen (Ruling R110): Liste und Daten frisch holen und erst dann zeichnen.
|
|
* Scheitert das Laden, wirft die Funktion und die Karte bleibt mit ihrer Meldung stehen. optionen: bestellformular (ordered zeigt das
|
|
* Bestellformular statt der Karte), meldung ({ text, art }, nur im Zustand ordered noch von Belang).
|
|
*/
|
|
async function ladeNeu(s, optionen = {}) {
|
|
const lauf = s.lauf
|
|
await ladeFunktionen(s)
|
|
const daten = await ladeDaten(s.projekt.projectId)
|
|
if (S !== s || s.lauf !== lauf) return // inzwischen hat ein Wechsel der Website die Ansicht ersetzt
|
|
const nr = ++s.lauf
|
|
await zeichneAnsicht(s, { daten, aktuell: () => S === s && s.lauf === nr, ...optionen })
|
|
}
|
|
|
|
/** Die Ansicht zum Zustand aus den frisch geladenen Daten (daten.state; faellt auf den Eintrag der Liste zurueck). */
|
|
async function zeichneAnsicht(s, { daten, aktuell, bestellformular = false, meldung = null }) {
|
|
const projekt = s.projekt
|
|
s.daten = daten
|
|
const zustand = daten.state || zustandVon(projekt)
|
|
s.nurLesen = zustand !== 'active' && zustand !== 'off'
|
|
if (zustand === 'off') {
|
|
if (daten.abo?.ok === true) zeigeFragebogen(s, { projekt, aktuell })
|
|
else await zeigeAboHinweis(s, { aktuell })
|
|
return
|
|
}
|
|
if (istBestellt(zustand)) {
|
|
await zeigeBestellung(s, { zustand, aktuell, bestellformular, meldung })
|
|
return
|
|
}
|
|
if (daten.einstellung) zeigeReiter(s, { projekt, aktuell, zustand })
|
|
else if (s.nurLesen) {
|
|
s.buehne.replaceChildren(
|
|
hinweisAbgeschaltet(projekt.projectId, daten.abgeschaltet),
|
|
infoKarte('Keine Termine eingerichtet', 'Für diese Website wurden keine Einstellungen und Termine angelegt.', { chat: true }))
|
|
} else zeigeVorlagen(s, { projekt, aktuell })
|
|
}
|
|
|
|
const neueMeldung = () => h('div', { class: 'tm-meldung', role: 'status', 'aria-live': 'polite', tabindex: '-1' })
|
|
|
|
/* ----------------------------------------------- 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.
|
|
*/
|
|
async function zeigeAboHinweis(s, { aktuell }) {
|
|
if (typeof s.termine?.hinweis !== 'string' || s.termine.hinweis === '') {
|
|
try {
|
|
await ladeFunktionen(s)
|
|
} catch {
|
|
// ohne Text bleibt der Hinweis bei der Ueberschrift (und dem Link bei kein_abo)
|
|
}
|
|
if (!aktuell()) return
|
|
}
|
|
const hinweis = typeof s.termine?.hinweis === 'string' ? s.termine.hinweis : ''
|
|
const link = s.termine?.bestellbar === false && s.termine.grund === 'kein_abo'
|
|
? 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))
|
|
}
|
|
|
|
/** Der Fragebogen mit der Bestellseite danach (termine-fragebogen.js); die Meldung ueber ihm ist s.meldungEl (ctx.meldung). */
|
|
function zeigeFragebogen(s, { projekt, aktuell }) {
|
|
s.meldungEl = neueMeldung()
|
|
const wurzel = h('div', { class: 'tm-fb-wurzel' })
|
|
s.buehne.replaceChildren(s.meldungEl, wurzel)
|
|
renderFragebogen(wurzel, baueCtx(s, { projekt, aktuell }))
|
|
}
|
|
|
|
/* ----------------------------------------------- Zustand ordered, setup --- */
|
|
const GESPERRT = 'Deine Angaben sind seit der Bestellung gesperrt. Änderungen laufen über den Chat.'
|
|
|
|
/**
|
|
* Statuskarte (Zahlung offen | In Einrichtung) und darunter die Reiter zum Lesen. Der Eintrag der Liste liefert Bestelldatum, Ticketnummer und
|
|
* Rechnung; stimmt sein Zustand nicht mit dem der Daten ueberein (die Liste ist aelter, z. B. nach der Zahlung), wird sie neu geholt.
|
|
* Die Karte meldet ueber weiter(), wenn „Zahlung fortsetzen“ scheitert: ladeNeu zeichnet die Ansicht dann neu (bei 409 zustimmung_fehlt
|
|
* mit dem Bestellformular statt der Karte). Das Bestellformular (daten.bestellung, R110) zeigt dieselbe Seite wie der Fragebogen.
|
|
*/
|
|
async function zeigeBestellung(s, { zustand, aktuell, bestellformular, meldung }) {
|
|
if (s.projekt.features?.termine?.state !== zustand) {
|
|
try {
|
|
await ladeFunktionen(s)
|
|
} catch {
|
|
// es gilt der Zustand der Daten; was nur die Liste kennt (Ticketnummer, Rechnung), fehlt dann in der Karte
|
|
}
|
|
if (!aktuell()) return
|
|
}
|
|
const projekt = s.projekt
|
|
const eintrag = { ...projekt.features?.termine, state: zustand }
|
|
const danke = s.danke
|
|
s.danke = false // nur beim ersten Zeichnen nach der Rueckkehr
|
|
const formular = bestellformular && zustand === 'ordered' && Boolean(s.daten.bestellung)
|
|
const kopf = (ctx) => {
|
|
if (formular) {
|
|
const seite = h('div', { class: 'tm-fb-bestellen' })
|
|
renderBestellen(seite, { ctx, daten: s.daten, zumSchritt: () => ctx.meldung(GESPERRT, 'fehler') })
|
|
return seite
|
|
}
|
|
return statusKarte({
|
|
projekt,
|
|
bestellung: eintrag,
|
|
einrichtung: s.termine?.einrichtung,
|
|
danke,
|
|
kundenansicht: s.daten.kundenansicht === true,
|
|
meldung: zustand === 'ordered' && meldung ? meldung.text : '', // die Meldung gilt nur, solange die Bestellung noch offen ist
|
|
}, { weiter: (fall) => ladeNeu(s, { bestellformular: fall?.bestellen === true, meldung: fall?.meldung ? { text: fall.meldung } : null }) })
|
|
}
|
|
zeigeReiter(s, { projekt, aktuell, zustand, kopf, meldung: formular && meldung ? { text: meldung.text, art: 'ok' } : null })
|
|
}
|
|
|
|
/* ---------------------------------------------------- Vorlage wählen --- */
|
|
function zeigeVorlagen(s, { projekt, aktuell }) {
|
|
const fehler = h('p', { class: 'tm-fehler', role: 'alert' })
|
|
const start = h('button', { type: 'button', class: 'kb-btn-primary', disabled: true }, 'Mit dieser Vorlage starten')
|
|
let gewaehlt = null
|
|
const karten = (s.daten.vorlagen || []).map((v) => {
|
|
const radio = h('input', { type: 'radio', name: 'termine-vorlage', value: v.key })
|
|
radio.addEventListener('change', () => { gewaehlt = v; start.disabled = false; fehler.textContent = '' })
|
|
return h('label', { class: 'tm-vorlage' }, radio, h('span', null, h('strong', null, v.name), h('small', null, v.beschreibung)))
|
|
})
|
|
start.addEventListener('click', async () => {
|
|
if (!gewaehlt) return
|
|
start.disabled = true
|
|
fehler.textContent = ''
|
|
try {
|
|
await request('/api/termine/einstellungen/vorlage', { method: 'POST', body: { projectId: projekt.projectId, vorlage: gewaehlt.key } })
|
|
const daten = await ladeDaten(projekt.projectId)
|
|
if (!aktuell()) return
|
|
s.daten = daten
|
|
zeigeReiter(s, { projekt, aktuell })
|
|
zeigeMeldung(s, `Die Vorlage „${gewaehlt.name}“ ist angelegt. Alles darin ist ein Vorschlag, den du anpassen kannst.`)
|
|
} catch (err) {
|
|
fehler.textContent = err.message
|
|
start.disabled = false
|
|
}
|
|
})
|
|
s.buehne.replaceChildren(h('div', { class: 'kb-panel tm-vorlagen' },
|
|
h('h3', null, 'Womit möchtest du starten?'),
|
|
h('p', null, 'Such dir die Vorlage aus, die am besten zu deinem Betrieb passt. Die Vorlage legt Leistungen, Zeiten und Regeln als Vorschlag an. Du kannst danach alles ändern.'),
|
|
h('fieldset', { class: 'tm-vorlagen-liste' }, h('legend', { class: 'tm-sr' }, 'Vorlage'), karten),
|
|
fehler,
|
|
h('div', { class: 'abo-actions' }, start)))
|
|
}
|
|
|
|
/* -------------------------------------------------------- Reiter-Gerüst --- */
|
|
/** key, Beschriftung, Funktion (panel, ctx). */
|
|
const REITER = [
|
|
{ key: 'termine', label: 'Termine', render: renderTermine },
|
|
{ key: 'leistungen', label: 'Leistungen', render: renderLeistungen },
|
|
{ key: 'team', label: 'Team & Plätze', render: renderTeam },
|
|
{ key: 'einstellungen', label: 'Einstellungen', render: renderEinstellungen },
|
|
]
|
|
|
|
/**
|
|
* Meldung ueber der Reiterleiste: art 'ok' (Standard) oder 'fehler'; leerer Text blendet sie aus. Die Meldung ist die einzige Live-Region
|
|
* der Ansicht. Sie erscheint dort, wo man hinschaut: liegt sie ausserhalb des Bildes, wird sie hingescrollt (block: 'nearest'), eine
|
|
* Fehlermeldung bekommt zusaetzlich den Fokus.
|
|
*/
|
|
function zeigeMeldung(s, text, art = 'ok') {
|
|
const el = s.meldungEl
|
|
if (!el) return
|
|
el.textContent = text || ''
|
|
el.className = `tm-meldung tm-meldung-${art}`
|
|
el.setAttribute('role', art === 'fehler' ? 'alert' : 'status')
|
|
el.setAttribute('aria-live', art === 'fehler' ? 'assertive' : 'polite')
|
|
if (!text) return
|
|
if (art === 'fehler') el.focus({ preventScroll: true })
|
|
el.scrollIntoView({ block: 'nearest' })
|
|
}
|
|
|
|
/**
|
|
* Kontext fuer die Module, die in der Huelle laufen (die Reiter, der Fragebogen): Zustand und die Pruefung "bin ich noch aktuell?" kommen nur hierher.
|
|
* hinweise() zeichnet die Hinweisleiste ueber den Reitern neu (der Fragebogen hat keine), zumReiter(key) wechselt den Reiter; beide duerfen fehlen.
|
|
* nurLesen und daten sind die der Huelle im Moment des Aufrufs; neuLaden() holt die Daten neu und ersetzt sie in s und im Kontext.
|
|
*/
|
|
export function baueCtx(s, { projekt, aktuell, hinweise = () => {}, zumReiter = () => {} }) {
|
|
const projectId = projekt.projectId
|
|
const ctx = {
|
|
s,
|
|
projectId,
|
|
daten: s.daten,
|
|
nurLesen: s.nurLesen,
|
|
auswahl,
|
|
aktuell,
|
|
api: {
|
|
get: (pfad, query = {}) => request(`/api/termine${pfad}?${new URLSearchParams({ projectId, ...query })}`),
|
|
send: (method, pfad, body = {}) => (method === 'DELETE'
|
|
? request(`/api/termine${pfad}?${new URLSearchParams({ projectId })}`, { method })
|
|
: request(`/api/termine${pfad}`, { method, body: { projectId, ...body } })),
|
|
},
|
|
// eine Meldung, die erst nach dem Wechsel der Website oder der Ansicht eintrifft, gehoert nicht mehr auf diese Seite
|
|
meldung: (text, art) => { if (aktuell()) zeigeMeldung(s, text, art) },
|
|
hinweise: () => { if (aktuell()) hinweise() },
|
|
zumReiter: (key) => zumReiter(key),
|
|
neuLaden: async () => {
|
|
const daten = await ladeDaten(projectId)
|
|
if (aktuell()) {
|
|
s.daten = daten
|
|
ctx.daten = daten
|
|
hinweise()
|
|
}
|
|
return daten
|
|
},
|
|
}
|
|
return ctx
|
|
}
|
|
|
|
/**
|
|
* Die Reiter (Hinweisleiste, Meldung, Reiterleiste, Panel). zustand steuert, was dazugehoert: disabled hat den Banner der Abschaltung,
|
|
* active den Hinweis auf fehlende Pflichtangaben, ordered und setup keinen der beiden und keinen Reiter „Termine“ (der Server antwortet
|
|
* dort mit 409). kopf(ctx) liefert ein Element, das ueber der Meldung steht (die Statuskarte), meldung ({ text, art }) wird nach dem
|
|
* ersten Zeichnen angezeigt. Ohne Einstellung gibt es keine Reiter, dann bleibt es beim Kopf.
|
|
*/
|
|
function zeigeReiter(s, { projekt, aktuell, zustand = s.nurLesen ? 'disabled' : 'active', kopf = null, meldung = null }) {
|
|
const projectId = projekt.projectId
|
|
const nurLesen = s.nurLesen
|
|
s.meldungEl = neueMeldung()
|
|
const ctx = baueCtx(s, { projekt, aktuell, hinweise: zeichneHinweise, zumReiter: waehleReiter })
|
|
const reiterListe = istBestellt(zustand) ? REITER.filter((r) => r.key !== 'termine') : REITER
|
|
const kopfEl = kopf ? kopf(ctx) : null
|
|
|
|
// Hinweisleiste: abgeschaltet, oder Pflichtangaben fuer Gaeste fehlen (GET /einstellungen: fehlt). Sie folgt s.daten.
|
|
const hinweisBox = h('div', { class: 'tm-hinweise' })
|
|
function zeichneHinweise() {
|
|
const hinweise = []
|
|
if (zustand === 'disabled') {
|
|
hinweise.push(hinweisAbgeschaltet(projectId, s.daten.abgeschaltet))
|
|
} else if (!nurLesen && (s.daten.fehlt || []).length) {
|
|
hinweise.push(h('div', { class: 'tm-box tm-box-warn', role: 'note' },
|
|
h('span', null, `Noch nicht buchbar: Es fehlt ${fehltText(s.daten.fehlt) || 'eine Angabe zu deinem Betrieb'}. Solange das fehlt, können Gäste über deine Website nicht buchen.`),
|
|
h('button', { type: 'button', class: 'kb-btn-outline', onclick: () => waehleReiter('einstellungen') }, 'Zu den Einstellungen')))
|
|
}
|
|
hinweisBox.replaceChildren(...hinweise)
|
|
}
|
|
zeichneHinweise()
|
|
|
|
if (!s.daten.einstellung) {
|
|
s.buehne.replaceChildren(...[hinweisBox, kopfEl, s.meldungEl].filter(Boolean))
|
|
if (meldung) zeigeMeldung(s, meldung.text, meldung.art)
|
|
return
|
|
}
|
|
|
|
const panel = h('div', { class: 'tm-panel', role: 'tabpanel', id: 'tm-panel', tabindex: '0' })
|
|
let aktiv = null // Schluessel des gezeigten Reiters
|
|
const knoepfe = reiterListe.map((r) => h('button', {
|
|
type: 'button', role: 'tab', class: 'tm-reiter', id: `tm-tab-${r.key}`, 'aria-controls': 'tm-panel', 'data-reiter': r.key,
|
|
onclick: () => waehleReiter(r.key),
|
|
}, r.label))
|
|
const leiste = h('div', { class: 'tm-reiterleiste', role: 'tablist', 'aria-label': 'Bereiche des Terminplaners' }, knoepfe)
|
|
leiste.addEventListener('keydown', (ev) => {
|
|
const i = reiterListe.findIndex((r) => r.key === aktiv)
|
|
const ziel = { ArrowRight: (i + 1) % reiterListe.length, ArrowLeft: (i + reiterListe.length - 1) % reiterListe.length, Home: 0, End: reiterListe.length - 1 }[ev.key]
|
|
if (ziel === undefined) return
|
|
ev.preventDefault()
|
|
waehleReiter(reiterListe[ziel].key)
|
|
knoepfe[ziel].focus()
|
|
})
|
|
|
|
function waehleReiter(key, { merken = true } = {}) {
|
|
// ein Reiter, den es in diesem Zustand nicht gibt (Termine in ordered und setup), wird nicht gemerkt: die Auswahl gilt fuer die naechste Website weiter
|
|
const reiter = reiterListe.find((r) => r.key === key) || reiterListe[0]
|
|
if (merken && reiter.key === key) auswahl.reiter = reiter.key
|
|
aktiv = reiter.key
|
|
for (const k of knoepfe) {
|
|
const an = k.dataset.reiter === reiter.key
|
|
k.setAttribute('aria-selected', String(an))
|
|
k.tabIndex = an ? 0 : -1
|
|
}
|
|
panel.setAttribute('aria-labelledby', `tm-tab-${reiter.key}`)
|
|
zeigeMeldung(s, '')
|
|
panel.replaceChildren()
|
|
Promise.resolve()
|
|
.then(() => reiter.render(panel, ctx))
|
|
.catch((err) => { if (aktuell()) panel.replaceChildren(fehlerKarte(err.message, () => waehleReiter(reiter.key))) })
|
|
}
|
|
|
|
s.buehne.replaceChildren(...[hinweisBox, kopfEl, s.meldungEl, leiste, panel].filter(Boolean)) // replaceChildren(null) schriebe „null“ in die Seite
|
|
waehleReiter(auswahl.reiter, { merken: false })
|
|
if (meldung) zeigeMeldung(s, meldung.text, meldung.art)
|
|
}
|