Files
ClaudeandClaude Opus 5.5 b3e96d4a56 Öffnungszeiten: Editor für Hinweis- und Kontakt-Leiste, Google und Vorschau
Hinweis-Leiste (Liste, „Hinweis-Leiste anlegen“, Art, Knopf, Zeitraum, Preis-Hinweis),
Kontakt-Leiste (Telefon, Adresse, WhatsApp mit Datenschutz-Haken, R12 in der Kundenansicht),
Google (Für Google kopieren, Profil-Link), Live-Vorschau mit Regler „Uhrzeit testen“
und UI-Prüfung test/ui/zeiten-portal.ui.mjs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 06:43:22 +00:00

254 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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/<projectId> 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/<projectId>/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()
}