/* Oeffnungszeiten live im Kundenbereich: der Editor (ES-Modul, zeiten.js haengt ihn in .zt-editor ein; Spec 3.4). Server: server/routes/zeiten.js. renderEditor(root, ctx) laedt GET /api/zeiten/ und zeichnet die Abschnitte; die Eingaben des Kunden bleiben im Editor, solange der Platz (root) steht – zeiten.js baut ihn bei einem neuen Stand der Funktion nicht neu auf. root.dataset.state = Zustand der Funktion (setup, active, disabled): disabled heisst nur lesen (alles gesperrt, PUT antwortet mit 403). ctx von zeiten.js: { projectId, kundenansicht, neuZeichnen() } – neuZeichnen holt die Liste der Funktionen neu und zeichnet den Kopf der Ansicht (Statuskarte); der Editor ruft es einmal nach dem ersten Speichern (zeitenLeer wechselt), nie sonst. Der Editor-ctx t.ctx der Abschnitte: { projectId, kundenansicht, nurLesen, stand, entwurfsplan, offen, hinweisFrage, aktuell(), neuLaden(), meldung(text, art), uebernimm(antwort, { erstes }), beiStand(fn), leereDaten(), leer() } Die sechs Abschnitte (ABSCHNITTE): Zeitplaene, Feiertage, Ausnahmen (zeiten-plaene.js, zeiten-ausnahmen.js), Hinweis-Leiste, Kontakt-Leiste (zeiten-leisten.js) und Google (zeiten-google.js). Oben im Kopfbereich steht die Live-Vorschau „So steht es gerade auf deiner Seite: …“ (Status aus GET /api/zeiten bzw. der Antwort des letzten Speicherns) mit dem Regler „Uhrzeit testen“ (Wochentag und Uhrzeit, GET /api/zeiten//vorschau?zeit=JJJJ-MM-TTTHH:MM, dieselbe Serverlogik; vor dem ersten Speichern antwortet der Server mit 409 keine_zeiten: das ist ein Hinweis, kein Fehler). Abgeschaltet (nur lesen) gibt es keine Vorschau: die Website zeigt dann nichts mehr. ctx.beiStand(fn): fn() laeuft nach jedem Speichern, wenn ctx.stand neu ist (Vorschau, Google-Text); gibt fn false zurueck, wird sie nicht mehr gerufen (Abschnitt weg). stand = Antwort des Servers { state, daten (null vor dem ersten Speichern), version, kundenansicht, vorschau, feiertage, googleText }; sie wird bei jedem Speichern und bei „Neu laden“ ersetzt. Ein Abschnitt ist eine Funktion (t) => Element mit t = { ctx, sp, zeichne, wurzel }. t.zeichne() zeichnet alles neu (nur nach „Neu laden“, wenn die Daten vom Server neu sind); t.zeichne(['ausnahmen', …]) zeichnet nur die genannten Abschnitte neu und laesst alle anderen Formulare mit ihren ungespeicherten Eingaben stehen – das ist der Weg nach einem Speichern. Vor dem ersten Speichern (stand.daten null) gibt es nur den leeren Hauptplan; die anderen Abschnitte (mitDaten) erscheinen danach, denn der Server verlangt mindestens einen Plan. Die Kontaktdaten (daten.kontakt) gehen unveraendert zurueck: der Editor setzt nie ein neues kontakt.whatsapp.bestaetigtAt (R12, das tut Aufgabe 30 mit dem Haken des Kunden). */ import { fehlerKarte, h, request } from './termine-dom.js' import { WOCHENTAGE_PLAN, datumLang, heuteBerlin, istTag } from './termine-view.js' import { leererPlan, naechstesVorkommen, wochentagVon } from './zeiten-view.js' import { neuerZeitenSpeicher } from './zeiten-speichern.js' import { plaene } from './zeiten-plaene.js' import { ausnahmen, feiertage, feiertageUebernehmen } from './zeiten-ausnahmen.js' import { hinweise, kontakt } from './zeiten-leisten.js' import { google } from './zeiten-google.js' /** * Abschnitte in der Reihenfolge der Seite (Spec 3.4). mitDaten: erst nach dem ersten Speichern. uebernehmen(alt, neu): wird gerufen, wenn der Abschnitt allein * neu gezeichnet wird, und nimmt ungespeicherte Eingaben aus dem alten Abschnitt in den neuen mit (optional). */ const ABSCHNITTE = [ { key: 'plaene', bauen: plaene, mitDaten: false }, { key: 'feiertage', bauen: feiertage, mitDaten: true, uebernehmen: feiertageUebernehmen }, { key: 'ausnahmen', bauen: ausnahmen, mitDaten: true }, { key: 'hinweise', bauen: hinweise, mitDaten: true }, { key: 'kontakt', bauen: kontakt, mitDaten: true }, { key: 'google', bauen: google, mitDaten: true }, ] const NICHT_GELADEN = 'Deine Öffnungszeiten konnten gerade nicht geladen werden. Bitte versuch es noch einmal.' const NUR_LESEN = 'Diese Funktion ist abgeschaltet. Du kannst deine Eingaben nur noch ansehen.' const KUNDENANSICHT = 'Kundenansicht: Was du hier speicherst, ändert die Öffnungszeiten des Kunden. Jedes Speichern steht im Protokoll.' const LEER = 'Trag deine Öffnungszeiten ein. Sobald du speicherst, kennt deine Website sie.' const SPAETER = 'Feiertage, Ausnahmen und alles Weitere kannst du eintragen, sobald deine Öffnungszeiten gespeichert sind.' const VORSCHAU_LEER = 'Noch keine Öffnungszeiten gespeichert.' const VORSCHAU_ERST_SPEICHERN = 'Speichere zuerst deine Öffnungszeiten, dann kannst du Uhrzeiten testen.' const VORSCHAU_KEINE_ANZEIGE = 'Dazu gibt es gerade keine Anzeige.' // Welcher Aufruf von renderEditor gehoert zu einem Platz? Ein neuer Aufruf macht den alten ungueltig (seine Antworten fassen nichts mehr an). const LAEUFE = new WeakMap() /** Antwort des Servers pruefen und auf die Felder reduzieren, die der Editor braucht; wirft bei einer unbrauchbaren Antwort. */ function standAus(antwort) { if (antwort === null || typeof antwort !== 'object' || !Number.isInteger(antwort.version)) throw new Error(NICHT_GELADEN) const daten = antwort.daten !== null && typeof antwort.daten === 'object' && !Array.isArray(antwort.daten) ? antwort.daten : null if (daten !== null && !Array.isArray(daten.plaene)) throw new Error(NICHT_GELADEN) return { state: typeof antwort.state === 'string' ? antwort.state : '', // ohne Plaene ist nichts gespeichert (wie vor dem ersten Speichern) daten: daten !== null && daten.plaene.length > 0 ? daten : null, version: antwort.version, kundenansicht: antwort.kundenansicht === true, vorschau: antwort.vorschau ?? null, feiertage: (Array.isArray(antwort.feiertage) ? antwort.feiertage : []).filter((f) => f && istTag(f.tag) && typeof f.name === 'string'), googleText: typeof antwort.googleText === 'string' ? antwort.googleText : '', } } /** Berliner Uhrzeit 'HH:MM' (Vorgabe des Reglers). */ const berlinUhrzeit = (jetzt = new Date()) => jetzt.toLocaleTimeString('de-DE', { timeZone: 'Europe/Berlin', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }) /** * Live-Vorschau oben im Editor: der Status, wie ihn die Website jetzt zeigt (ec.stand.vorschau = { offen, text, kurz }), und der Regler „Uhrzeit testen“. Der * Regler fragt den Server mit dem naechsten Vorkommen des gewaehlten Wochentags (ab heute, Berliner Kalendertag) und der Uhrzeit (Berliner Wanduhr); die Logik ist * die der Website. Nur die letzte Anfrage zaehlt. Nach jedem Speichern (ec.beiStand) zeigt die Vorschau den neuen Status, und eine schon gestellte Frage wird mit * den neuen Daten wiederholt. */ function vorschauKarte(ec) { const jetzt = h('strong', { class: 'zt-vorschau-text', 'data-vorschau': 'jetzt' }) const zeigeJetzt = () => { const v = ec.stand.vorschau const gueltig = v !== null && typeof v === 'object' && typeof v.text === 'string' && v.text !== '' jetzt.textContent = gueltig ? v.text : VORSCHAU_LEER jetzt.setAttribute('data-offen', gueltig ? String(v.offen === true || v.offen === false ? v.offen : null) : 'leer') } zeigeJetzt() const heute = heuteBerlin() const tag = h('select', { id: 'zt-regler-tag', 'data-regler': 'tag' }, WOCHENTAGE_PLAN.map(([nr, name]) => h('option', { value: nr, selected: Number(nr) === wochentagVon(heute) }, name))) const zeit = h('input', { id: 'zt-regler-zeit', type: 'time', 'data-regler': 'zeit', value: berlinUhrzeit() }) const ergebnis = h('p', { class: 'tm-hinweis zt-regler-ergebnis', 'data-vorschau': 'test', 'aria-live': 'polite' }) const karte = h('section', { class: 'kb-panel zt-vorschau', 'aria-label': 'Vorschau deiner Öffnungszeiten' }, h('p', { class: 'zt-vorschau-zeile' }, 'So steht es gerade auf deiner Seite: ', jetzt), h('fieldset', { class: 'zt-regler' }, h('legend', null, 'Uhrzeit testen'), h('div', { class: 'zt-regler-felder' }, h('div', { class: 'tm-feld' }, h('label', { for: 'zt-regler-tag' }, 'Wochentag'), tag), h('div', { class: 'tm-feld' }, h('label', { for: 'zt-regler-zeit' }, 'Uhrzeit'), zeit)), ergebnis)) let lauf = 0 let benutzt = false async function frage() { const wann = zeit.value const nr = ++lauf if (!/^\d{2}:\d{2}$/.test(wann)) { ergebnis.textContent = '' ergebnis.removeAttribute('role') return } benutzt = true const datum = naechstesVorkommen(heuteBerlin(), Number(tag.value)) const ort = () => nr === lauf && karte.isConnected && ec.aktuell() // eine veraltete Antwort oder ein anderer Editor zaehlt nicht try { const antwort = await request(`/api/zeiten/${encodeURIComponent(ec.projectId)}/vorschau?zeit=${encodeURIComponent(`${datum}T${wann}`)}`) if (!ort()) return ergebnis.removeAttribute('role') ergebnis.textContent = typeof antwort?.text === 'string' && antwort.text !== '' ? `${datumLang(datum)}, ${wann} Uhr: ${antwort.text}` : VORSCHAU_KEINE_ANZEIGE } catch (err) { if (!ort()) return if (err.code === 'keine_zeiten') { ergebnis.removeAttribute('role') // vor dem ersten Speichern gibt es nichts zu testen: ein Hinweis, kein Fehler ergebnis.textContent = VORSCHAU_ERST_SPEICHERN return } ergebnis.setAttribute('role', 'alert') ergebnis.textContent = err.message } } tag.addEventListener('change', frage) zeit.addEventListener('change', frage) ec.beiStand(() => { if (!karte.isConnected) return false zeigeJetzt() if (benutzt) frage() return true }) return karte } export async function renderEditor(root, ctx = {}) { const token = {} LAEUFE.set(root, token) const aktuell = () => root.isConnected && LAEUFE.get(root) === token const url = `/api/zeiten/${encodeURIComponent(ctx.projectId)}` const ladeStand = async () => standAus(await request(url)) root.replaceChildren(h('p', { class: 'kb-empty' }, 'Lade…')) let stand try { stand = await ladeStand() } catch (err) { if (aktuell()) root.replaceChildren(fehlerKarte(err.message, () => renderEditor(root, ctx))) return } if (!aktuell()) return const nurLesen = ctx.nurLesen === true || root.dataset.state === 'disabled' const meldungEl = h('div', { class: 'tm-meldung', 'data-meldung': 'editor', role: 'status', 'aria-live': 'polite', tabindex: '-1' }) const entwurfsplan = leererPlan({ name: 'Öffnungszeiten', kurz: 'oeffnungszeiten', haupt: true }) const beobachter = new Set() // Funktionen, die nach jedem Speichern laufen (beiStand) const ec = { projectId: ctx.projectId, kundenansicht: ctx.kundenansicht === true, nurLesen, stand, entwurfsplan, // der leere Hauptplan vor dem ersten Speichern offen: new Set([entwurfsplan.id, ...(stand.daten ? stand.daten.plaene.filter((p) => p?.haupt === true).map((p) => p.id) : [])]), // aufgeklappte Plaene hinweisFrage: null, // { vorschlag } nach einem Urlaub ab 2 Tagen aktuell, beiStand: (fn) => { beobachter.add(fn) }, leer: () => ec.stand.daten === null, /** Daten fuer das erste Speichern: der leere Hauptplan; die Kontaktdaten setzt der Server auf seine Vorgabe. */ leereDaten: () => ({ land: 'RP', plaene: [ec.entwurfsplan], feiertage: {}, ausnahmen: [], hinweise: [] }), /** Eine Meldung, die erst nach dem Wechsel der Website eintrifft, gehoert nicht mehr auf diese Seite. */ meldung(text, art = 'ok') { if (!aktuell()) return meldungEl.textContent = text || '' meldungEl.className = `tm-meldung tm-meldung-${art}` meldungEl.setAttribute('role', art === 'fehler' ? 'alert' : 'status') meldungEl.setAttribute('aria-live', art === 'fehler' ? 'assertive' : 'polite') if (!text) return if (art === 'fehler') meldungEl.focus({ preventScroll: true }) meldungEl.scrollIntoView({ block: 'nearest' }) }, async neuLaden() { const neu = await ladeStand() if (aktuell()) ec.stand = neu return neu }, /** Antwort auf ein erfolgreiches Speichern. Das erste Speichern aendert, was die Huelle zeigt (zeitenLeer): sie zeichnet ihren Kopf neu. */ uebernimm(antwort, { erstes = false } = {}) { ec.stand = standAus(antwort) for (const fn of [...beobachter]) { if (fn() === false) beobachter.delete(fn) } if (erstes && typeof ctx.neuZeichnen === 'function') { try { Promise.resolve(ctx.neuZeichnen()).catch(() => {}) // scheitert das, zeigt der Kopf den alten Stand bis zum naechsten Besuch } catch { /* wie oben */ } } }, } const sp = neuerZeitenSpeicher(ec, root, zeichne) const t = { ctx: ec, sp, zeichne, wurzel: root } let leerGezeichnet = null const abschnittEl = new Map() // key => das gezeichnete Element des Abschnitts function baueAbschnitt(abschnitt) { const el = abschnitt.bauen(t) el.dataset.abschnitt = abschnitt.key abschnittEl.set(abschnitt.key, el) return el } /** Alles neu (welche fehlt) oder nur die Abschnitte in welche (Liste von Schluesseln). Aenderte sich leer/nicht leer, muss alles neu. */ function zeichne(welche) { const leer = ec.leer() if (Array.isArray(welche) && welche.length && leer === leerGezeichnet) { for (const key of welche) { const abschnitt = ABSCHNITTE.find((a) => a.key === key) const alt = abschnittEl.get(key) if (!abschnitt || !alt?.parentNode) continue const neu = baueAbschnitt(abschnitt) abschnitt.uebernehmen?.(alt, neu) alt.parentNode.insertBefore(neu, alt) alt.remove() } return } leerGezeichnet = leer abschnittEl.clear() const teile = [meldungEl, sp.konflikt] if (ec.kundenansicht || ec.stand.kundenansicht) teile.push(h('div', { class: 'tm-box tm-box-info', role: 'note', 'data-hinweis': 'kundenansicht' }, h('span', null, KUNDENANSICHT))) if (nurLesen) teile.push(h('div', { class: 'tm-box tm-box-warn', role: 'note', 'data-hinweis': 'nurlesen' }, h('span', null, NUR_LESEN))) else if (leer) teile.push(h('div', { class: 'tm-box tm-box-info', role: 'note', 'data-hinweis': 'leer' }, h('span', null, LEER))) teile.push(h('div', { class: 'zt-kopfbereich' }, nurLesen ? null : vorschauKarte(ec))) // Live-Vorschau; abgeschaltet zeigt die Website nichts mehr for (const abschnitt of ABSCHNITTE) { if (abschnitt.mitDaten && leer) continue teile.push(baueAbschnitt(abschnitt)) } if (leer && !nurLesen) teile.push(h('p', { class: 'tm-hinweis zt-spaeter' }, SPAETER)) root.replaceChildren(...teile) } zeichne() }