/* Oeffnungszeiten live im Kundenbereich: die Ansicht #zeiten (ES-Modul, app.js laedt es beim Oeffnen der Ansicht; Styles: zeiten.css, dazu die gemeinsamen tm-Klassen aus termine.css). Server: server/routes/funktionen.js (Liste, Einschalten, Bestellen, Behalten) und server/routes/zeiten.js (Editor, Aufgaben 29 und 30). initZeiten(rootEl, { projectId }): Projektauswahl (bei mehr als einer Website) und je nach Zustand der Funktion (features.zeiten.state aus GET /api/funktionen): off Einschalten (Wartungs-Abo laeuft), Bestellen (30 €) oder ein Hinweis (Abo noch nicht aktiv, nicht lesbar): renderZeitenBestellen ordered Statuskarte „Zahlung wird bestätigt …“ (Zahlung fortsetzen) setup Statuskarte (Einbau, Ticket, „Trag jetzt deine Zeiten ein“) und darunter der Platz des Editors active Statuskarte und der Platz des Editors disabled Statuskarte mit dem Grund; „Für 30 € wieder einschalten“, solange die Eingaben da sind; der Platz des Editors zum Lesen Das Abo-Ende (aboEndeAt) zeigt in setup und active den Hinweis „behalte die Funktion bis für einmalig 30 €“ mit dem Knopf zur Behalten-Seite. Rueckkehr von Stripe: #zeiten?bestellt=1&projekt= (Bestellung) und #zeiten?behalten=1&projekt= (Behalten) waehlen das Projekt und zeigen „Danke!“; #zeiten?projekt= (Abbruch) waehlt nur das Projekt. Beides wird einmal gelesen und aus der Adresse genommen. Die Kachel in Funktionen legt Projekt und Weg in den Behaelter ab (dataset.wunschProjekt, dataset.wunschWeg = 'behalten'). Der Editor haengt in .zt-editor ein (renderEditor(box, ctx) aus zeiten-editor.js). Ruft app.js initZeiten bei jedem Wechsel in die Ansicht, baut sich nichts neu auf, solange Projekt und Stand gleich sind und der Editorplatz steht (Muster initNews): Eingaben im Editor bleiben stehen. Aendert sich der Stand (zeitenLeer nach dem ersten Speichern, setup → active, die Abo-Lage), wird nur der Kopf (Statuskarte) neu gezeichnet: der Editorplatz bleibt mit seinem Editor und den ungespeicherten Eingaben stehen, solange Website und Schreibrecht (abgeschaltet = nur lesen) gleich sind (data-projekt, data-state); eine andere Website oder der Wechsel in „nur lesen“ bekommt einen frischen Editor. Die Rueckkehr von Stripe zeichnet immer ganz neu. Kundenansicht (window.KbView.id() gesetzt): Einschalten, Bestellen und Behalten sind gesperrt, der Server antwortet dort ohnehin mit 403. */ import { fehlerKarte, h, infoKarte, request } from './termine-dom.js' import { behaltenLage, renderZeitenBestellen, renderZeitenEinschalten, zeitenStatusKarte } from './zeiten-bestellen.js' import { renderEditor } from './zeiten-editor.js' // Auswahl bleibt beim Wechsel zwischen Funktionen und Zeiten erhalten const auswahl = { projectId: '' } let S = null // aktuelle Sitzung der Ansicht; eine alte Antwort darf eine neuere nicht ueberschreiben (S !== s) const zustandVon = (projekt) => projekt?.features?.zeiten?.state || 'off' const istBestellt = (zustand) => zustand === 'ordered' || zustand === 'setup' const kundenansicht = () => Boolean(window.KbView?.id?.()) /** Rueckkehr von Stripe aus der Adresse lesen und die Adresse bereinigen (wie initNews/initTermine). */ function leseRueckkehr() { const hash = window.location.hash || '' if (!hash.startsWith('#zeiten?')) return { bestellt: false, behalten: false, projekt: '' } const query = new URLSearchParams(hash.slice(hash.indexOf('?') + 1)) window.history.replaceState(null, '', `${window.location.pathname}${window.location.search}#zeiten`) return { bestellt: query.get('bestellt') === '1', behalten: query.get('behalten') === '1', projekt: query.get('projekt') || '' } } // Vorauswahl wie bei der Kachel: aktiv, dann bestellt/in Einrichtung, dann abgeschaltet, sonst die erste function pickProjekt(projects) { return projects.find((p) => zustandVon(p) === 'active') || projects.find((p) => istBestellt(zustandVon(p))) || projects.find((p) => zustandVon(p) === 'disabled') || projects[0] } /** * Darf der Kunde die Funktion jetzt behalten (30 €)? Nach dem erkannten Abo-Ende in Einrichtung/aktiv, abgeschaltet nur mit gespeicherten Eingaben, * und nur ohne Abo (behaltenLage 'bezahlen': inklusive false UND grund kein_abo); laeuft das Abo (nicht_aktiv, gekuendigt, inklusive), gibt es nichts zu bezahlen. */ function behaltenMoeglich(projekt, angebot, modell) { if (modell === 'abo') return false // Abo-Modell: kein Behalten gegen Geld (Z2: Öffnungszeiten live läuft noch bis zur Frist) const feature = projekt?.features?.zeiten if (!feature || behaltenLage(angebot) !== 'bezahlen') return false if (feature.state === 'setup' || feature.state === 'active') return Boolean(feature.aboEndeAt) return feature.state === 'disabled' && feature.disabledReason === 'abo_ende' && projekt.zeitenLeer === false } export async function initZeiten(root, { projectId, einschalten } = {}) { if (!root) return const rueckkehr = leseRueckkehr() const wunsch = rueckkehr.projekt || projectId || root.dataset.wunschProjekt || '' // die Kachel in Funktionen legt ihr Projekt hier ab const wunschWeg = root.dataset.wunschWeg === 'behalten' ? 'behalten' : '' delete root.dataset.wunschProjekt delete root.dataset.wunschWeg if (wunsch) auswahl.projectId = wunsch const antwort = await request('/api/funktionen') const projects = Array.isArray(antwort.projects) ? antwort.projects : [] const s = { root, projects, angebot: antwort.zeiten || null, features: antwort.features || {}, ustHinweis: typeof antwort.ustHinweis === 'string' ? antwort.ustHinweis : '', modell: antwort.modell, einschalten, projekt: null, lauf: 0, buehne: null, danke: '', editorAlt: null, } // der Editorplatz der bisherigen Ansicht (mit seinem Editor) wird in den neuen Aufbau mitgenommen, ausser nach der Rueckkehr von Stripe const alterEditor = !rueckkehr.bestellt && !rueckkehr.behalten && S?.root === root ? root.querySelector('.zt-editor') : null if (!s.angebot?.verfuegbar) { S = s root.dataset.zeitenKey = '' root.replaceChildren(infoKarte('Öffnungszeiten live ist noch nicht verfügbar', 'Sobald es für dich bereit ist, findest du es hier.')) return } if (!projects.length) { S = s root.dataset.zeitenKey = '' root.replaceChildren(h('div', { class: 'kb-panel kb-empty' }, 'Noch keine Website-Projekte zugewiesen.')) return } s.projekt = projects.find((p) => p.projectId === auswahl.projectId) || pickProjekt(projects) auswahl.projectId = s.projekt.projectId // Nicht neu aufbauen, solange Projekt und Stand gleich sind und der Editorplatz steht: ungespeicherte Eingaben bleiben (Muster initNews). // Ein Weg (Behalten aus der Kachel) oder die Rueckkehr von Stripe zeichnen immer neu; der Wechsel in die Behalten-Seite behaelt den Editor. const key = stand(s.projekt, s.angebot) const editorDa = root.dataset.zeitenKey === key && root.querySelector('.zt-editor') && S?.root === root if (editorDa && !rueckkehr.bestellt && !rueckkehr.behalten) { if (wunschWeg === 'behalten' && behaltenMoeglich(S.projekt, S.angebot, S.modell)) zeigeBehalten(S) return } S = s s.editorAlt = alterEditor s.danke = rueckkehr.bestellt && (!rueckkehr.projekt || rueckkehr.projekt === s.projekt.projectId) ? 'bestellt' : rueckkehr.behalten && (!rueckkehr.projekt || rueckkehr.projekt === s.projekt.projectId) ? 'behalten' : '' s.buehne = h('div', { id: 'zeiten-buehne' }) root.replaceChildren(...(projects.length > 1 ? [projektwahl(s)] : []), s.buehne) zeichneProjekt(s, { weg: s.modell === 'abo' ? '' : wunschWeg }) } /** Alles, was die Statuskarte zeigt und der Editorplatz davon abhaengt (auch die Abo-Lage: Behalten gibt es nur ohne Abo); aendert es sich, wird neu aufgebaut. */ function stand(projekt, angebot) { const f = projekt?.features?.zeiten || {} return [projekt?.projectId, f.state, f.aboEndeAt, f.einbau, f.ticketWoid, f.disabledReason, f.unlockedBy, projekt?.zeitenLeer, angebot?.inklusive, angebot?.grund].join(':') } function projektwahl(s) { const select = h('select', { id: 'zeiten-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.danke = '' // der Dank gilt dem Projekt, mit dem man von Stripe zurueckkam zeichneProjekt(s) }) return h('div', { class: 'fn-project kb-panel' }, h('label', { for: 'zeiten-project' }, 'Website'), select) } /** Liste der Funktionen neu holen (Projekte, Angebot, Register-Ansicht). Wirft bei einem Fehler und aendert dann nichts. */ async function ladeListe(s) { const antwort = await request('/api/funktionen') const projects = Array.isArray(antwort.projects) ? antwort.projects : [] s.projects = projects s.angebot = antwort.zeiten || null s.modell = antwort.modell s.features = antwort.features || {} s.ustHinweis = typeof antwort.ustHinweis === 'string' ? antwort.ustHinweis : '' s.projekt = projects.find((p) => p.projectId === s.projekt?.projectId) || s.projekt } /** * Nach einem Konflikt (409) oder dem Einschalten: Liste frisch holen und die Ansicht nach dem neuen Stand zeichnen (optionen: meldung des Servers, * danke). Scheitert das Laden, wirft die Funktion und die Seite bleibt mit ihrer Meldung stehen. Eine Antwort, die erst nach einem Wechsel der * Website eintrifft, wird verworfen. */ async function neuZeichnen(s, optionen = {}) { const lauf = s.lauf await ladeListe(s) if (S !== s || s.lauf !== lauf) return if (!s.angebot?.verfuegbar) { s.root.dataset.zeitenKey = '' s.root.replaceChildren(infoKarte('Öffnungszeiten live ist noch nicht verfügbar', 'Sobald es für dich bereit ist, findest du es hier.')) return } if (optionen.danke) s.danke = optionen.danke zeichneProjekt(s, { meldung: optionen.meldung }) } /** Kontext der Seiten in der Huelle (zeiten-bestellen.js): Projekt, Register-Ansicht, Kundenansicht und die Wege, die Ansicht nach dem Server neu zu zeichnen. */ function baueCtx(s, projekt) { return { projectId: projekt.projectId, features: s.features, ustHinweis: s.ustHinweis, kundenansicht: kundenansicht(), einschalten: s.einschalten, // Ablauf des Einschaltens im Abo-Modell (Init-Option der Hülle) neuZeichnen: (optionen) => neuZeichnen(s, optionen), fertig: ({ danke } = {}) => neuZeichnen(s, { danke }), } } /** Die Ansicht zum Zustand des gewaehlten Projekts zeichnen (siehe Kopf der Datei). Jeder Aufruf zaehlt s.lauf hoch. */ function zeichneProjekt(s, { weg = '', meldung = '', bestellformular = false } = {}) { const projekt = s.projekt ++s.lauf const ctx = baueCtx(s, projekt) const zustand = zustandVon(projekt) const feature = projekt.features?.zeiten || {} const danke = s.danke s.danke = '' // nur beim ersten Zeichnen nach der Rueckkehr s.root.dataset.zeitenKey = '' const kopf = h('div', { class: 'zt-kopf' }) const teile = [] const meldungEl = meldung ? h('p', { class: 'tm-meldung tm-meldung-fehler', role: 'alert', tabindex: '-1', 'data-meldung': 'ansicht' }, meldung) : null if (meldungEl) teile.push(meldungEl) teile.push(kopf) // Der Editorplatz steht in setup, active und (mit Daten) disabled. Ein Platz derselben Website mit gleichem Schreibrecht bleibt samt Editor stehen // (ungespeicherte Eingaben gehen nicht verloren, der Editor laedt nicht noch einmal); sonst gibt es einen frischen. let editorPlatz = null let frisch = false if (zustand === 'setup' || zustand === 'active' || (zustand === 'disabled' && projekt.zeitenLeer === false)) { const vorhanden = s.editorAlt || s.buehne.querySelector('.zt-editor') if (vorhanden && vorhanden.dataset.projekt === projekt.projectId && (vorhanden.dataset.state === 'disabled') === (zustand === 'disabled')) { editorPlatz = vorhanden editorPlatz.dataset.state = zustand editorPlatz.hidden = false // die Behalten-Seite versteckt ihn; sie wird bei Bedarf unten wieder geoeffnet } else { editorPlatz = h('div', { class: 'zt-editor', 'data-state': zustand, 'data-projekt': projekt.projectId }) frisch = true } teile.push(editorPlatz) } s.editorAlt = null s.buehne.replaceChildren(...teile) if (editorPlatz) s.root.dataset.zeitenKey = stand(projekt, s.angebot) if (frisch) { // neuZeichnen gehoert zur jeweils aktuellen Sitzung (S): der Platz kann einen Neuaufbau der Ansicht ueberleben const editorCtx = { projectId: projekt.projectId, kundenansicht: ctx.kundenansicht, neuZeichnen: (optionen) => (S ? neuZeichnen(S, optionen) : undefined) } Promise.resolve(renderEditor(editorPlatz, editorCtx)).catch((err) => editorPlatz.replaceChildren(fehlerKarte(err?.message || 'Der Editor konnte nicht gezeichnet werden.'))) } // die Meldung des Servers ersetzt die Seite, auf der der Kunde geklickt hat: dorthin schauen, nicht ins Leere if (meldungEl) { meldungEl.focus({ preventScroll: true }) meldungEl.scrollIntoView({ block: 'nearest' }) } const statusKarte = (zusatz = {}) => zeitenStatusKarte( { projekt, feature, angebot: s.angebot, kundenansicht: ctx.kundenansicht, danke, modell: s.modell, ...zusatz }, { behalten: () => zeigeBehalten(s), weiter: (fall) => zeichneProjekt(s, { bestellformular: fall?.bestellen === true, meldung: fall?.meldung || '' }), }) if (zustand === 'off' && s.modell === 'abo') { renderZeitenEinschalten(kopf, { ctx, projekt }) return } if (zustand === 'off' || (zustand === 'ordered' && bestellformular)) { renderZeitenBestellen(kopf, { ctx, angebot: s.angebot, projekt }) return } kopf.replaceChildren(statusKarte()) if (weg === 'behalten' && behaltenMoeglich(projekt, s.angebot, s.modell)) zeigeBehalten(s) } /** Behalten-Seite (30 €) statt der Statuskarte; der Editor darunter bleibt, nur versteckt, damit nichts verloren geht. „Zurück“ zeigt wieder die Karte. */ function zeigeBehalten(s) { const kopf = s.buehne.querySelector('.zt-kopf') if (!kopf) return const editor = s.buehne.querySelector('.zt-editor') const ctx = baueCtx(s, s.projekt) ctx.zurueck = () => { if (editor) editor.hidden = false kopf.replaceChildren(zeitenStatusKarte( { projekt: s.projekt, feature: s.projekt.features?.zeiten, angebot: s.angebot, kundenansicht: ctx.kundenansicht }, { behalten: () => zeigeBehalten(s) })) } if (editor) editor.hidden = true renderZeitenBestellen(kopf, { ctx, angebot: s.angebot, projekt: s.projekt, modus: 'behalten' }) }