Ö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>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 06:43:22 +00:00
1 parent 242fbb01e5
commit b3e96d4a56
8 files changed
+1684 -13

No files matched your search

+2 -2
View File
@@ -9,7 +9,7 @@
„geschlossen“, ein „bitte prüfen“-Tag (Heiligabend, Silvester, nur ortsuebliche Feiertage) folgt dem Wochenplan.
Ausnahmen: Betriebsurlaub (von–bis, Text) und Sondertag (Datum, geschlossen oder Zeiten), Geltung „alle Pläne“ oder ein Plan. Nach dem Speichern
eines Urlaubs ab 2 Tagen fragt der Editor „Hinweis-Leiste dazu anlegen?“ (ctx.hinweisFrage); „Ja“ legt den Vorschlag (hinweisVorschlag) in
daten.hinweise an und speichert. */
daten.hinweise an und speichert (danach werden Ausnahmen und die Hinweis-Liste neu gezeichnet). */
import { BUNDESLAENDER, datumKurz, datumLang, heuteBerlin, istTag } from './termine-view.js'
import { abschnitt, allgemeinerFehler, editorZeile, feld, frage, gesperrt, h, neuerEintrag, zeigeFehler } from './termine-dom.js'
@@ -196,7 +196,7 @@ function hinweisFrage(t) {
}, { container: form })
if (!ok || !sp.nochHier()) return
ctx.hinweisFrage = null
nachSpeichern(t, `Die Hinweis-Leiste „${v.text}“ ist angelegt.`, '[data-werkzeug="urlaub-neu"]', () => t.zeichne(['ausnahmen']))
nachSpeichern(t, `Die Hinweis-Leiste „${v.text}“ ist angelegt.`, '[data-werkzeug="urlaub-neu"]', () => t.zeichne(['ausnahmen', 'hinweise']))
})
return form
}
+96 -5
View File
@@ -4,9 +4,14 @@
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 (aus Aufgabe 29; Aufgabe 30 haengt ihre Abschnitte an ABSCHNITTE und die Live-Vorschau in den Kopf):
Der Editor-ctx t.ctx der Abschnitte:
{ projectId, kundenansicht, nurLesen, stand, entwurfsplan, offen, hinweisFrage, aktuell(), neuLaden(), meldung(text, art), uebernimm(antwort, { erstes }),
leereDaten(), leer() }
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
@@ -16,11 +21,13 @@
kontakt.whatsapp.bestaetigtAt (R12, das tut Aufgabe 30 mit dem Haken des Kunden). */
import { fehlerKarte, h, request } from './termine-dom.js'
import { istTag } from './termine-view.js'
import { leererPlan } from './zeiten-view.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
@@ -30,6 +37,9 @@ 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.'
@@ -38,6 +48,10 @@ const KUNDENANSICHT = 'Kundenansicht: Was du hier speicherst, ändert die Öffnu
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()
@@ -58,6 +72,78 @@ function standAus(antwort) {
}
}
/** 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)
@@ -78,6 +164,7 @@ export async function renderEditor(root, ctx = {}) {
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,
@@ -87,6 +174,7 @@ export async function renderEditor(root, ctx = {}) {
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: [] }),
@@ -109,6 +197,9 @@ export async function renderEditor(root, ctx = {}) {
/** 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
@@ -150,7 +241,7 @@ export async function renderEditor(root, ctx = {}) {
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' })) // Platz der Live-Vorschau (Aufgabe 30)
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))
+66
View File
@@ -0,0 +1,66 @@
/* Oeffnungszeiten live im Kundenbereich: Editor, Bereich 6 „Google“ (Spec 3.4). google(t) ist ein Abschnitt des Editors (t = { ctx, sp, zeichne, wurzel }
aus zeiten-editor.js). Der Text fuer das Google-Profil kommt vom Server (ctx.stand.googleText aus GET/PUT /api/zeiten: Montag bis Sonntag, danach die
Sonderzeiten der naechsten 60 Tage) und wird hier nie selbst gebaut. „Für Google kopieren“ legt ihn in die Zwischenablage; fehlt die Schnittstelle oder
lehnt der Browser ab, bleibt der Text im (schreibgeschuetzten) Textfeld markiert und eine Meldung sagt, wie der Kunde ihn selbst kopiert. Nach jedem Speichern
folgt die Anzeige dem neuen Text (ctx.beiStand). Das Google-Profil selbst oeffnet der Link in einem neuen Fenster (noopener); wir schreiben dort nichts. */
import { abschnitt, h } from './termine-dom.js'
const GOOGLE_PROFIL = 'https://business.google.com/'
const KOPIERT = 'Kopiert. Füge den Text jetzt in dein Google-Profil ein.'
const KEIN_TEXT = 'Es gibt noch keinen Text zum Kopieren. Speichere zuerst deine Öffnungszeiten.'
const NICHT_KOPIERT = 'Das Kopieren hat nicht geklappt. Der Text ist unten markiert: Kopiere ihn selbst (Strg+C, am Handy: lange drücken und „Kopieren“ wählen).'
export function google(t) {
const { ctx } = t
const text = () => (typeof ctx.stand.googleText === 'string' ? ctx.stand.googleText : '')
const feld = h('textarea', { class: 'zt-google-text', readonly: true, 'aria-label': 'Öffnungszeiten für dein Google-Profil', 'data-google': 'text' })
const meldung = h('p', { class: 'tm-hinweis zt-google-meldung', role: 'status', 'aria-live': 'polite', 'data-meldung': 'google' })
const knopf = h('button', { type: 'button', class: 'kb-btn-primary', 'data-aktion': 'google-kopieren' }, 'Für Google kopieren')
const zeige = () => {
feld.value = text()
feld.setAttribute('rows', String(Math.min(Math.max(text().split('\n').length + 1, 4), 16)))
}
zeige()
knopf.addEventListener('click', async () => {
const inhalt = text() // der Stand zum Zeitpunkt des Klicks, nicht der beim Zeichnen
if (inhalt === '') {
meldung.textContent = KEIN_TEXT
return
}
let kopiert = false
try {
const zwischenablage = typeof navigator !== 'undefined' ? navigator.clipboard : undefined
if (zwischenablage && typeof zwischenablage.writeText === 'function') {
await zwischenablage.writeText(inhalt)
kopiert = true
}
} catch {
kopiert = false // nicht erlaubt: weiter mit dem markierten Text
}
if (kopiert) {
meldung.textContent = KOPIERT
return
}
feld.focus()
feld.select()
if (typeof feld.setSelectionRange === 'function') feld.setSelectionRange(0, feld.value.length) // iOS markiert sonst nichts
meldung.textContent = NICHT_KOPIERT
})
const el = abschnitt('Google',
'Die Google-Daten im Seitencode laufen automatisch – dafür musst du nichts tun. Dein Google-Unternehmensprofil pflegst du selbst: Kopiere hier den Text mit deinen Öffnungszeiten (auch die Sonderzeiten der nächsten 60 Tage) und füge ihn dort ein.',
feld,
h('div', { class: 'zt-google-aktionen' },
knopf,
h('a', { class: 'kb-btn-outline', href: GOOGLE_PROFIL, target: '_blank', rel: 'noopener noreferrer', 'data-google': 'profil' }, 'Google-Profil öffnen')),
meldung)
ctx.beiStand(() => {
if (!el.isConnected) return false // der Abschnitt wurde neu gezeichnet: diese Anzeige gibt es nicht mehr
zeige()
return true
})
return el
}
+312
View File
@@ -0,0 +1,312 @@
/* Oeffnungszeiten live im Kundenbereich: Editor, Bereiche 4 „Hinweis-Leiste“ und 5 „Kontakt-Leiste (Handy)“ (Spec 3.4). Jeder Bereich ist eine Funktion
(t) => Element; t = { ctx, sp, zeichne, wurzel } kommt aus zeiten-editor.js (sp = Speicher des Editors, zeichne([…]) zeichnet einzelne Abschnitte neu).
Jede Aenderung speichert die ganzen Daten (zeiten-speichern.js); nach einem Speichern wird nur der eigene Abschnitt neu gezeichnet, ungespeicherte Eingaben
in anderen Formularen bleiben stehen. Abgeschaltete Funktion (ctx.nurLesen): dieselben Felder gesperrt, ohne Knoepfe zum Aendern. Stil: zeiten-ausnahmen.js.
Hinweis-Leiste: Liste der laufenden und geplanten Hinweise (Vergangenes eingeklappt), „Hinweis-Leiste anlegen“ (dieser Text ist fest: die Relaunch-Station
tippt den Knopf an) und je Hinweis Text (<= 140), Art (Info, Urlaub, Aktion, Wichtig mit festen Farben wie auf der Website), optionaler Knopf (Link
https oder Anrufen) und Zeitraum von–bis (Kalendertage, beide optional; „Aktion“ verlangt ein Enddatum). Der Browser prueft frueh (Aktion ohne Ende, Ende vor
Beginn, leerer Text) und zeigt bei Link und Rufnummer nur einen Hinweis (linkOk): entschieden wird auf dem Server, sein Fehler steht am Feld.
Kontakt-Leiste: Ein/Aus, Telefon, Adresse, WhatsApp (Nummer, Starttext, Haken „Datenschutzerklaerung“). Der Server speichert Rufnummern als Ziffern mit
Laendervorwahl; der Editor zeigt die gespeicherte Nummer aus der Antwort (+49 …) im Feld und in der Meldung, damit der Kunde sieht, was gilt.
R12: den Zeitpunkt der WhatsApp-Bestaetigung (kontakt.whatsapp.bestaetigtAt) setzt nur der Kunde selbst. In der Kundenansicht ist der Haken gesperrt und der
gespeicherte Wert geht unveraendert zurueck (ohne gespeicherten kein Speichern mit WhatsApp); beim Kunden geht ein frischer Zeitpunkt nur hinaus, wenn der Haken
neu gesetzt wurde (der Server ersetzt ihn durch seine Zeit), sonst der gespeicherte Wert. */
import { datumKurz, heuteBerlin, istTag } from './termine-view.js'
import { abschnitt, allgemeinerFehler, editorZeile, feld, gesperrt, h, hakenFeld, neuerEintrag, zeigeFehler } from './termine-dom.js'
import { HINWEIS_ARTEN, ZEITEN_LIMITS, ZEITEN_MAX, linkOk, neueZeitenId, preisHinweisNoetig } from './zeiten-view.js'
import { entfernenKnopf, nachSpeichern, stelleVon } from './zeiten-speichern.js'
const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert)
/* ----------------------------------------------------------- Hinweis-Leiste --- */
const ART_NAME = Object.freeze({ info: 'Info', urlaub: 'Urlaub', aktion: 'Aktion', wichtig: 'Wichtig' })
const KNOPF_WAHL = Object.freeze([['', 'Kein Knopf'], ['link', 'Link'], ['anruf', 'Anrufen']])
const PREIS_HINWEIS = 'Bei Preisangaben gelten Endpreise. Bei Waren nennst du dazu den niedrigsten Preis der letzten 30 Tage.'
const LINK_HINWEIS = 'Bitte gib eine vollständige Adresse mit https:// an.'
const ANRUF_HINWEIS = 'Bitte nur Ziffern, Leerzeichen, + / und - (höchstens 30 Zeichen).'
const KNOPF_ARTEN = Object.freeze(['link', 'anruf'])
/** Rufnummer eines Anruf-Knopfs wie auf dem Server: nur Ziffern, Leerzeichen, / und -, ein + nur vorn, mindestens eine Ziffer, hoechstens 30 Zeichen. */
const anrufOk = (ziel) => {
const s = typeof ziel === 'string' ? ziel.trim() : ''
return s !== '' && [...s].length <= ZEITEN_MAX.telefon && /^\+?[0-9 /-]+$/.test(s) && /\d/.test(s)
}
/** 'JJJJ-MM-TT' oder '' (fehlt, null oder kein Kalendertag). */
const tagOder = (wert) => (typeof wert === 'string' && istTag(wert) ? wert : '')
/** „12.10.2026 – 26.10.2026“, „ab …“, „bis …“ oder „immer sichtbar“. */
function zeitraumText(x) {
const von = tagOder(x.von)
const bis = tagOder(x.bis)
if (von && bis) return von === bis ? datumKurz(von) : `${datumKurz(von)} – ${datumKurz(bis)}`
if (von) return `ab ${datumKurz(von)}`
if (bis) return `bis ${datumKurz(bis)}`
return 'immer sichtbar'
}
const nachBeginn = (a, b) => (tagOder(a.von) < tagOder(b.von) ? -1 : tagOder(a.von) > tagOder(b.von) ? 1 : 0)
export function hinweise(t) {
const { ctx } = t
const alle = (Array.isArray(ctx.stand.daten.hinweise) ? ctx.stand.daten.hinweise : []).filter((x) => istObjekt(x) && typeof x.id === 'string')
const heute = heuteBerlin()
const vorbei = (x) => tagOder(x.bis) !== '' && x.bis < heute
const kommend = alle.filter((x) => !vorbei(x)).sort(nachBeginn)
const alt = alle.filter(vorbei).sort(nachBeginn).reverse()
const voll = alle.length >= ZEITEN_LIMITS.hinweise
const neu = neuerEintrag({
nurLesen: ctx.nurLesen, text: 'Hinweis-Leiste anlegen', werkzeug: 'hinweis-neu', primaer: true, voll, grenzText: `Mehr als ${ZEITEN_LIMITS.hinweise} Hinweise sind nicht möglich.`,
baue: (schliessen) => formular(t, { a: null, schliessen }),
})
const reihe = (x) => {
const art = HINWEIS_ARTEN.includes(x.art) ? x.art : 'info'
const knopf = istObjekt(x.knopf) && KNOPF_ARTEN.includes(x.knopf.art) ? x.knopf : null
const von = tagOder(x.von)
const zustand = vorbei(x) ? ['vorbei', 'off'] : von && von > heute ? ['geplant', 'wait'] : ['läuft', 'ok']
const text = typeof x.text === 'string' ? x.text : ''
return editorZeile({
attrs: { 'data-id': x.id },
nurLesen: ctx.nurLesen,
label: `Hinweis ${text}`,
haupt: [
h('strong', { class: 'tm-name' }, text),
h('span', { class: 'tm-meta' },
h('span', { class: `zt-art zt-art-${art}` }, ART_NAME[art]),
` · ${zeitraumText(x)}${knopf ? ` · Knopf „${typeof knopf.text === 'string' ? knopf.text : ''}“ (${knopf.art === 'anruf' ? 'Anrufen' : 'Link'})` : ''}`),
],
pille: h('span', { class: `tm-pille tm-pille-${zustand[1]}` }, zustand[0]),
baue: (schliessen) => formular(t, { a: x, schliessen }),
})
}
return abschnitt('Hinweis-Leiste',
'Eine Leiste ganz oben auf deiner Website – zum Beispiel für Betriebsurlaub, eine Aktion oder etwas Wichtiges. Die Leiste erscheint nur in dem Zeitraum, den du einstellst. Gelten mehrere gleichzeitig, zeigt die Website nur einen: zuerst „Wichtig“, dann „Urlaub“, „Aktion“ und „Info“.',
h('div', { class: 'zt-werkzeuge' }, neu.leiste),
neu.box,
kommend.length
? h('ul', { class: 'tm-eintraege', 'aria-label': 'Laufende und geplante Hinweise' }, kommend.map(reihe))
: h('p', { class: 'tm-leer' }, 'Keine Hinweise eingetragen.'),
alt.length
? h('details', { class: 'tm-details' }, h('summary', null, `Vergangene Hinweise (${alt.length})`), h('ul', { class: 'tm-eintraege' }, alt.map(reihe)))
: null)
}
/**
* Formular eines neuen (a null) oder bestehenden Hinweises. Die Felder heissen nach dem Pfad der Fehler des Servers (hinweise.<ID>.text, .art, .knopf.art,
* .knopf.text, .knopf.ziel, .von, .bis); Absenden sucht den Hinweis per ID.
*/
function formular(t, { a, schliessen }) {
const { ctx, sp } = t
const neu = a === null
const alt = a || { id: neueZeitenId('h'), art: 'info', text: '', knopf: null, von: null, bis: null }
const p = `hinweise.${alt.id}`
const knopfAlt = istObjekt(alt.knopf) && KNOPF_ARTEN.includes(alt.knopf.art) ? alt.knopf : null
const text = h('input', { type: 'text', maxlength: ZEITEN_MAX.hinweisText, autocomplete: 'off', placeholder: 'z. B. Heute geschlossen – morgen wieder für dich da', value: typeof alt.text === 'string' ? alt.text : '' })
const art = h('select', null, HINWEIS_ARTEN.map((k) => h('option', { value: k, selected: k === (HINWEIS_ARTEN.includes(alt.art) ? alt.art : 'info') }, ART_NAME[k])))
const knopfArt = h('select', null, KNOPF_WAHL.map(([wert, name]) => h('option', { value: wert, selected: wert === (knopfAlt?.art ?? '') }, name)))
const knopfText = h('input', { type: 'text', maxlength: ZEITEN_MAX.knopfText, autocomplete: 'off', placeholder: 'z. B. Mehr erfahren', value: typeof knopfAlt?.text === 'string' ? knopfAlt.text : '' })
const knopfZiel = h('input', { type: 'url', maxlength: ZEITEN_MAX.link, autocomplete: 'off', value: typeof knopfAlt?.ziel === 'string' ? knopfAlt.ziel : '' })
const von = h('input', { type: 'date', value: tagOder(alt.von) })
const bis = h('input', { type: 'date', value: tagOder(alt.bis) })
const preisHinweis = h('p', { class: 'tm-hinweis', 'data-hinweis': 'preis', hidden: true }, PREIS_HINWEIS)
const zielHinweis = h('p', { class: 'tm-hinweis', 'data-hinweis': 'knopf-ziel', hidden: true }, LINK_HINWEIS)
const zielFeld = feld(`${p}.knopf.ziel`, 'Link', knopfZiel)
zielFeld.insertBefore(zielHinweis, zielFeld.querySelector('[data-fehler]'))
const textFeld = feld(`${p}.text`, 'Text', text, `Höchstens ${ZEITEN_MAX.hinweisText} Zeichen.`)
textFeld.insertBefore(preisHinweis, textFeld.querySelector('[data-fehler]'))
const knopfFelder = h('div', { class: 'tm-raster zt-knopf-felder', 'data-knopf-felder': '' },
feld(`${p}.knopf.text`, 'Beschriftung des Knopfs', knopfText, `Höchstens ${ZEITEN_MAX.knopfText} Zeichen.`),
zielFeld)
// Muster: so sieht die Leiste in der Farbe der Art aus (feste Farben wie auf der Website)
const musterText = h('span', { class: 'zt-muster-text' })
const musterKnopf = h('span', { class: 'zt-muster-knopf' })
const muster = h('div', { class: 'zt-muster', 'aria-hidden': 'true', 'data-muster': '' }, musterText, musterKnopf)
const aktualisiere = () => {
preisHinweis.hidden = !preisHinweisNoetig(text.value)
const wahl = knopfArt.value
knopfFelder.hidden = wahl === ''
knopfZiel.type = wahl === 'anruf' ? 'tel' : 'url'
knopfZiel.placeholder = wahl === 'anruf' ? 'z. B. 06301 123456' : 'https://'
zielFeld.querySelector('label').textContent = wahl === 'anruf' ? 'Telefonnummer' : 'Link'
const ziel = knopfZiel.value.trim()
zielHinweis.textContent = wahl === 'anruf' ? ANRUF_HINWEIS : LINK_HINWEIS
zielHinweis.hidden = wahl === '' || ziel === '' || (wahl === 'anruf' ? anrufOk(ziel) : linkOk(ziel))
muster.className = `zt-muster zt-art-${art.value}`
musterText.textContent = text.value.trim() || 'Dein Hinweistext'
musterKnopf.textContent = knopfText.value.trim()
musterKnopf.hidden = wahl === '' || knopfText.value.trim() === ''
}
for (const steuer of [text, art, knopfArt, knopfText, knopfZiel]) {
steuer.addEventListener('input', aktualisiere)
steuer.addEventListener('change', aktualisiere)
}
aktualisiere()
const entfernen = neu || ctx.nurLesen ? null : entfernenKnopf(sp, {
titel: 'Hinweis entfernen?',
text: 'Die Leiste verschwindet von deiner Website.',
aendere: (entwurf) => { entwurf.hinweise = (Array.isArray(entwurf.hinweise) ? entwurf.hinweise : []).filter((x) => x?.id !== alt.id) },
nachher: () => nachSpeichern(t, 'Hinweis entfernt.', '[data-werkzeug="hinweis-neu"]', () => t.zeichne(['hinweise'])),
})
const speichern = h('button', { type: 'submit', class: 'kb-btn-primary', 'data-aktion': 'hinweis-speichern' }, neu ? 'Hinweis speichern' : 'Speichern')
const form = h('form', { class: 'kb-panel tm-formular zt-hinweis-form', novalidate: true, 'aria-label': neu ? 'Neuer Hinweis' : 'Hinweis bearbeiten' },
h('h3', null, neu ? 'Neuer Hinweis' : 'Hinweis'),
gesperrt(ctx.nurLesen, [
textFeld,
feld(`${p}.art`, 'Art', art, 'Jede Art hat ihre feste Farbe.'),
muster,
feld(`${p}.knopf.art`, 'Knopf (optional)', knopfArt, 'Ein Knopf in der Leiste: ein Link auf eine Seite oder ein Anruf.'),
knopfFelder,
h('div', { class: 'tm-raster' },
feld(`${p}.von`, 'Sichtbar ab (optional)', von),
feld(`${p}.bis`, 'Sichtbar bis (bei einer Aktion nötig)', bis)),
h('p', { class: 'tm-hinweis' }, 'Ohne Datum gilt der Hinweis immer. Bei einer Aktion brauchst du ein Enddatum.'),
]),
allgemeinerFehler(),
h('div', { class: 'tm-frage-knoepfe' },
ctx.nurLesen ? null : speichern,
h('button', { type: 'button', class: 'kb-btn-outline', onclick: schliessen }, ctx.nurLesen ? 'Schließen' : 'Abbrechen'),
entfernen?.knopf))
form.addEventListener('submit', async (ev) => {
ev.preventDefault()
const ok = await sp.speichere((entwurf) => {
const fehler = {}
const vonWert = von.value.trim()
const bisWert = bis.value.trim()
const hinweisText = text.value.trim()
if (hinweisText === '') fehler[`${p}.text`] = 'Bitte gib einen Text ein.'
if (vonWert !== '' && !istTag(vonWert)) fehler[`${p}.von`] = 'Bitte gib ein gültiges Datum an.'
if (bisWert !== '' && !istTag(bisWert)) fehler[`${p}.bis`] = 'Bitte gib ein gültiges Datum an.'
if (!fehler[`${p}.von`] && !fehler[`${p}.bis`]) {
if (vonWert !== '' && bisWert !== '' && bisWert < vonWert) fehler[`${p}.bis`] = 'Das Ende darf nicht vor dem Beginn liegen.'
else if (art.value === 'aktion' && bisWert === '') fehler[`${p}.bis`] = 'Eine Aktion braucht ein Enddatum.'
}
if (Object.keys(fehler).length) {
zeigeFehler(form, { fehler })
return false
}
// ein Knopf ohne Beschriftung und Ziel ist kein Knopf (wie auf dem Server)
const ziel = knopfZiel.value.trim()
const beschriftung = knopfText.value.trim()
const knopf = knopfArt.value !== '' && (ziel !== '' || beschriftung !== '') ? { art: knopfArt.value, ziel, text: beschriftung } : null
const eintrag = { id: alt.id, art: art.value, text: hinweisText, knopf, von: vonWert || null, bis: bisWert || null }
if (neu) {
entwurf.hinweise = [...(Array.isArray(entwurf.hinweise) ? entwurf.hinweise : []), eintrag]
return true
}
const stelle = stelleVon(entwurf.hinweise, alt.id, form, 'Dieser Hinweis')
if (stelle < 0) return false
entwurf.hinweise[stelle] = eintrag
return true
}, { container: form })
if (!ok || !sp.nochHier()) return
nachSpeichern(t, 'Hinweis gespeichert.', neu ? '[data-werkzeug="hinweis-neu"]' : `li[data-id="${CSS.escape(alt.id)}"] [data-aktion="bearbeiten"]`, () => t.zeichne(['hinweise']))
})
return h('div', { class: 'tm-editor' }, form, entfernen?.box)
}
/* ------------------------------------------------------------ Kontakt-Leiste --- */
const WHATSAPP_HAKEN = 'Ich habe den WhatsApp-Hinweis in meine Datenschutzerklärung aufgenommen'
const HAKEN_FEHLT = 'Bitte bestätige, dass du den WhatsApp-Hinweis in deine Datenschutzerklärung aufgenommen hast.'
const KUNDE_BESTAETIGT = 'Der Kunde muss den WhatsApp-Hinweis selbst bestätigen – in der Kundenansicht geht das nicht.'
/** Gespeicherte Rufnummer (Ziffern mit Laendervorwahl, z. B. 491711234567) als „+49 1711234567“; alles andere bleibt, wie es gespeichert ist. */
export function nummerAnzeige(wert) {
const s = typeof wert === 'string' ? wert.trim() : ''
if (!/^\d{6,15}$/.test(s)) return s
return s.startsWith('49') ? `+49 ${s.slice(2)}` : `+${s}`
}
export function kontakt(t) {
const { ctx, sp } = t
const nurLesen = ctx.nurLesen
const kundenansicht = ctx.kundenansicht === true || ctx.stand.kundenansicht === true
const k = istObjekt(ctx.stand.daten.kontakt) ? ctx.stand.daten.kontakt : {}
const wa = istObjekt(k.whatsapp) ? k.whatsapp : null
const gespeichertAt = typeof wa?.bestaetigtAt === 'string' ? wa.bestaetigtAt : ''
const telefonAlt = typeof k.telefon === 'string' ? k.telefon : ''
const nummerAlt = typeof wa?.nummer === 'string' ? wa.nummer : ''
const telefonAnzeige = nummerAnzeige(telefonAlt)
const nummerAnz = nummerAnzeige(nummerAlt)
const an = h('input', { type: 'checkbox', checked: k.an === true })
const telefon = h('input', { type: 'tel', maxlength: ZEITEN_MAX.telefon, autocomplete: 'off', placeholder: 'z. B. 06301 123456', value: telefonAnzeige })
const adresse = h('input', { type: 'text', maxlength: ZEITEN_MAX.adresse, autocomplete: 'off', placeholder: 'z. B. Hauptstraße 1, 67722 Winnweiler', value: typeof k.adresse === 'string' ? k.adresse : '' })
const waAn = h('input', { type: 'checkbox', checked: wa !== null })
const nummer = h('input', { type: 'tel', maxlength: ZEITEN_MAX.telefon, autocomplete: 'off', placeholder: 'z. B. 0171 1234567', value: nummerAnz })
const starttext = h('textarea', { rows: 3, maxlength: ZEITEN_MAX.starttext, placeholder: 'z. B. Hallo, ich habe eine Frage.' })
starttext.value = typeof wa?.starttext === 'string' ? wa.starttext : ''
const bestaetigt = h('input', { type: 'checkbox', checked: gespeichertAt !== '', disabled: kundenansicht })
const waBlock = h('div', { class: 'zt-whatsapp', 'data-whatsapp': '', hidden: wa === null },
feld('kontakt.whatsapp.nummer', 'WhatsApp-Nummer', nummer, 'Mit Vorwahl. Wir speichern sie mit Ländervorwahl, zum Beispiel +49 …'),
feld('kontakt.whatsapp.starttext', 'Vorgeschriebener Text (optional)', starttext, `Dieser Text steht schon im Nachrichtenfeld, wenn jemand auf den WhatsApp-Knopf tippt. Höchstens ${ZEITEN_MAX.starttext} Zeichen.`),
hakenFeld('kontakt.whatsapp.bestaetigtAt', bestaetigt, WHATSAPP_HAKEN, 'Beim Tippen öffnet sich WhatsApp (ein Dienst von Meta). Das gehört in die Datenschutzerklärung deiner Website – frag uns im Chat, wenn du den passenden Absatz brauchst.'),
kundenansicht ? h('p', { class: 'tm-hinweis', 'data-hinweis': 'whatsapp-kundenansicht' }, 'Kundenansicht: Den Haken zur Datenschutzerklärung kann nur der Kunde selbst setzen.') : null)
waAn.addEventListener('change', () => { waBlock.hidden = !waAn.checked })
const speichern = h('button', { type: 'submit', class: 'kb-btn-primary', 'data-aktion': 'kontakt-speichern' }, 'Kontakt-Leiste speichern')
const form = h('form', { novalidate: true, 'aria-label': 'Kontakt-Leiste' },
gesperrt(nurLesen, [
hakenFeld('kontakt.an', an, 'Kontakt-Leiste auf dem Handy anzeigen', 'Eine Leiste unten auf deiner Website, nur auf dem Handy: Anrufen, Route und WhatsApp – je nachdem, was du einträgst.'),
feld('kontakt.telefon', 'Telefon', telefon, 'Für „Anrufen“. Wir speichern die Nummer mit Ländervorwahl (+49 für Deutschland).'),
feld('kontakt.adresse', 'Adresse für „Route“', adresse, `Höchstens ${ZEITEN_MAX.adresse} Zeichen.`),
hakenFeld('kontakt.whatsapp', waAn, 'WhatsApp-Knopf anbieten'),
waBlock,
]),
allgemeinerFehler(),
nurLesen ? null : h('div', { class: 'tm-frage-knoepfe' }, speichern))
form.addEventListener('submit', async (ev) => {
ev.preventDefault()
const ok = await sp.speichere((entwurf) => {
const fehler = {}
let whatsapp = null
if (waAn.checked) {
const nummerWert = nummer.value === nummerAnz ? nummerAlt : nummer.value.trim()
if (nummerWert === '') fehler['kontakt.whatsapp.nummer'] = 'Bitte gib die Nummer an.'
let bestaetigtAt
if (kundenansicht) {
// R12: im Namen des Kunden gibt es nie einen neuen Zeitpunkt; ein gespeicherter geht unveraendert zurueck
if (gespeichertAt !== '') bestaetigtAt = gespeichertAt
else fehler['kontakt.whatsapp.bestaetigtAt'] = KUNDE_BESTAETIGT
} else if (bestaetigt.checked) {
bestaetigtAt = gespeichertAt !== '' ? gespeichertAt : new Date().toISOString() // der Server ersetzt einen neuen Zeitpunkt durch seine Zeit
} else {
fehler['kontakt.whatsapp.bestaetigtAt'] = HAKEN_FEHLT
}
whatsapp = { nummer: nummerWert, starttext: starttext.value.trim(), bestaetigtAt }
}
if (Object.keys(fehler).length) {
zeigeFehler(form, { fehler })
return false
}
entwurf.kontakt = {
...(istObjekt(entwurf.kontakt) ? entwurf.kontakt : {}),
an: an.checked,
telefon: telefon.value === telefonAnzeige ? telefonAlt : telefon.value.trim(),
adresse: adresse.value.trim(),
whatsapp,
}
return true
}, { container: form })
if (!ok || !sp.nochHier()) return
// was der Server gespeichert hat, steht danach im Feld (nur dieser Abschnitt wird neu gezeichnet) und in der Meldung
const neu = istObjekt(ctx.stand.daten.kontakt) ? ctx.stand.daten.kontakt : {}
const nummern = [neu.telefon ? `Telefon ${nummerAnzeige(neu.telefon)}` : '', neu.whatsapp?.nummer ? `WhatsApp ${nummerAnzeige(neu.whatsapp.nummer)}` : ''].filter(Boolean)
nachSpeichern(t, `Kontakt-Leiste gespeichert.${nummern.length ? ` Gespeichert als ${nummern.join(', ')}.` : ''}`, 'form[aria-label="Kontakt-Leiste"] button[type="submit"]', () => t.zeichne(['kontakt']))
})
return abschnitt('Kontakt-Leiste (Handy)',
'Auf dem Handy erscheint unten auf deiner Website eine Leiste mit Knöpfen zum Anrufen, für die Route und für WhatsApp. Auf größeren Bildschirmen erscheint sie nicht.',
form)
}
+12
View File
@@ -209,6 +209,18 @@ export function planLoeschenGrund(daten, planId) {
return null
}
/* ---------------------------------------------------------- Wochentage --- */
/** Wochentag eines Kalendertags 'JJJJ-MM-TT': 1 = Montag … 7 = Sonntag. */
export function wochentagVon(tag) {
const nr = new Date(`${tag}T00:00:00Z`).getUTCDay() // 0 = Sonntag
return nr === 0 ? 7 : nr
}
/** Der erste Tag ab heute (heute eingeschlossen), der auf den Wochentag (1 = Montag … 7 = Sonntag) fällt: Tag für den Regler „Uhrzeit testen“. */
export function naechstesVorkommen(heute, wochentag) {
return tagPlus(heute, (wochentag - wochentagVon(heute) + 7) % 7)
}
/* ------------------------------------------------------ Hinweis-Leiste --- */
const VORLAUF_TAGE = 7
const tageZwischen = (von, bis) => Math.round((Date.parse(`${bis}T00:00:00Z`) - Date.parse(`${von}T00:00:00Z`)) / 86400000)
+40 -2
View File
@@ -1,4 +1,5 @@
/* Öffnungszeiten live im Kundenbereich (zeiten.js, zeiten-bestellen.js und der Editor: zeiten-editor.js, zeiten-plaene.js, zeiten-ausnahmen.js).
/* Öffnungszeiten live im Kundenbereich (zeiten.js, zeiten-bestellen.js und der Editor: zeiten-editor.js, zeiten-plaene.js, zeiten-ausnahmen.js,
zeiten-leisten.js, zeiten-google.js).
Farben und Abstände aus den Tokens von kundenbereich.css (.kb-page). Die Bausteine der Bestell- und Statusseiten (tm-bestellen, tm-statuskarte,
tm-rechtstext, tm-haken, tm-pille, tm-box, tm-meldung, tm-info …) stehen in termine.css und gelten hier mit; neu sind nur Klassen mit zt-
und die Regeln für #view-zeiten. Handy: bis 880px wie die App-Ansicht, Tippflächen mindestens 44px. */
@@ -66,11 +67,48 @@
.zt-tag > .tm-fehler { margin-top: 8px; }
.zt-feiertag .zt-tag { margin-top: 10px; padding: 0; border: 0; background: transparent; }
/* Editor II: Live-Vorschau mit Regler „Uhrzeit testen“, Hinweis-Leiste, Kontakt-Leiste, Google (zeiten-editor.js, zeiten-leisten.js, zeiten-google.js) */
.zt-editor [hidden] { display: none; }
.zt-vorschau { padding: 16px 18px; }
.zt-vorschau-zeile { margin: 0; font-size: 15px; line-height: 1.5; overflow-wrap: anywhere; }
.zt-vorschau-text { font-size: 17px; }
.zt-vorschau-text::before { content: ''; display: inline-block; width: 10px; height: 10px; margin-right: 8px; border-radius: 50%; background: var(--kb-muted); }
.zt-vorschau-text[data-offen='true']::before { background: #4ade80; }
.zt-vorschau-text[data-offen='false']::before { background: #f87171; }
.zt-vorschau-text[data-offen='null']::before { background: #fbbf24; }
.zt-regler { min-width: 0; margin: 14px 0 0; padding: 0; border: 0; }
.zt-regler legend { padding: 0; margin-bottom: 2px; font-size: 13px; font-weight: 600; }
.zt-regler-felder { display: flex; flex-wrap: wrap; gap: 0 14px; }
.zt-regler-felder .tm-feld { margin-top: 8px; }
.zt-regler-ergebnis { margin-top: 10px; font-size: 14px; line-height: 1.5; color: inherit; overflow-wrap: anywhere; }
.zt-regler-ergebnis:empty { display: none; }
.zt-regler-ergebnis[role='alert'] { color: #fecaca; }
.zt-regler .tm-feld select, .zt-regler .tm-feld input { min-height: 40px; }
.zt-art { display: inline-block; padding: 1px 10px; border-radius: 999px; font-size: 12px; font-weight: 700; }
.zt-art-info { background: #e0f2fe; color: #0c4a6e; }
.zt-art-aktion { background: #dcfce7; color: #14532d; }
.zt-art-urlaub { background: #fef3c7; color: #78350f; }
.zt-art-wichtig { background: #b91c1c; color: #ffffff; }
.zt-muster { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 6px 12px; margin: 14px 0 0; padding: 10px 14px; border-radius: 10px; font-size: 14px; line-height: 1.4; text-align: center; }
.zt-muster-text { overflow-wrap: anywhere; }
.zt-muster-knopf { padding: 3px 12px; border-radius: 999px; border: 1.5px solid currentColor; font-size: 13px; font-weight: 700; }
.zt-knopf-felder { margin-top: 0; }
.zt-hinweis-form .zt-tag { margin-top: 0; }
.zt-whatsapp { margin-top: 10px; padding: 0 14px 10px; border-radius: 12px; border: 1px solid var(--kb-border); background: rgba(255, 255, 255, 0.02); }
.zt-google-text {
display: block; width: 100%; box-sizing: border-box; padding: 10px 12px; border-radius: 12px; font: inherit; font-size: 14px; line-height: 1.6; resize: vertical; white-space: pre; overflow: auto;
border: 1px solid var(--kb-border-strong); background: rgba(255, 255, 255, 0.05); color: inherit;
}
.zt-google-aktionen { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-top: 12px; }
.zt-google-aktionen a { display: inline-flex; align-items: center; text-decoration: none; }
.zt-google-meldung { margin-top: 10px; font-size: 13.5px; line-height: 1.5; color: inherit; }
.zt-google-meldung:empty { display: none; }
@media (max-width: 880px), (pointer: coarse) {
#view-zeiten button, #view-zeiten .kb-btn-outline, #view-zeiten .kb-btn-primary { min-height: 44px; }
#view-zeiten .tm-haken { align-items: center; min-height: 44px; margin-top: 0; margin-bottom: 0; }
#view-zeiten .tm-haken input { margin-top: 0; }
#view-zeiten .zt-zeit input, #view-zeiten .zt-tag select, #view-zeiten .zt-feiertag select { min-height: 44px; }
#view-zeiten .zt-zeit input, #view-zeiten .zt-tag select, #view-zeiten .zt-feiertag select, #view-zeiten .zt-regler .tm-feld select, #view-zeiten .zt-regler .tm-feld input { min-height: 44px; }
}
@media (max-width: 600px) {
.zt-fenster-name { min-width: 100%; }
+502
View File
@@ -0,0 +1,502 @@
// Playwright-Checks der Portal-Ansicht Öffnungszeiten live (public/zeiten*.js, Kachel in funktionen.js, zeiten.css) – Aufgabe 30:
// Kachel, Einschalten, leerer Editor, Speichern mit Konflikt, Hinweis-Vorschlag, Handy (390 px) und Tastatur. Nicht Teil von npm test.
// Aufruf: free -m; nice node test/ui/zeiten-portal.ui.mjs (es läuft nur ein Browser)
// Screenshots: /root/reports/zeiten-portal-ui/portal-*.png (bei Fehlern fehler-portal-<Prüfung>.png). Exit-Code 1, wenn eine Prüfung scheitert.
//
// Alle Prüfungen sind "fx_*": Fixture. Alle /api/-Anfragen beantwortet page.route mit festen Daten (wie test/ui/termine-portal.ui.mjs und
// funktionen.ui.mjs); der Server liefert nur dashboard.html und die Dateien aus public/. Nichts verlässt den Rechner.
// NUR=<Teil des Namens> führt nur passende Prüfungen aus (zum Entwickeln); STACK=1 zeigt bei Fehlern den Aufrufweg.
// Uhr: Browser steht auf Montag 2026-10-05 10:00 Berliner Zeit (context.clock.setFixedTime).
import http from 'node:http'
import { mkdir, readdir, readFile, rm } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const PW = process.env.PLAYWRIGHT_CORE || '/root/.npm/_npx/db89d7302a373f10/node_modules/playwright-core/index.mjs'
const { chromium } = await import(PW)
const PUBLIC = path.join(path.dirname(fileURLToPath(import.meta.url)), '../../public')
const SHOTS = '/root/reports/zeiten-portal-ui'
await mkdir(SHOTS, { recursive: true })
for (const datei of await readdir(SHOTS)) if (/^(portal|fehler-portal)-.*\.png$/.test(datei)) await rm(path.join(SHOTS, datei)) // alte Läufe
const TYPES = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon', '.webmanifest': 'application/json' }
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, 'http://x')
try {
const datei = path.join(PUBLIC, url.pathname === '/' ? 'dashboard.html' : url.pathname)
if (!datei.startsWith(PUBLIC)) throw new Error('außerhalb')
const daten = await readFile(datei)
res.writeHead(200, { 'content-type': TYPES[path.extname(datei)] || 'application/octet-stream' })
res.end(daten)
} catch {
res.writeHead(404)
res.end('nicht gefunden')
}
})
await new Promise((fertig) => { server.listen(0, '127.0.0.1', fertig) })
const BASE = `http://127.0.0.1:${server.address().port}`
const JETZT = new Date('2026-10-05T08:00:00.000Z') // Montag, 10:00 Berlin (MESZ)
const HEUTE = '2026-10-05'
// ---------------------------------------------------------------- Helfer
function assert(cond, msg) { if (!cond) throw new Error(msg) }
function eq(ist, soll, msg) {
const a = JSON.stringify(ist)
const b = JSON.stringify(soll)
if (a !== b) throw new Error(`${msg}: ${a} statt ${b}`)
}
const tagPlus = (tag, n) => new Date(Date.parse(`${tag}T00:00:00Z`) + n * 86400000).toISOString().slice(0, 10)
/** Screenshot der ganzen Seite: das Portal scrollt im Dokument, fullPage zeigt dort nur die Kopfzeile; darum Fenster auf Seitenhöhe (höchstens 3600 px). */
async function shot(page, name) {
await page.evaluate(() => Promise.all(document.getAnimations().filter((a) => a.effect.getComputedTiming().iterations !== Infinity).map((a) => a.finished.catch(() => {}))))
const fenster = page.viewportSize()
const hoehe = await page.evaluate(() => Math.min(Math.max(document.documentElement.scrollHeight, window.innerHeight), 3600))
await page.setViewportSize({ width: fenster.width, height: hoehe })
await page.screenshot({ path: `${SHOTS}/portal-${name}.png` })
await page.setViewportSize(fenster)
}
/** Kein Streifen rechts: die Seite passt in die Breite. */
const passtInBreite = (page) => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
// ---------------------------------------------------------------- Fixture
const WOCHENTAGE = ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag']
const woche = (zeiten = {}) => Object.fromEntries([1, 2, 3, 4, 5, 6, 7].map((n) => [n, zeiten[n] || { modus: 'zu', fenster: [] }]))
const werktage = () => woche({
1: { modus: 'zeiten', fenster: [['09:00', '12:30'], ['13:30', '18:00']] },
2: { modus: 'zeiten', fenster: [['09:00', '18:00']] },
3: { modus: 'zeiten', fenster: [['09:00', '18:00']] },
4: { modus: 'zeiten', fenster: [['09:00', '18:00']] },
5: { modus: 'zeiten', fenster: [['09:00', '16:00']] },
6: { modus: 'zeiten', fenster: [['09:00', '12:00']] },
})
const PLAN = 'p_haupt001'
const KONTAKT = { an: true, telefon: '496301123456', adresse: 'Hauptstraße 1, 67722 Winnweiler', whatsapp: { nummer: '4917112345678', starttext: 'Hallo, ich habe eine Frage zu euren Öffnungszeiten.', bestaetigtAt: '2026-10-01T08:00:00.000Z' } }
const FEIERTAGE = [
{ tag: '2026-11-01', name: 'Allerheiligen', art: 'feiertag' },
{ tag: '2026-12-24', name: 'Heiligabend', art: 'pruefen' },
{ tag: '2026-12-25', name: 'Erster Weihnachtstag', art: 'feiertag' },
{ tag: '2026-12-31', name: 'Silvester', art: 'pruefen' },
]
const gespeicherteDaten = (extra = {}) => ({
land: 'RP',
plaene: [{ id: PLAN, kurz: 'oeffnungszeiten', name: 'Öffnungszeiten', haupt: true, tage: werktage() }],
feiertage: {},
ausnahmen: [],
hinweise: [],
kontakt: KONTAKT,
...extra,
})
const googleTextAus = (daten) => {
const tage = daten.plaene[0].tage
const text = (t) => (t.modus === 'zeiten' ? t.fenster.map((f) => `${f[0]}–${f[1]}`).join(', ') : t.modus === 'rund' ? 'rund um die Uhr' : t.modus === 'vereinbarung' ? 'nach Vereinbarung' : 'geschlossen')
return WOCHENTAGE.map((name, i) => `${name}: ${text(tage[i + 1])}`).join('\n')
}
const FX_CONSENT = { version: 'fx-1', knopf: 'Einschalten', items: [{ id: 'leistung', text: 'Ich habe die Leistungsbeschreibung „Öffnungszeiten live“ gelesen.', haken: true }] }
/** Fixture: Daten, die page.route statt der Schnittstellen /api/funktionen und /api/zeiten liefert. */
function neueFixture(o = {}) {
const fx = {
state: 'active',
inklusive: true,
grund: null,
zeitenLeer: false,
daten: gespeicherteDaten(),
version: 3,
konflikt: false, // das nächste PUT antwortet 409 (jemand hat inzwischen gespeichert)
aufrufe: [],
...o,
}
fx.angebot = () => ({
verfuegbar: true,
inklusive: fx.inklusive,
grund: fx.grund,
preis: '30 €',
einschalten: { consent: FX_CONSENT, leistungsbeschreibung: { version: 'fx-1', url: '/vertragsunterlagen/zeiten-leistungsbeschreibung.html' } },
belehrung: 'Widerrufsbelehrung der Fixture.',
})
fx.feature = () => ({
state: fx.state, siteKey: 'abcdabcdabcdabcd', ticketWoid: fx.state === 'off' ? null : '1234', orderedAt: null, activatedAt: null, invoiceUrl: null,
disabledReason: null, disabledAt: null, loeschungAm: null, unlockedBy: fx.state === 'off' ? null : 'abo', aboEndeAt: null, einbau: fx.state === 'setup' ? 'angestossen' : null,
})
fx.vorschau = () => (fx.daten ? { offen: true, text: 'Jetzt geöffnet · schließt um 18 Uhr', kurz: 'Geöffnet' } : null)
fx.liste = () => ({
projects: [{
projectId: 'p1', projectName: 'Bäckerei Sonnenschein', liveUrl: 'https://baeckerei.example', features: { news: { state: 'off' }, zeiten: fx.feature() },
zeitenStatus: fx.state === 'active' && fx.daten ? fx.vorschau() : null, zeitenLeer: fx.zeitenLeer,
}],
features: { zeiten: { consent: FX_CONSENT, priceLabel: '30 €', setupEstimate: 'meist innerhalb von 1–2 Wochen', leistungsbeschreibung: { version: 'fx-1', url: '/vertragsunterlagen/zeiten-leistungsbeschreibung.html' } } },
ustHinweis: 'ohne Umsatzsteuer gemäß § 19 UStG',
zeiten: fx.angebot(),
})
fx.ansicht = () => ({
state: fx.state, daten: fx.daten, version: fx.version, kundenansicht: false, vorschau: fx.vorschau(), feiertage: FEIERTAGE, googleText: fx.daten ? googleTextAus(fx.daten) : '',
})
return fx
}
/** Die übrigen Portal-Schnittstellen, die dashboard.html beim Laden braucht (wie test/ui/funktionen.ui.mjs). */
function portalHilfe(p, json) {
if (p === '/api/auth/me') return json({ authenticated: true, role: 'customer', customer: { id: 'c1', code: '1001', name: 'Berta Berg', email: 'berta@baeckerei.example' } })
if (p === '/api/projects') return json({ projects: [{ id: 'p1', name: 'Bäckerei Sonnenschein', status: 'live' }] })
if (p === '/api/features') return json({ features: [] })
if (p === '/api/contact') return json({ name: 'WEBklar-Team', responseTime: 'Antwort meist < 1 Werktag' })
if (p === '/api/chat/unread-count') return json({ unread: 0 })
return json({})
}
/** Antworten der Fixture auf /api/funktionen und /api/zeiten (Teil von page.route). */
function fixtureAntwort(fx, req, url, json) {
const p = url.pathname
const m = req.method()
if (p === '/api/funktionen') return json(fx.liste())
if (p === '/api/funktionen/zeiten/einschalten' && m === 'POST') {
fx.aufrufe.push(['einschalten', req.postDataJSON()])
fx.state = 'setup'
fx.zeitenLeer = true
fx.daten = null
fx.version = 0
return json({ feature: { state: 'setup' }, einbau: 'angestossen' })
}
if (p === '/api/zeiten/p1' && m === 'GET') {
fx.aufrufe.push(['get'])
return json(fx.ansicht())
}
if (p === '/api/zeiten/p1' && m === 'PUT') {
const body = req.postDataJSON()
fx.aufrufe.push(['put', body])
if (fx.konflikt) {
// jemand anders hat inzwischen gespeichert: neuer Stand, neue Version
fx.konflikt = false
fx.version += 1
fx.daten = { ...fx.daten, plaene: [{ ...fx.daten.plaene[0], name: 'Öffnungszeiten (inzwischen geändert)' }] }
return json({ error: 'Inzwischen geändert – bitte neu laden.', code: 'ZEITEN_VERALTET' }, 409)
}
if (body.version !== fx.version) return json({ error: 'Inzwischen geändert – bitte neu laden.', code: 'ZEITEN_VERALTET' }, 409)
const erstes = fx.daten === null
fx.daten = { ...body.daten, kontakt: body.daten.kontakt || { an: false, telefon: '', adresse: '', whatsapp: null } }
fx.version += 1
if (erstes) fx.zeitenLeer = false
return json(fx.ansicht())
}
if (p === '/api/zeiten/p1/vorschau' && m === 'GET') {
const zeit = url.searchParams.get('zeit') || ''
fx.aufrufe.push(['vorschau', zeit])
if (!fx.daten) return json({ error: 'Bitte speichere zuerst deine Öffnungszeiten.', code: 'keine_zeiten' }, 409)
const wann = new Date(`${zeit}:00Z`)
const stunde = wann.getUTCHours()
const sonntag = wann.getUTCDay() === 0
if (sonntag) return json({ offen: false, text: 'Geschlossen · öffnet morgen um 9 Uhr', kurz: 'Geschlossen' })
return json(stunde >= 9 && stunde < 18 ? { offen: true, text: 'Jetzt geöffnet · schließt um 18 Uhr', kurz: 'Geöffnet' } : { offen: false, text: 'Geschlossen · öffnet morgen um 9 Uhr', kurz: 'Geschlossen' })
}
return json({})
}
// ---------------------------------------------------------------- Ablauf der Prüfungen
const results = []
let browser = null
const hosts = new Set()
/** Eine Prüfung in eigenem Browser-Kontext. optionen: viewport/context (Playwright), fixture (Objekt aus neueFixture()). */
async function check(name, fn, { context = {}, fixture = neueFixture() } = {}) {
if (process.env.NUR && !name.includes(process.env.NUR)) return
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 }, locale: 'de-DE', timezoneId: 'Europe/Berlin', ...context })
await ctx.clock.setFixedTime(JETZT)
ctx.setDefaultTimeout(8000)
const fehler = []
ctx.on('page', (seite) => {
seite.on('pageerror', (e) => fehler.push(`JavaScript-Fehler: ${e.message}`))
seite.on('dialog', (d) => { fehler.push(`Browser-Dialog ${d.type()}: ${d.message()}`); d.dismiss().catch(() => {}) })
seite.on('request', (r) => { try { const u = new URL(r.url()); if (u.protocol.startsWith('http')) hosts.add(u.host) } catch { /* data: u. a. */ } })
})
await ctx.route('**/api/**', async (route) => {
const req = route.request()
const url = new URL(req.url())
const json = (body, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) })
if (url.pathname.startsWith('/api/funktionen') || url.pathname.startsWith('/api/zeiten')) return fixtureAntwort(fixture, req, url, json)
return portalHilfe(url.pathname, json)
})
const page = await ctx.newPage()
page.fx = fixture
try {
await fn(page, ctx, fixture)
assert(fehler.length === 0, fehler.join(' | '))
results.push([name, true])
console.log(`ok ${name}`)
} catch (e) {
results.push([name, false])
console.log(`FAIL ${name}: ${e.message.split('\n')[0]}`)
if (process.env.STACK) console.log(e.stack)
await page.screenshot({ path: `${SHOTS}/fehler-portal-${name}.png` }).catch(() => {})
} finally {
await ctx.close()
}
}
const oeffne = async (page, hash) => {
await page.goto(`${BASE}/dashboard.html${hash}`)
await page.waitForFunction(() => document.getElementById('customer-name')?.textContent)
}
/** Ansicht #zeiten öffnen und warten, bis der Editor (mit Abschnitten) da ist. */
const oeffneEditor = async (page) => {
await oeffne(page, '#zeiten')
await page.locator('#view-zeiten.active').waitFor()
await page.locator('#zeiten-body .zt-editor [data-abschnitt="plaene"]').waitFor()
}
const editor = (page) => page.locator('#zeiten-body .zt-editor')
const abschnitt = (page, key) => page.locator(`#zeiten-body [data-abschnitt="${key}"]`)
const knopf = (loc, name) => loc.getByRole('button', { name }) // Teiltreffer: wie die Relaunch-Station („Hinweis-Leiste anlegen“)
const meldung = (page) => page.locator('#zeiten-body [data-meldung="editor"]')
async function pruefungen() {
// ---------------------------------------------------------------- Kachel in Funktionen
await check('fx_kachel_zustaende', async (page, _ctx, fx) => {
const faelle = [
['off, im Abo enthalten', { state: 'off', inklusive: true, zeitenLeer: true, daten: null }, 'Aus', /Im Wartungs-Abo enthalten\./, 'Einschalten'],
['off, 30 €', { state: 'off', inklusive: false, grund: 'kein_abo', zeitenLeer: true, daten: null }, 'Aus', /30 € einmalig/, 'Öffnungszeiten einrichten · 30 €'],
['off, Abo noch nicht aktiv', { state: 'off', inklusive: false, grund: 'nicht_aktiv', zeitenLeer: true, daten: null }, 'Aus', /einschaltbar, sobald dein Abo aktiv ist/, null],
['off, nicht prüfbar', { state: 'off', inklusive: null, grund: null, zeitenLeer: true, daten: null }, 'Aus', /Gerade nicht prüfbar – bitte später erneut\./, null],
['Zahlung offen', { state: 'ordered', inklusive: false, grund: 'kein_abo', zeitenLeer: true, daten: null }, 'Zahlung offen', /Zahlung wird bestätigt/, 'Status ansehen'],
['In Einrichtung, leer', { state: 'setup', zeitenLeer: true, daten: null }, 'In Einrichtung', /Trag jetzt deine Zeiten ein/, 'Zeiten bearbeiten'],
['Aktiv mit Status', { state: 'active' }, 'Aktiv', /Jetzt geöffnet · schließt um 18 Uhr/, 'Zeiten bearbeiten'],
['Abgeschaltet', { state: 'disabled', zeitenLeer: true, daten: null }, 'Abgeschaltet', /abgeschaltet/, 'Chat öffnen'],
]
for (const [name, zustand, pille, text, cta] of faelle) {
Object.assign(fx, { state: 'active', inklusive: true, grund: null, zeitenLeer: false, daten: gespeicherteDaten() }, zustand)
await page.goto('about:blank')
await oeffne(page, '#funktionen')
const kachel = page.locator('[data-fn-tile="zeiten"]')
await kachel.waitFor()
eq((await kachel.locator('.fn-pill').innerText()).trim(), pille, `${name}: Pille`)
assert(text.test(await kachel.innerText()), `${name}: Text fehlt: ${text} in „${(await kachel.innerText()).replace(/\n/g, ' | ')}“`)
if (cta) eq((await kachel.locator('.fn-tile-cta').first().innerText()).trim(), cta, `${name}: Knopf`)
else eq(await kachel.locator('.fn-tile-cta').count(), 0, `${name}: kein Knopf`)
assert(await passtInBreite(page), `${name}: waagerechter Streifen`)
}
await shot(page, 'kachel-abgeschaltet')
})
// ---------------------------------------------------------------- Einschalten
await check('fx_einschalten', async (page, _ctx, fx) => {
await oeffne(page, '#funktionen')
await knopf(page.locator('[data-fn-tile="zeiten"]'), 'Einschalten').click()
const seite = page.locator('[data-variante="einschalten"]')
await seite.waitFor()
const einschalten = knopf(seite, 'Einschalten')
assert(await einschalten.isDisabled(), 'ohne Haken gesperrt')
await shot(page, 'einschalten')
await seite.locator('input[type="checkbox"]').check()
assert(await einschalten.isEnabled(), 'der Haken gibt den Knopf frei')
await einschalten.click()
await page.locator('.zt-status[data-state="setup"]').waitFor()
eq(fx.aufrufe.filter((a) => a[0] === 'einschalten'), [['einschalten', { projectId: 'p1', zustimmung: { version: 'fx-1', haken: ['leistung'] } }]], 'POST Einschalten')
assert((await page.locator('.zt-status [data-danke]').innerText()).includes('Öffnungszeiten live ist eingeschaltet.'), 'Danke-Satz')
await page.locator('#zeiten-body .zt-editor [data-hinweis="leer"]').waitFor()
assert((await page.locator('#zeiten-body .zt-editor [data-hinweis="leer"]').innerText()).includes('Trag deine Öffnungszeiten ein'), 'der Editor wartet auf die ersten Zeiten')
eq(await page.locator('#zeiten-body details[data-plan]').count(), 1, 'ein leerer Hauptplan')
assert(await passtInBreite(page), 'waagerechter Streifen')
await shot(page, 'einschalten-danach')
}, { fixture: neueFixture({ state: 'off', inklusive: true, zeitenLeer: true, daten: null, version: 0 }) })
// ---------------------------------------------------------------- Leerer Editor, erstes Speichern
await check('fx_editor_leer', async (page, _ctx, fx) => {
await oeffneEditor(page)
assert((await page.locator('.zt-status').innerText()).includes('Trag jetzt deine Zeiten ein'), 'Statuskarte fordert die Zeiten')
assert((await editor(page).innerText()).includes('Trag deine Öffnungszeiten ein'), 'Editor: Trag deine Öffnungszeiten ein')
eq(await page.locator('#zeiten-body details[data-plan]').count(), 1, 'ein leerer Hauptplan')
assert((await page.locator('#zeiten-body [data-kurz]').innerText()).trim() === 'oeffnungszeiten', 'Kurzname oeffnungszeiten')
for (const key of ['feiertage', 'ausnahmen', 'hinweise', 'kontakt', 'google']) eq(await abschnitt(page, key).count(), 0, `${key}: erst nach dem ersten Speichern`)
assert((await page.locator('[data-vorschau="jetzt"]').innerText()).toLowerCase().includes('noch keine öffnungszeiten gespeichert'), 'Vorschau: noch nichts gespeichert')
// Regler vor dem ersten Speichern: ein Hinweis, kein Fehler
await page.locator('[data-regler="zeit"]').fill('11:00')
await page.locator('[data-vorschau="test"]', { hasText: 'Speichere zuerst deine Öffnungszeiten' }).waitFor()
eq(await page.locator('[data-vorschau="test"]').getAttribute('role'), null, 'der Hinweis ist kein Alarm')
await shot(page, 'editor-leer')
// Montag eintragen und speichern
const montag = page.locator('details[data-plan] [data-tag="1"]')
await montag.locator('input[type="checkbox"]').check()
const zeiten = montag.locator('input[type="time"]')
await zeiten.nth(0).fill('09:00')
await zeiten.nth(1).fill('12:30')
await zeiten.nth(2).fill('13:30')
await zeiten.nth(3).fill('18:00')
await knopf(page.locator('details[data-plan]'), 'Speichern').click()
await abschnitt(page, 'feiertage').waitFor()
for (const key of ['ausnahmen', 'hinweise', 'kontakt', 'google']) await abschnitt(page, key).waitFor()
const put = fx.aufrufe.find((a) => a[0] === 'put')[1]
eq(put.version, 0, 'erstes PUT mit version 0')
eq(put.daten.plaene[0].tage['1'], { modus: 'zeiten', fenster: [['09:00', '12:30'], ['13:30', '18:00']] }, 'Montag gespeichert')
assert(!(await page.locator('.zt-status').innerText()).includes('Trag jetzt deine Zeiten ein'), 'der Kopf kennt die Zeiten jetzt')
assert((await page.locator('[data-vorschau="jetzt"]').innerText()).includes('Jetzt geöffnet'), 'Vorschau aus der Antwort')
await shot(page, 'editor-gespeichert')
}, { fixture: neueFixture({ state: 'active', zeitenLeer: true, daten: null, version: 0 }) })
// ---------------------------------------------------------------- Speichern mit Konflikt
await check('fx_speichern_konflikt', async (page, _ctx, fx) => {
await oeffneEditor(page)
fx.konflikt = true
const name = page.locator('details[data-plan] input[type="text"]').first()
await name.fill('Öffnungszeiten neu')
await knopf(page.locator('details[data-plan]'), 'Speichern').click()
const box = page.locator('#zeiten-body .zt-editor [role="alert"]', { hasText: 'Inzwischen geändert – bitte neu laden.' })
await box.waitFor()
assert(await knopf(box, 'Neu laden').isVisible(), 'Knopf „Neu laden“')
await shot(page, 'konflikt')
await knopf(box, 'Neu laden').click()
await page.waitForFunction(() => !document.querySelector('#zeiten-body .zt-editor [role="alert"]:not([hidden])') || [...document.querySelectorAll('#zeiten-body .zt-editor .tm-box[role="alert"]')].every((e) => e.hidden))
eq(await page.locator('details[data-plan] input[type="text"]').first().inputValue(), 'Öffnungszeiten (inzwischen geändert)', 'der Stand vom Server steht da')
// jetzt geht Speichern mit der neuen Version
await knopf(page.locator('details[data-plan]'), 'Speichern').click()
await meldung(page).filter({ hasText: 'gespeichert' }).waitFor()
const puts = fx.aufrufe.filter((a) => a[0] === 'put').map((a) => a[1].version)
eq(puts, [3, 4], 'zweites PUT mit der neuen Version')
})
// ---------------------------------------------------------------- Urlaub ⇒ Hinweis-Vorschlag, „Hinweis-Leiste anlegen“
await check('fx_hinweis_vorschlag', async (page, _ctx, fx) => {
await oeffneEditor(page)
const von = tagPlus(HEUTE, 20)
const bis = tagPlus(HEUTE, 25)
await abschnitt(page, 'ausnahmen').getByRole('button', { name: '+ Betriebsurlaub eintragen' }).click()
const form = page.getByRole('form', { name: 'Betriebsurlaub eintragen' })
await form.locator('input[type="date"]').nth(0).fill(von)
await form.locator('input[type="date"]').nth(1).fill(bis)
await form.locator('input[type="text"]').fill('Sommerpause')
await form.getByRole('button', { name: 'Betriebsurlaub eintragen' }).click()
const frage = page.getByRole('form', { name: 'Hinweis-Leiste dazu anlegen?' })
await frage.waitFor()
const kurz = (t) => `${t.slice(8)}.${t.slice(5, 7)}.`
assert((await frage.innerText()).includes(`Betriebsurlaub vom ${kurz(von)} bis ${kurz(bis)}`), 'Vorschlag mit Zeitraum')
await shot(page, 'hinweis-vorschlag')
await knopf(frage, 'Ja, anlegen').click()
const liste = abschnitt(page, 'hinweise')
await liste.locator('li', { hasText: `Betriebsurlaub vom ${kurz(von)} bis ${kurz(bis)}` }).waitFor()
const hinweise = fx.aufrufe.filter((a) => a[0] === 'put').at(-1)[1].daten.hinweise
eq(hinweise.map((h) => [h.art, h.knopf, h.von, h.bis]), [['urlaub', null, tagPlus(von, -7), bis]], 'Hinweis im zweiten PUT')
// „Hinweis-Leiste anlegen“: der Knopf, den die Relaunch-Station antippt
const anlegen = page.getByRole('button', { name: 'Hinweis-Leiste anlegen', exact: true })
assert(await anlegen.isVisible(), 'Knopf „Hinweis-Leiste anlegen“ ist sichtbar')
await anlegen.click()
const neu = page.getByRole('form', { name: 'Neuer Hinweis' })
await neu.waitFor()
await neu.locator('input[type="text"]').first().fill('Heute 20 % auf alle Torten')
await neu.locator('[data-hinweis="preis"]').waitFor({ state: 'visible' })
await shot(page, 'hinweis-formular')
await neu.locator('select').first().selectOption('aktion')
await knopf(neu, 'Hinweis speichern').click()
await neu.locator('[data-fehler$=".bis"]', { hasText: 'Eine Aktion braucht ein Enddatum.' }).waitFor()
await neu.locator('input[type="date"]').nth(1).fill(tagPlus(HEUTE, 3))
await knopf(neu, 'Hinweis speichern').click()
await liste.locator('li', { hasText: 'Heute 20 % auf alle Torten' }).waitFor()
eq(fx.aufrufe.filter((a) => a[0] === 'put').length, 3, 'drei Speichervorgänge')
})
// ---------------------------------------------------------------- Handy 390 px
await check('fx_handy_390', async (page, _ctx, fx) => {
fx.daten = gespeicherteDaten({
hinweise: [{ id: 'h_aktion001', art: 'aktion', text: '20 % auf alle Torten – nur diese Woche, solange der Vorrat reicht, bei Abholung im Laden', knopf: { art: 'link', ziel: 'https://baeckerei.example/torten/aktion/herbst', text: 'Mehr erfahren' }, von: HEUTE, bis: tagPlus(HEUTE, 4) }],
})
await oeffneEditor(page)
await page.locator('details[data-plan] summary').first().scrollIntoViewIfNeeded()
// alles aufklappen, was aufklappt: Hinweis-Formular, WhatsApp-Felder, Regler
await abschnitt(page, 'hinweise').locator('li[data-id="h_aktion001"] [data-aktion="bearbeiten"]').click()
await abschnitt(page, 'ausnahmen').getByRole('button', { name: '+ Betriebsurlaub eintragen' }).click()
await page.locator('[data-regler="zeit"]').fill('19:30')
await page.locator('[data-vorschau="test"]', { hasText: 'Geschlossen' }).waitFor()
assert(await passtInBreite(page), 'waagerechter Streifen')
// nichts im Editor ragt über den rechten Rand hinaus
const ueberstehend = await page.evaluate(() => {
const breite = window.innerWidth
return [...document.querySelectorAll('#zeiten-body *')].filter((el) => {
const r = el.getBoundingClientRect()
return r.width > 0 && r.height > 0 && (r.right > breite + 0.5 || r.left < -0.5)
}).slice(0, 5).map((el) => `${el.tagName.toLowerCase()}.${el.className}`)
})
eq(ueberstehend, [], 'nichts ragt über den Rand')
// Tippflächen mindestens 44 px (Knöpfe, Auswahllisten, Eingabefelder; Haken über ihre Beschriftungszeile)
const zuKlein = await page.evaluate(() => [...document.querySelectorAll('#zeiten-body button, #zeiten-body select, #zeiten-body input:not([type="checkbox"]), #zeiten-body summary, #zeiten-body a[href]')]
.filter((el) => el.getClientRects().length && !el.matches(':disabled'))
.map((el) => ({ el, h: el.getBoundingClientRect().height }))
.filter(({ h }) => h < 43.5)
.slice(0, 6)
.map(({ el, h }) => `${el.tagName.toLowerCase()}[${el.getAttribute('data-aktion') || el.getAttribute('data-feld') || el.className}] ${Math.round(h)}px`))
eq(zuKlein, [], 'Tippflächen ≥ 44 px')
await shot(page, 'handy-editor')
// Kontakt und Google unten
await abschnitt(page, 'kontakt').scrollIntoViewIfNeeded()
await shot(page, 'handy-kontakt')
assert(await passtInBreite(page), 'waagerechter Streifen (unten)')
}, { context: { viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true } })
// ---------------------------------------------------------------- Tastatur
await check('fx_tastatur', async (page, _ctx, fx) => {
fx.daten = gespeicherteDaten({
hinweise: [{ id: 'h_aktion001', art: 'aktion', text: '20 % auf alle Torten', knopf: { art: 'link', ziel: 'https://baeckerei.example/torten', text: 'Mehr erfahren' }, von: HEUTE, bis: tagPlus(HEUTE, 4) }],
})
await oeffneEditor(page)
await abschnitt(page, 'hinweise').getByRole('button', { name: 'Hinweis-Leiste anlegen' }).click()
await abschnitt(page, 'ausnahmen').getByRole('button', { name: '+ Sondertag eintragen' }).click()
await page.locator('details[data-plan] summary').first().scrollIntoViewIfNeeded()
// alles, was sichtbar und bedienbar ist, bekommt eine Nummer
const erwartet = await page.evaluate(() => {
const fokus = 'a[href], button, input:not([type="hidden"]), select, textarea, summary, [tabindex]:not([tabindex="-1"])'
const sichtbar = (el) => !el.matches(':disabled') && el.getClientRects().length > 0 && getComputedStyle(el).visibility !== 'hidden'
const liste = [...document.querySelectorAll(`#zeiten-body ${fokus.split(', ').join(', #zeiten-body ')}`)].filter(sichtbar)
liste.forEach((el, i) => el.setAttribute('data-tabidx', String(i)))
liste[0].focus()
return liste.map((el) => `${el.tagName.toLowerCase()}:${el.getAttribute('data-aktion') || el.getAttribute('data-feld') || el.getAttribute('aria-label') || el.id || el.className}`)
})
assert(erwartet.length > 40, `genug Bedienelemente (${erwartet.length})`)
// Zeit- und Datumsfelder haben in Chromium mehrere Tab-Stopps (Stunde, Minute, Tag …): bleibt der Fokus im selben Element, zählt es nicht als nächstes
const erreicht = new Set([0])
const ohneFokusring = []
let aktuell = 0
for (let versuch = 0; aktuell < erwartet.length - 1; versuch += 1) {
if (versuch > erwartet.length * 8) throw new Error(`Tab: nach ${versuch} Tastendrücken erst bei ${aktuell + 1} von ${erwartet.length}`)
await page.keyboard.press('Tab')
const stand = await page.evaluate(() => {
const el = document.activeElement
const stil = el ? getComputedStyle(el) : null
return el && el.hasAttribute('data-tabidx')
? { idx: Number(el.getAttribute('data-tabidx')), ring: stil.outlineStyle !== 'none' && parseFloat(stil.outlineWidth) > 0, schatten: stil.boxShadow !== 'none' }
: { idx: -1 }
})
if (stand.idx < 0) throw new Error(`Tab: Fokus außerhalb des Editors (erwartet: ${erwartet[aktuell + 1]})`)
if (stand.idx === aktuell) continue
if (stand.idx !== aktuell + 1) throw new Error(`Tab: Fokus auf ${erwartet[stand.idx]} statt ${erwartet[aktuell + 1]} (Reihenfolge oder übersprungen)`)
aktuell = stand.idx
erreicht.add(aktuell)
if (!stand.ring && !stand.schatten) ohneFokusring.push(erwartet[aktuell])
}
eq([...erreicht].length, erwartet.length, 'alle Felder per Tab erreichbar')
eq(ohneFokusring, [], 'Fokus überall sichtbar')
await shot(page, 'tastatur-ende')
})
}
// ---------------------------------------------------------------- Lauf
let abbruch = null
try {
browser = await chromium.launch()
await pruefungen()
const fremd = [...hosts].filter((h) => h !== new URL(BASE).host)
results.push(['nur_eigene_herkunft', fremd.length === 0])
console.log(fremd.length ? `FAIL nur_eigene_herkunft: Anfragen an fremde Hosts: ${fremd.join(', ')}` : 'ok nur_eigene_herkunft')
} catch (e) {
abbruch = e
console.log(`ABBRUCH: ${e.stack || e.message}`)
} finally {
if (browser) await browser.close().catch(() => {})
server.close()
}
const fehlgeschlagen = results.filter(([, ok]) => !ok)
console.log(`${results.length - fehlgeschlagen.length}/${results.length} Checks bestanden`)
process.exit(abbruch || fehlgeschlagen.length ? 1 : 0)
+654 -4
View File
@@ -13,8 +13,9 @@ import * as editor from '../public/zeiten-editor.js'
import * as speichern from '../public/zeiten-speichern.js'
// Editor der Öffnungszeiten live, Teil I (Task 29): Speichern, Zeitpläne, Feiertage, Ausnahmen (public/zeiten-editor.js, zeiten-speichern.js,
// zeiten-plaene.js, zeiten-ausnahmen.js; Spec 3.4 Bereiche 1 bis 3 und der Absatz „Speichern“). Fake-DOM und Fake-fetch aus
// test/support/fakeDom.js: jeder Aufruf steht in fx.fetchLog, die Antworten kommen der Reihe nach aus fx.antworten.
// zeiten-plaene.js, zeiten-ausnahmen.js; Spec 3.4 Bereiche 1 bis 3 und der Absatz „Speichern“).
// Teil II (Task 30): Hinweis-Leiste, Kontakt-Leiste, Google und Vorschau (zeiten-leisten.js, zeiten-google.js, zeiten-editor.js).
// Fake-DOM und Fake-fetch aus test/support/fakeDom.js: jeder Aufruf steht in fx.fetchLog, die Antworten kommen der Reihe nach aus fx.antworten.
const mitDom = (optionen, fn) => async () => {
const fx = installFakeDom(optionen)
@@ -1196,11 +1197,660 @@ test('Hülle: eine andere Website oder ein anderer Schreibstatus (abgeschaltet)
assert.deepEqual(gets(fx).filter((a) => a.url.startsWith('/api/zeiten/')).map((a) => a.url), ['/api/zeiten/p1', '/api/zeiten/p2'])
}))
// ---------------------------------------------------------------------------------------------------------------- Editor II (Task 30)
// Hinweis-Leiste, Kontakt-Leiste, Google und Vorschau (public/zeiten-leisten.js, zeiten-google.js, Vorschau oben in zeiten-editor.js; Spec 3.4 Bereiche 4 bis 6)
const ymdKurz = (tag) => `${tag.slice(8)}.${tag.slice(5, 7)}.${tag.slice(0, 4)}`
const wochentagNr = (tag) => ((new Date(`${tag}T00:00:00Z`).getUTCDay() + 6) % 7) + 1
const naechster = (nr) => { for (let i = 0; i < 7; i += 1) if (wochentagNr(tagPlus(HEUTE, i)) === nr) return tagPlus(HEUTE, i); throw new Error('unmöglich') }
const hinweisAktion = { id: 'h_aktion001', art: 'aktion', text: '20 % auf alle Torten', knopf: { art: 'link', ziel: 'https://beispiel.de/torten', text: 'Mehr erfahren' }, von: tagPlus(HEUTE, -1), bis: tagPlus(HEUTE, 10) }
const hinweisUrlaub = { id: 'h_urlaub001', art: 'urlaub', text: 'Betriebsurlaub im Sommer', knopf: null, von: tagPlus(HEUTE, 30), bis: tagPlus(HEUTE, 40) }
const hinweisAlt = { id: 'h_vorbei001', art: 'info', text: 'Alter Hinweis', knopf: null, von: tagPlus(HEUTE, -20), bis: tagPlus(HEUTE, -10) }
const hinweisFeld = (fx, form, name) => fx.finde(form, (el) => el.nodeType === 1 && new RegExp(`^hinweise\\.[^.]+\\.${name.replace('.', '\\.')}$`).test(el.getAttribute('data-feld') || ''))
const hinweisFehlerPfad = (fx, form, name) => fx.finde(form, (el) => el.nodeType === 1 && new RegExp(`^hinweise\\.[^.]+\\.${name.replace('.', '\\.')}$`).test(el.getAttribute('data-fehler') || ''))
const neuerHinweis = (fx, root) => fx.finde(root, 'form[aria-label="Neuer Hinweis"]')
const kontaktForm = (fx, root) => fx.finde(root, 'form[aria-label="Kontakt-Leiste"]')
const kfeld = (fx, root, name) => fx.finde(kontaktForm(fx, root), `[data-feld="kontakt.${name}"]`)
const sichtbar = (el) => el !== null && el.hidden !== true
/** Zwischenablage des Browsers für die Dauer von fn ersetzen (Node hat seit 21 ein eigenes navigator-Objekt: Eigenschaft sichern und danach zurückgeben). */
async function mitZwischenablage(clipboard, fn) {
const vorher = Object.getOwnPropertyDescriptor(globalThis, 'navigator')
Object.defineProperty(globalThis, 'navigator', { value: clipboard === undefined ? {} : { clipboard }, configurable: true, writable: true, enumerable: true })
try {
await fn()
} finally {
if (vorher) Object.defineProperty(globalThis, 'navigator', vorher)
else delete globalThis.navigator
}
}
test('Editor II: die Abschnitte Hinweis-Leiste, Kontakt-Leiste und Google stehen nach den Ausnahmen, erst nach dem ersten Speichern', mitDom({}, async (fx) => {
const { root } = await oeffne(fx)
assert.deepEqual(fx.alle(root, '[data-abschnitt]').map((el) => el.getAttribute('data-abschnitt')), ['plaene', 'feiertage', 'ausnahmen', 'hinweise', 'kontakt', 'google'])
const leer = await oeffne(fx, { start: stand(null) })
for (const key of ['hinweise', 'kontakt', 'google']) assert.equal(abschnitt(fx, leer.root, key), null, `${key}: erst nach dem ersten Speichern`)
for (const [bauen, datei] of [['hinweise', 'zeiten-leisten'], ['kontakt', 'zeiten-leisten'], ['google', 'zeiten-google']]) {
const modul = await import(`../public/${datei}.js`)
assert.equal(typeof modul[bauen], 'function', `${datei}.js exportiert ${bauen}(t)`)
}
}))
// ---------------------------------------------------------------------------------------------------------------- Hinweis-Leiste
test('Hinweis-Leiste: der Knopf heißt genau „Hinweis-Leiste anlegen“, das Formular hat Text, Art und Zeitraum, PUT mit neuem Hinweis', mitDom({}, async (fx) => {
const { root } = await oeffne(fx)
const knopf = fx.finde(root, '[data-werkzeug="hinweis-neu"]')
assert.equal(fx.text(knopf), 'Hinweis-Leiste anlegen')
assert.equal(knopf.tagName, 'BUTTON')
assert.match(fx.text(abschnitt(fx, root, 'hinweise')), /Keine Hinweise eingetragen/)
await fx.klick(knopf)
const form = neuerHinweis(fx, root)
assert.ok(form)
assert.equal(hinweisFeld(fx, form, 'text').getAttribute('maxlength'), '140')
assert.deepEqual(optionen(fx, hinweisFeld(fx, form, 'art')), ['info', 'urlaub', 'aktion', 'wichtig'])
assert.deepEqual(fx.alle(hinweisFeld(fx, form, 'art'), 'option').map((o) => fx.text(o)), ['Info', 'Urlaub', 'Aktion', 'Wichtig'])
assert.equal(hinweisFeld(fx, form, 'von').getAttribute('type'), 'date')
assert.equal(hinweisFeld(fx, form, 'bis').getAttribute('type'), 'date')
await fx.setze(hinweisFeld(fx, form, 'text'), ' Heute Kuchen im Angebot ')
await fx.setze(hinweisFeld(fx, form, 'art'), 'wichtig')
const von = tagPlus(HEUTE, 1)
const bis = tagPlus(HEUTE, 3)
await fx.setze(hinweisFeld(fx, form, 'von'), von)
await fx.setze(hinweisFeld(fx, form, 'bis'), bis)
const gespeichert = { id: 'h_neu00001', art: 'wichtig', text: 'Heute Kuchen im Angebot', knopf: null, von, bis }
fx.antworten.push(antwort(daten({ hinweise: [gespeichert] }), 4))
await fx.klick(fx.finde(form, 'button[type="submit"]'))
assert.equal(puts(fx).length, 1)
const h = puts(fx)[0].body.daten.hinweise
assert.equal(h.length, 1)
assert.match(h[0].id, /^h_[a-z0-9]{8}$/)
assert.deepEqual({ ...h[0], id: 'h' }, { id: 'h', art: 'wichtig', text: 'Heute Kuchen im Angebot', knopf: null, von, bis })
assert.match(meldung(fx, root), /Hinweis.*gespeichert/)
const zeile = fx.finde(abschnitt(fx, root, 'hinweise'), 'li[data-id="h_neu00001"]')
assert.ok(zeile, 'der Hinweis steht in der Liste')
assert.ok(fx.text(zeile).includes('Heute Kuchen im Angebot'))
assert.ok(fx.text(zeile).includes('Wichtig'))
assert.ok(fx.text(zeile).includes(ymdKurz(von)))
assert.equal(neuerHinweis(fx, root), null, 'das Formular ist nach dem Speichern zu')
}))
test('Hinweis: Zeitraum ist optional (ohne Daten gilt der Hinweis immer), Datum leer ⇒ null', mitDom({}, async (fx) => {
const { root } = await oeffne(fx)
await fx.klick(fx.finde(root, '[data-werkzeug="hinweis-neu"]'))
const form = neuerHinweis(fx, root)
await fx.setze(hinweisFeld(fx, form, 'text'), 'Heute Ruhetag')
fx.antworten.push(antwort(daten(), 4))
await fx.klick(fx.finde(form, 'button[type="submit"]'))
const h = puts(fx)[0].body.daten.hinweise[0]
assert.equal(h.art, 'info')
assert.equal(h.von, null)
assert.equal(h.bis, null)
}))
test('Hinweis: Aktion ohne Enddatum ⇒ Feldfehler am Enddatum (nichts geht hinaus); % im Text ⇒ Preis-Hinweis', mitDom({}, async (fx) => {
const { root } = await oeffne(fx)
await fx.klick(fx.finde(root, '[data-werkzeug="hinweis-neu"]'))
const form = neuerHinweis(fx, root)
const hinweis = fx.finde(form, '[data-hinweis="preis"]')
assert.ok(hinweis, 'der Preis-Hinweis hat seinen Platz im Formular')
assert.equal(sichtbar(hinweis), false, 'zuerst nicht sichtbar')
await fx.setze(hinweisFeld(fx, form, 'text'), 'Alles 20 % günstiger')
assert.equal(sichtbar(hinweis), true, '% im Text')
assert.match(fx.text(hinweis), /Endpreise/)
assert.match(fx.text(hinweis), /niedrigste/)
assert.match(fx.text(hinweis), /30 Tage/)
await fx.setze(hinweisFeld(fx, form, 'text'), 'Jetzt nur 5 € statt 8 €')
assert.equal(sichtbar(hinweis), true, '„statt“ im Text')
await fx.setze(hinweisFeld(fx, form, 'text'), 'Heute Schlussverkauf')
assert.equal(sichtbar(hinweis), false, 'ohne % und „statt“ weg')
await fx.setze(hinweisFeld(fx, form, 'art'), 'aktion')
await fx.klick(fx.finde(form, 'button[type="submit"]'))
assert.equal(puts(fx).length, 0, 'ohne Enddatum geht nichts hinaus')
assert.equal(fx.text(hinweisFehlerPfad(fx, form, 'bis')), 'Eine Aktion braucht ein Enddatum.')
assert.equal(hinweisFeld(fx, form, 'bis').getAttribute('aria-invalid'), 'true')
// mit Enddatum geht es hinaus
await fx.setze(hinweisFeld(fx, form, 'bis'), tagPlus(HEUTE, 5))
fx.antworten.push(antwort(daten(), 4))
await fx.klick(fx.finde(form, 'button[type="submit"]'))
assert.equal(puts(fx).length, 1)
assert.equal(puts(fx)[0].body.daten.hinweise[0].art, 'aktion')
}))
test('Hinweis: Ende vor Beginn ⇒ Feldfehler; ein Fehler des Servers steht am Feld', mitDom({}, async (fx) => {
const { root } = await oeffne(fx)
await fx.klick(fx.finde(root, '[data-werkzeug="hinweis-neu"]'))
const form = neuerHinweis(fx, root)
await fx.setze(hinweisFeld(fx, form, 'text'), 'Zeitraum falsch')
await fx.setze(hinweisFeld(fx, form, 'von'), tagPlus(HEUTE, 5))
await fx.setze(hinweisFeld(fx, form, 'bis'), tagPlus(HEUTE, 2))
await fx.klick(fx.finde(form, 'button[type="submit"]'))
assert.equal(puts(fx).length, 0)
assert.equal(fx.text(hinweisFehlerPfad(fx, form, 'bis')), 'Das Ende darf nicht vor dem Beginn liegen.')
// der Server hat das letzte Wort: Fehler an einem Textfeld
await fx.setze(hinweisFeld(fx, form, 'bis'), tagPlus(HEUTE, 6))
const pfad = hinweisFeld(fx, form, 'text').getAttribute('data-feld')
fx.antworten.push(fehler(400, 'x', { fehler: { [pfad]: 'Bitte gib einen Text ein.' } }))
await fx.klick(fx.finde(form, 'button[type="submit"]'))
assert.equal(puts(fx).length, 1)
assert.equal(fx.text(fehlerAn(fx, form, pfad)), 'Bitte gib einen Text ein.')
assert.equal(hinweisFeld(fx, form, 'text').getAttribute('aria-invalid'), 'true')
}))
test('Hinweis-Knopf: nur Link (https) oder Anrufen, Link-Prüfung als Hinweis, der Server entscheidet', mitDom({}, async (fx) => {
const { root } = await oeffne(fx)
await fx.klick(fx.finde(root, '[data-werkzeug="hinweis-neu"]'))
const form = neuerHinweis(fx, root)
const art = hinweisFeld(fx, form, 'knopf.art')
assert.deepEqual(optionen(fx, art), ['', 'link', 'anruf'], 'kein Knopf, Link, Anrufen – sonst nichts')
assert.deepEqual(fx.alle(art, 'option').map((o) => fx.text(o)), ['Kein Knopf', 'Link', 'Anrufen'])
assert.equal(art.value, '', 'Vorgabe: kein Knopf')
const felder = fx.finde(form, '[data-knopf-felder]')
assert.equal(felder.hidden, true, 'ohne Knopf keine Knopf-Felder')
assert.equal(hinweisFeld(fx, form, 'knopf.text').getAttribute('maxlength'), '30')
await fx.setze(hinweisFeld(fx, form, 'text'), 'Neue Torten')
await fx.setze(art, 'link')
assert.equal(felder.hidden, false)
assert.equal(hinweisFeld(fx, form, 'knopf.ziel').getAttribute('maxlength'), '300')
const linkHinweis = fx.finde(form, '[data-hinweis="knopf-ziel"]')
await fx.setze(hinweisFeld(fx, form, 'knopf.text'), 'Mehr erfahren')
await fx.setze(hinweisFeld(fx, form, 'knopf.ziel'), 'http://beispiel.de/torten')
assert.equal(sichtbar(linkHinweis), true, 'http ist kein https')
assert.match(fx.text(linkHinweis), /https:\/\//)
await fx.setze(hinweisFeld(fx, form, 'knopf.ziel'), 'javascript:alert(1)')
assert.equal(sichtbar(linkHinweis), true)
await fx.setze(hinweisFeld(fx, form, 'knopf.ziel'), 'https://beispiel.de/torten')
assert.equal(sichtbar(linkHinweis), false, 'ein https-Link ist in Ordnung')
// die Prüfung ist nur ein Hinweis: ein schlechter Link geht hinaus, der Server lehnt ihn ab, der Fehler steht am Feld
await fx.setze(hinweisFeld(fx, form, 'knopf.ziel'), 'http://beispiel.de/torten')
const pfad = hinweisFeld(fx, form, 'knopf.ziel').getAttribute('data-feld')
fx.antworten.push(fehler(400, 'x', { fehler: { [pfad]: 'Bitte gib eine vollständige Adresse mit https:// an (höchstens 300 Zeichen, ohne Zugangsdaten).' } }))
await fx.klick(fx.finde(form, 'button[type="submit"]'))
assert.equal(puts(fx).length, 1, 'der Server entscheidet')
assert.deepEqual(puts(fx)[0].body.daten.hinweise[0].knopf, { art: 'link', ziel: 'http://beispiel.de/torten', text: 'Mehr erfahren' })
assert.match(fx.text(fehlerAn(fx, form, pfad)), /https:\/\//)
// Anrufen: Ziffern, Leerzeichen, + / -
await fx.setze(art, 'anruf')
const anrufHinweis = fx.finde(form, '[data-hinweis="knopf-ziel"]')
await fx.setze(hinweisFeld(fx, form, 'knopf.ziel'), '06301 abc')
assert.equal(sichtbar(anrufHinweis), true)
assert.match(fx.text(anrufHinweis), /Ziffern/)
await fx.setze(hinweisFeld(fx, form, 'knopf.ziel'), '+49 6301 123-456')
assert.equal(sichtbar(anrufHinweis), false)
await fx.setze(hinweisFeld(fx, form, 'knopf.text'), 'Anrufen')
fx.antworten.push(antwort(daten(), 4))
await fx.klick(fx.finde(form, 'button[type="submit"]'))
assert.deepEqual(puts(fx)[1].body.daten.hinweise[0].knopf, { art: 'anruf', ziel: '+49 6301 123-456', text: 'Anrufen' })
// Knopf wieder „Kein Knopf“ ⇒ knopf null
const zwei = await oeffne(fx)
await fx.klick(fx.finde(zwei.root, '[data-werkzeug="hinweis-neu"]'))
const form2 = neuerHinweis(fx, zwei.root)
await fx.setze(hinweisFeld(fx, form2, 'text'), 'Ohne Knopf')
await fx.setze(hinweisFeld(fx, form2, 'knopf.art'), 'link')
await fx.setze(hinweisFeld(fx, form2, 'knopf.art'), '')
fx.antworten.push(antwort(daten(), 4))
await fx.klick(fx.finde(form2, 'button[type="submit"]'))
assert.equal(puts(fx)[2].body.daten.hinweise[0].knopf, null)
}))
test('Hinweis bearbeiten und entfernen: der Eintrag wird per ID gefunden, die Reihenfolge der übrigen bleibt, Rückfrage in der Seite', mitDom({}, async (fx) => {
const { root } = await oeffne(fx, { start: stand(daten({ hinweise: [hinweisAktion, hinweisUrlaub] })) })
const liste = abschnitt(fx, root, 'hinweise')
const zeile = fx.finde(liste, 'li[data-id="h_aktion001"]')
assert.ok(fx.text(zeile).includes('20 % auf alle Torten'))
assert.ok(fx.text(zeile).includes('Aktion'))
assert.match(fx.text(zeile), /Mehr erfahren/, 'der Knopf steht in der Zeile')
await fx.klick(fx.finde(zeile, '[data-aktion="bearbeiten"]'))
const form = fx.finde(zeile, 'form[aria-label="Hinweis bearbeiten"]')
assert.ok(form)
assert.equal(hinweisFeld(fx, form, 'text').value, '20 % auf alle Torten')
assert.equal(hinweisFeld(fx, form, 'art').value, 'aktion')
assert.equal(hinweisFeld(fx, form, 'knopf.art').value, 'link')
assert.equal(hinweisFeld(fx, form, 'knopf.ziel').value, 'https://beispiel.de/torten')
assert.equal(hinweisFeld(fx, form, 'knopf.text').value, 'Mehr erfahren')
assert.equal(hinweisFeld(fx, form, 'bis').value, hinweisAktion.bis)
assert.equal(sichtbar(fx.finde(form, '[data-hinweis="preis"]')), true, '% im gespeicherten Text zeigt den Preis-Hinweis gleich')
await fx.setze(hinweisFeld(fx, form, 'text'), '30 % auf alle Torten')
fx.antworten.push(antwort(daten(), 4))
await fx.klick(fx.finde(form, 'button[type="submit"]'))
const h = puts(fx)[0].body.daten.hinweise
assert.deepEqual(h.map((x) => x.id), ['h_aktion001', 'h_urlaub001'], 'dieselbe Reihenfolge')
assert.equal(h[0].text, '30 % auf alle Torten')
assert.deepEqual(h[1], hinweisUrlaub)
assert.deepEqual(h[0].knopf, hinweisAktion.knopf)
// Entfernen mit Rückfrage
const zwei = await oeffne(fx, { start: stand(daten({ hinweise: [hinweisAktion, hinweisUrlaub] })) })
const zeile2 = fx.finde(zwei.root, 'li[data-id="h_urlaub001"]')
await fx.klick(fx.finde(zeile2, '[data-aktion="bearbeiten"]'))
await fx.klick(fx.finde(zeile2, '[data-aktion="entfernen"]'))
const rueckfrage = fx.finde(zeile2, '.tm-frage-box')
assert.match(fx.text(rueckfrage), /Hinweis entfernen\?/)
assert.equal(puts(fx).length, 1, 'erst die Rückfrage')
fx.antworten.push(antwort(daten({ hinweise: [hinweisAktion] }), 5))
await fx.klick(fx.finde(rueckfrage, 'button[type="submit"]'))
assert.deepEqual(puts(fx)[1].body.daten.hinweise.map((x) => x.id), ['h_aktion001'])
assert.equal(fx.finde(zwei.root, 'li[data-id="h_urlaub001"]'), null, 'der Hinweis ist aus der Liste')
}))
test('Hinweis-Liste: laufend und geplant mit Pille, Vergangenes eingeklappt, nach Beginn sortiert; Text als Text', mitDom({}, async (fx) => {
const bose = '<img src=x onerror=alert(1)>'
const { root } = await oeffne(fx, { start: stand(daten({ hinweise: [hinweisUrlaub, { ...hinweisAktion, text: bose }, hinweisAlt] })) })
const liste = abschnitt(fx, root, 'hinweise')
assert.equal(fx.alle(root, 'img').length, 0)
const kommend = fx.alle(fx.finde(liste, 'ul[aria-label="Laufende und geplante Hinweise"]'), 'li[data-id]').map((li) => li.getAttribute('data-id'))
assert.deepEqual(kommend, ['h_aktion001', 'h_urlaub001'], 'nach Beginn sortiert')
assert.match(fx.text(fx.finde(liste, 'li[data-id="h_aktion001"]')), /läuft/)
assert.match(fx.text(fx.finde(liste, 'li[data-id="h_urlaub001"]')), /geplant/)
const vorbei = fx.finde(liste, 'details')
assert.match(fx.text(fx.finde(vorbei, 'summary')), /Vergangene Hinweise \(1\)/)
assert.ok(fx.finde(vorbei, 'li[data-id="h_vorbei001"]'))
}))
test('Hinweise: bei 20 Hinweisen ist „Hinweis-Leiste anlegen“ gesperrt und sagt warum', mitDom({}, async (fx) => {
const viele = Array.from({ length: ZEITEN_LIMITS.hinweise }, (_, i) => ({ id: `h_viele${String(i).padStart(3, '0')}`, art: 'info', text: `Hinweis ${i}`, knopf: null, von: null, bis: null }))
const { root } = await oeffne(fx, { start: stand(daten({ hinweise: viele })) })
assert.equal(fx.finde(root, '[data-werkzeug="hinweis-neu"]').disabled, true)
assert.match(fx.text(abschnitt(fx, root, 'hinweise')), /Mehr als 20 Hinweise sind nicht möglich/)
}))
test('Hinweis speichern zeichnet nur die Hinweise neu: Wochenplan-Entwurf und Kontakt-Eingaben bleiben', mitDom({}, async (fx) => {
const { root } = await oeffne(fx)
const [von1] = zeitFelder(fx, tagZeile(fx, root, P1, '5'))
await fx.setze(von1, '08:15')
await fx.setze(kfeld(fx, root, 'adresse'), 'Neue Straße 9')
const kontaktAlt = abschnitt(fx, root, 'kontakt')
await fx.klick(fx.finde(root, '[data-werkzeug="hinweis-neu"]'))
const form = neuerHinweis(fx, root)
await fx.setze(hinweisFeld(fx, form, 'text'), 'Neu')
fx.antworten.push(antwort(daten({ hinweise: [{ id: 'h_neu00002', art: 'info', text: 'Neu', knopf: null, von: null, bis: null }] }), 4))
await fx.klick(fx.finde(form, 'button[type="submit"]'))
assert.equal(zeitFelder(fx, tagZeile(fx, root, P1, '5'))[0].value, '08:15', 'der Wochenplan-Entwurf steht noch da')
assert.equal(abschnitt(fx, root, 'kontakt'), kontaktAlt, 'der Kontakt-Abschnitt ist dasselbe Element')
assert.equal(kfeld(fx, root, 'adresse').value, 'Neue Straße 9')
assert.ok(fx.finde(root, 'li[data-id="h_neu00002"]'))
assert.equal(puts(fx)[0].body.daten.plaene[0].tage['5'].fenster[0][0], '09:00', 'der Entwurf ging nicht mit hinaus')
}))
test('Urlaub-Frage „Ja, anlegen“: der neue Hinweis steht danach auch in der Hinweis-Liste', mitDom({}, async (fx) => {
const { root } = await oeffne(fx)
await fx.klick(fx.finde(root, '[data-werkzeug="urlaub-neu"]'))
const form = urlaubsformular(fx, root)
const von = tagPlus(HEUTE, 20)
const bis = tagPlus(HEUTE, 25)
await fx.setze(feldIn(fx, form, 'von'), von)
await fx.setze(feldIn(fx, form, 'bis'), bis)
const urlaub = { id: 'x_urlaub02', art: 'urlaub', von, bis, text: '', modus: 'zu', fenster: [], plan: null }
fx.antworten.push(antwort(daten({ ausnahmen: [urlaub] }), 4))
await fx.klick(fx.finde(form, 'button[type="submit"]'))
const vorschlag = { id: 'h_vorschlag', art: 'urlaub', text: 'Betriebsurlaub', knopf: null, von: tagPlus(von, -7), bis }
fx.antworten.push(antwort(daten({ ausnahmen: [urlaub], hinweise: [vorschlag] }), 5))
await fx.klick(fx.finde(fx.finde(root, '[data-frage="hinweis"]'), '[data-aktion="hinweis-ja"]'))
assert.ok(fx.finde(abschnitt(fx, root, 'hinweise'), 'li[data-id="h_vorschlag"]'), 'die Hinweis-Liste zeigt ihn')
}))
test('Hinweise abgeschaltet (nur ansehen): Liste ja, aber kein Knopf zum Anlegen und alle Felder gesperrt', mitDom({}, async (fx) => {
const { root } = await oeffne(fx, { state: 'disabled', start: stand(daten({ hinweise: [hinweisAktion] }), { state: 'disabled' }) })
const liste = abschnitt(fx, root, 'hinweise')
assert.ok(fx.finde(liste, 'li[data-id="h_aktion001"]'))
assert.equal(fx.finde(liste, '[data-werkzeug="hinweis-neu"]'), null)
await fx.klick(fx.finde(liste, '[data-aktion="bearbeiten"]'))
assert.equal(fx.text(fx.finde(liste, '[data-aktion="bearbeiten"]')), 'Ansehen')
const form = fx.finde(liste, 'form[aria-label="Hinweis bearbeiten"]')
assert.equal(fx.finde(form, 'button[type="submit"]'), null)
for (const feld of fx.alle(form, 'input, select')) assert.equal(feld.matches(':disabled'), true, `${feld.tagName} ist gesperrt`)
}))
// ---------------------------------------------------------------------------------------------------------------- Kontakt-Leiste
test('Kontakt-Leiste: Ein/Aus, Telefon, Adresse und WhatsApp aus den Daten; unverändert gespeichert bleibt bestaetigtAt (R12)', mitDom({}, async (fx) => {
const { root } = await oeffne(fx)
const form = kontaktForm(fx, root)
assert.ok(form)
assert.equal(kfeld(fx, root, 'an').checked, true)
assert.equal(kfeld(fx, root, 'telefon').value, '+49 6301123456', 'die gespeicherte Nummer (Ziffern mit Vorwahl) steht mit + da')
assert.equal(kfeld(fx, root, 'telefon').getAttribute('maxlength'), '30')
assert.equal(kfeld(fx, root, 'adresse').value, KONTAKT.adresse)
assert.equal(kfeld(fx, root, 'adresse').getAttribute('maxlength'), '200')
assert.equal(kfeld(fx, root, 'whatsapp').checked, true)
assert.equal(kfeld(fx, root, 'whatsapp.nummer').value, '+49 17112345678')
assert.equal(kfeld(fx, root, 'whatsapp.starttext').value, 'Hallo!')
assert.equal(kfeld(fx, root, 'whatsapp.starttext').getAttribute('maxlength'), '200')
assert.equal(kfeld(fx, root, 'whatsapp.bestaetigtAt').checked, true, 'schon bestätigt')
assert.match(fx.text(form), /Ich habe den WhatsApp-Hinweis in meine Datenschutzerklärung aufgenommen/)
assert.equal(fx.finde(form, '[data-whatsapp]').hidden, false)
fx.antworten.push(antwort(daten(), 4))
await fx.klick(fx.finde(form, '[data-aktion="kontakt-speichern"]'))
assert.deepEqual(puts(fx)[0].body.daten.kontakt, KONTAKT, 'dieselben Werte, bestaetigtAt unverändert')
}))
test('Kontakt: das Telefon geht so hinaus, wie der Kunde es schreibt; die Nummer aus der Antwort des Servers steht danach im Feld und in der Meldung', mitDom({}, async (fx) => {
const { root } = await oeffne(fx)
await fx.setze(kfeld(fx, root, 'telefon'), '0171 1234567')
await fx.setze(kfeld(fx, root, 'adresse'), 'Marktplatz 1')
const gespeichert = daten({ kontakt: { ...KONTAKT, telefon: '491711234567', adresse: 'Marktplatz 1' } })
fx.antworten.push(antwort(gespeichert, 4))
await fx.klick(fx.finde(kontaktForm(fx, root), '[data-aktion="kontakt-speichern"]'))
const k = puts(fx)[0].body.daten.kontakt
assert.equal(k.telefon, '0171 1234567')
assert.equal(k.adresse, 'Marktplatz 1')
assert.equal(kfeld(fx, root, 'telefon').value, '+49 1711234567', 'das Feld zeigt, was der Server gespeichert hat')
assert.ok(meldung(fx, root).includes('+49 1711234567'), 'die Meldung nennt die gespeicherte Nummer')
assert.match(meldung(fx, root), /Kontakt-Leiste gespeichert/)
}))
test('Kontakt: ohne gespeicherte Kontaktdaten sind die Felder leer und die Leiste aus', mitDom({}, async (fx) => {
const ohne = daten()
delete ohne.kontakt
const { root } = await oeffne(fx, { start: stand(ohne) })
assert.equal(kfeld(fx, root, 'an').checked, false)
assert.equal(kfeld(fx, root, 'telefon').value, '')
assert.equal(kfeld(fx, root, 'whatsapp').checked, false)
assert.equal(fx.finde(kontaktForm(fx, root), '[data-whatsapp]').hidden, true)
await fx.klick(kfeld(fx, root, 'an'))
await fx.setze(kfeld(fx, root, 'telefon'), '06301 123456')
fx.antworten.push(antwort(daten(), 4))
await fx.klick(fx.finde(kontaktForm(fx, root), '[data-aktion="kontakt-speichern"]'))
assert.deepEqual(puts(fx)[0].body.daten.kontakt, { an: true, telefon: '06301 123456', adresse: '', whatsapp: null })
}))
test('Kontakt: WhatsApp verlangt den Datenschutz-Haken – ohne Haken geht nichts hinaus, mit neuem Haken ein frischer ISO-Zeitpunkt, ein gespeicherter bleibt', mitDom({}, async (fx) => {
const ohne = daten({ kontakt: { an: true, telefon: '496301123456', adresse: '', whatsapp: null } })
const { root } = await oeffne(fx, { start: stand(ohne) })
const form = kontaktForm(fx, root)
assert.equal(fx.finde(form, '[data-whatsapp]').hidden, true)
await fx.klick(kfeld(fx, root, 'whatsapp'))
assert.equal(fx.finde(form, '[data-whatsapp]').hidden, false, 'WhatsApp an zeigt die Felder')
assert.equal(kfeld(fx, root, 'whatsapp.bestaetigtAt').checked, false)
await fx.setze(kfeld(fx, root, 'whatsapp.nummer'), '0171 7654321')
await fx.setze(kfeld(fx, root, 'whatsapp.starttext'), 'Hallo, ich habe eine Frage.')
await fx.klick(fx.finde(form, '[data-aktion="kontakt-speichern"]'))
assert.equal(puts(fx).length, 0, 'ohne Haken geht nichts hinaus')
assert.match(fx.text(fehlerAn(fx, form, 'kontakt.whatsapp.bestaetigtAt')), /Bitte bestätige, dass du den WhatsApp-Hinweis in deine Datenschutzerklärung aufgenommen hast/)
assert.equal(kfeld(fx, root, 'whatsapp.bestaetigtAt').getAttribute('aria-invalid'), 'true')
// mit Haken: frischer Zeitpunkt (der Server ersetzt ihn durch seine Zeit)
await fx.klick(kfeld(fx, root, 'whatsapp.bestaetigtAt'))
const vorher = Date.now()
const serverZeit = '2026-10-05T08:00:00.000Z'
fx.antworten.push(antwort(daten({ kontakt: { an: true, telefon: '496301123456', adresse: '', whatsapp: { nummer: '491717654321', starttext: 'Hallo, ich habe eine Frage.', bestaetigtAt: serverZeit } } }), 4))
await fx.klick(fx.finde(form, '[data-aktion="kontakt-speichern"]'))
const w = puts(fx)[0].body.daten.kontakt.whatsapp
assert.equal(w.nummer, '0171 7654321')
assert.equal(w.starttext, 'Hallo, ich habe eine Frage.')
assert.match(w.bestaetigtAt, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/)
assert.ok(Math.abs(Date.parse(w.bestaetigtAt) - vorher) < 5000, 'ein frischer Zeitpunkt')
assert.equal(kfeld(fx, root, 'whatsapp.nummer').value, '+49 1717654321', 'die Nummer des Servers')
assert.ok(meldung(fx, root).includes('+49 1717654321'))
// der Haken steht jetzt (gespeichert): das nächste Speichern schickt den gespeicherten Wert unverändert zurück
assert.equal(kfeld(fx, root, 'whatsapp.bestaetigtAt').checked, true)
fx.antworten.push(antwort(daten(), 5))
await fx.klick(fx.finde(kontaktForm(fx, root), '[data-aktion="kontakt-speichern"]'))
assert.equal(puts(fx)[1].body.daten.kontakt.whatsapp.bestaetigtAt, serverZeit)
}))
test('Kontakt: WhatsApp abwählen ⇒ whatsapp null (und der Haken ist weg); die Leiste ausschalten ⇒ an false', mitDom({}, async (fx) => {
const { root } = await oeffne(fx)
await fx.klick(kfeld(fx, root, 'whatsapp'))
assert.equal(fx.finde(kontaktForm(fx, root), '[data-whatsapp]').hidden, true)
await fx.klick(kfeld(fx, root, 'an'))
fx.antworten.push(antwort(daten(), 4))
await fx.klick(fx.finde(kontaktForm(fx, root), '[data-aktion="kontakt-speichern"]'))
const k = puts(fx)[0].body.daten.kontakt
assert.equal(k.whatsapp, null)
assert.equal(k.an, false)
assert.equal(k.telefon, '496301123456', 'die übrigen Angaben bleiben')
}))
test('Kontakt: ein Fehler des Servers steht am Feld; Speichern ändert nichts an den anderen Formularen', mitDom({}, async (fx) => {
const { root } = await oeffne(fx)
const [von1] = zeitFelder(fx, tagZeile(fx, root, P1, '5'))
await fx.setze(von1, '07:45')
await fx.setze(kfeld(fx, root, 'telefon'), 'abc')
fx.antworten.push(fehler(400, 'x', { fehler: { 'kontakt.telefon': 'Bitte gib eine gültige Telefonnummer an (nur Ziffern, Leerzeichen, + ( ) / . und -).' } }))
await fx.klick(fx.finde(kontaktForm(fx, root), '[data-aktion="kontakt-speichern"]'))
assert.match(fx.text(fehlerAn(fx, root, 'kontakt.telefon')), /gültige Telefonnummer/)
assert.equal(kfeld(fx, root, 'telefon').getAttribute('aria-invalid'), 'true')
assert.equal(zeitFelder(fx, tagZeile(fx, root, P1, '5'))[0].value, '07:45')
// Speichern gelingt: nur der Kontakt-Abschnitt wird neu gezeichnet
await fx.setze(kfeld(fx, root, 'telefon'), '06301 111')
fx.antworten.push(antwort(daten({ kontakt: { ...KONTAKT, telefon: '4963011110' } }), 4))
await fx.klick(fx.finde(kontaktForm(fx, root), '[data-aktion="kontakt-speichern"]'))
assert.equal(zeitFelder(fx, tagZeile(fx, root, P1, '5'))[0].value, '07:45', 'der Wochenplan-Entwurf bleibt')
}))
test('Kontakt in der Kundenansicht (R12): der WhatsApp-Haken ist gesperrt, ein gespeicherter Zeitpunkt geht unverändert zurück, nie ein neuer', mitDom({}, async (fx) => {
const { root } = await oeffne(fx, { start: stand(daten(), { kundenansicht: true }), kundenansicht: true })
const haken = kfeld(fx, root, 'whatsapp.bestaetigtAt')
assert.equal(haken.checked, true)
assert.equal(haken.disabled, true, 'gesperrt')
assert.match(fx.text(kontaktForm(fx, root)), /Kundenansicht/)
assert.match(fx.text(fx.finde(kontaktForm(fx, root), '[data-hinweis="whatsapp-kundenansicht"]')), /nur der Kunde selbst/)
await fx.klick(haken)
assert.equal(haken.checked, true, 'ein Klick ändert nichts')
await fx.setze(kfeld(fx, root, 'whatsapp.starttext'), 'Hallo zusammen')
fx.antworten.push(antwort(daten(), 4, { kundenansicht: true }))
await fx.klick(fx.finde(kontaktForm(fx, root), '[data-aktion="kontakt-speichern"]'))
assert.equal(puts(fx)[0].body.daten.kontakt.whatsapp.bestaetigtAt, KONTAKT.whatsapp.bestaetigtAt, 'unverändert zurück')
assert.equal(puts(fx)[0].body.daten.kontakt.whatsapp.starttext, 'Hallo zusammen')
// ohne gespeicherte Bestätigung: WhatsApp einschalten geht in der Kundenansicht nicht (nie ein neuer Zeitpunkt)
const ohne = daten({ kontakt: { an: true, telefon: '496301123456', adresse: '', whatsapp: null } })
const zwei = await oeffne(fx, { start: stand(ohne, { kundenansicht: true }), kundenansicht: true })
await fx.klick(kfeld(fx, zwei.root, 'whatsapp'))
await fx.setze(kfeld(fx, zwei.root, 'whatsapp.nummer'), '0171 7654321')
const haken2 = kfeld(fx, zwei.root, 'whatsapp.bestaetigtAt')
assert.equal(haken2.disabled, true)
assert.equal(haken2.checked, false)
await fx.klick(fx.finde(kontaktForm(fx, zwei.root), '[data-aktion="kontakt-speichern"]'))
assert.equal(puts(fx).length, 1, 'es geht nichts hinaus')
assert.match(fx.text(fehlerAn(fx, zwei.root, 'kontakt.whatsapp.bestaetigtAt')), /Kunde/)
// Kundenansicht ohne WhatsApp: speichern geht, whatsapp bleibt null
await fx.klick(kfeld(fx, zwei.root, 'whatsapp'))
fx.antworten.push(antwort(daten(), 5, { kundenansicht: true }))
await fx.klick(fx.finde(kontaktForm(fx, zwei.root), '[data-aktion="kontakt-speichern"]'))
assert.equal(puts(fx)[1].body.daten.kontakt.whatsapp, null)
}))
test('Kontakt abgeschaltet (nur ansehen): Felder gesperrt, kein Speichern-Knopf', mitDom({}, async (fx) => {
const { root } = await oeffne(fx, { state: 'disabled', start: stand(daten(), { state: 'disabled' }) })
const form = kontaktForm(fx, root)
assert.equal(fx.finde(form, 'button[type="submit"]'), null)
for (const feld of fx.alle(form, 'input, select')) assert.equal(feld.matches(':disabled'), true, `${feld.tagName} ist gesperrt`)
}))
// ---------------------------------------------------------------------------------------------------------------- Google
test('Google: „Für Google kopieren“ legt den googleText des Servers in die Zwischenablage; Link zum Google-Profil, Satz zu den Google-Daten', mitDom({}, async (fx) => {
const text = 'Montag: 09:00–12:30, 13:30–18:00\nDienstag: 09:00–18:00\nSonntag: geschlossen'
const { root } = await oeffne(fx, { start: stand(daten(), { googleText: text }) })
const el = abschnitt(fx, root, 'google')
const link = fx.finde(el, 'a[data-google="profil"]')
assert.equal(fx.text(link), 'Google-Profil öffnen')
assert.equal(link.getAttribute('href'), 'https://business.google.com/')
assert.match(link.getAttribute('rel'), /noopener/)
assert.equal(link.getAttribute('target'), '_blank')
assert.match(fx.text(el), /Google-Daten im Seitencode laufen automatisch/)
const knopf = fx.finde(el, '[data-aktion="google-kopieren"]')
assert.equal(fx.text(knopf), 'Für Google kopieren')
assert.equal(fx.finde(el, '[data-google="text"]').value, text, 'der Text steht zum Nachlesen da')
const kopiert = []
await mitZwischenablage({ writeText: async (t) => { kopiert.push(t) } }, async () => {
await fx.klick(knopf)
})
assert.deepEqual(kopiert, [text])
assert.match(fx.text(fx.finde(el, '[data-meldung="google"]')), /Kopiert/)
// nach einem Speichern gilt der neue Text vom Server
const neu = 'Montag: 08:00–12:00\nSonntag: geschlossen'
fx.antworten.push(antwort(daten(), 4, { googleText: neu }))
await fx.klick(speichernKnopf(fx, root, P1))
assert.equal(fx.finde(el, '[data-google="text"]').value, neu, 'die Anzeige folgt dem Server')
await mitZwischenablage({ writeText: async (t) => { kopiert.push(t) } }, async () => {
await fx.klick(knopf)
})
assert.deepEqual(kopiert, [text, neu])
}))
test('Google: fehlt die Zwischenablage oder wird das Kopieren abgelehnt, ist der Text markiert und eine Meldung sagt, wie es weitergeht', mitDom({}, async (fx) => {
const text = 'Montag: 09:00–18:00'
const { root } = await oeffne(fx, { start: stand(daten(), { googleText: text }) })
const el = abschnitt(fx, root, 'google')
const feld = fx.finde(el, '[data-google="text"]')
assert.equal(feld.hasAttribute('readonly'), true)
let markiert = 0
feld.select = () => { markiert += 1 }
await mitZwischenablage(undefined, async () => { await fx.klick(fx.finde(el, '[data-aktion="google-kopieren"]')) })
assert.equal(markiert, 1, 'ohne Zwischenablage: Text markiert')
assert.match(fx.text(fx.finde(el, '[data-meldung="google"]')), /markiert/)
assert.doesNotMatch(fx.text(fx.finde(el, '[data-meldung="google"]')), /^Kopiert/)
await mitZwischenablage({ writeText: async () => { throw new Error('verweigert') } }, async () => { await fx.klick(fx.finde(el, '[data-aktion="google-kopieren"]')) })
assert.equal(markiert, 2, 'abgelehnt: Text markiert')
assert.match(fx.text(fx.finde(el, '[data-meldung="google"]')), /markiert/)
}))
// ---------------------------------------------------------------------------------------------------------------- Vorschau
const vorschauText = (fx, root) => fx.text(fx.finde(root, '[data-vorschau="jetzt"]'))
const regler = (fx, root, name) => fx.finde(root, `[data-regler="${name}"]`)
const vorschauAufrufe = (fx) => fx.fetchLog.filter((a) => /\/vorschau\?/.test(a.url))
test('Vorschau oben: „So steht es gerade auf deiner Seite: …“ aus dem GET, nach dem Speichern aus der Antwort des PUT', mitDom({}, async (fx) => {
const { root } = await oeffne(fx, { start: stand(daten(), { vorschau: { offen: true, text: 'Jetzt geöffnet · schließt um 18 Uhr', kurz: 'Geöffnet' } }) })
const zeile = fx.finde(root, '.zt-vorschau')
assert.ok(zeile)
assert.match(fx.text(zeile), /So steht es gerade auf deiner Seite:/)
assert.equal(vorschauText(fx, root), 'Jetzt geöffnet · schließt um 18 Uhr')
assert.equal(fx.finde(root, '[data-vorschau="jetzt"]').getAttribute('data-offen'), 'true')
assert.equal(root.querySelector('.zt-kopfbereich').contains(zeile), true, 'oben, im Kopfbereich')
fx.antworten.push(antwort(daten(), 4, { vorschau: { offen: false, text: 'Geschlossen · öffnet morgen um 9 Uhr', kurz: 'Geschlossen' } }))
await fx.klick(speichernKnopf(fx, root, P1))
assert.equal(vorschauText(fx, root), 'Geschlossen · öffnet morgen um 9 Uhr')
assert.equal(fx.finde(root, '[data-vorschau="jetzt"]').getAttribute('data-offen'), 'false')
assert.equal(vorschauAufrufe(fx).length, 0, 'ohne Regler keine weitere Anfrage')
}))
test('Vorschau vor dem ersten Speichern: es steht noch nichts da; nach dem ersten Speichern der Status aus der Antwort', mitDom({}, async (fx) => {
const { root } = await oeffne(fx, { start: stand(null) })
assert.match(fx.text(fx.finde(root, '.zt-vorschau')), /So steht es gerade auf deiner Seite:/)
assert.match(vorschauText(fx, root), /noch keine Öffnungszeiten gespeichert/i)
const id = fx.finde(root, 'details[data-plan]').getAttribute('data-plan')
fx.antworten.push(antwort(daten({ plaene: [plan(id)] }), 1, { vorschau: { offen: null, text: 'Öffnungszeiten nach Vereinbarung', kurz: 'Nach Vereinbarung' } }))
await fx.klick(speichernKnopf(fx, root, id))
assert.equal(vorschauText(fx, root), 'Öffnungszeiten nach Vereinbarung')
assert.equal(fx.alle(root, '.zt-vorschau').length, 1)
}))
test('Vorschau-Regler: fragt /vorschau mit dem nächsten Vorkommen des Wochentags und der Uhrzeit (Berliner Wanduhr) und zeigt die Antwort', mitDom({}, async (fx) => {
const { root } = await oeffne(fx, { start: stand(daten(), { vorschau: { offen: true, text: 'Jetzt geöffnet', kurz: 'Geöffnet' } }) })
const tag = regler(fx, root, 'tag')
const zeit = regler(fx, root, 'zeit')
assert.deepEqual(optionen(fx, tag), ['1', '2', '3', '4', '5', '6', '7'])
assert.deepEqual(fx.alle(tag, 'option').map((o) => fx.text(o)), ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag'])
assert.equal(tag.value, String(wochentagNr(HEUTE)), 'Vorgabe: heute')
assert.equal(zeit.getAttribute('type'), 'time')
assert.match(zeit.value, /^\d{2}:\d{2}$/, 'Vorgabe: jetzt (Berliner Zeit)')
assert.match(fx.text(root.querySelector('.zt-regler')), /Uhrzeit testen/)
const geschlossen = { status: 200, body: { offen: false, text: 'Geschlossen · öffnet morgen um 9 Uhr', kurz: 'Geschlossen' } }
fx.antworten.push(geschlossen, geschlossen) // eine Anfrage je Änderung
await fx.setze(tag, '3')
await fx.setze(zeit, '14:30')
const aufrufe = vorschauAufrufe(fx)
assert.ok(aufrufe.length >= 1)
const letzter = aufrufe[aufrufe.length - 1]
assert.equal(letzter.method, 'GET')
const url = new URL(letzter.url, 'http://x')
assert.equal(url.pathname, '/api/zeiten/p1/vorschau')
assert.equal(url.searchParams.get('zeit'), `${naechster(3)}T14:30`)
const test = fx.text(fx.finde(root, '[data-vorschau="test"]'))
assert.match(test, /Geschlossen · öffnet morgen um 9 Uhr/)
assert.ok(test.includes('Mittwoch'), 'der Tag der Anfrage steht dabei')
assert.ok(test.includes('14:30'))
assert.equal(vorschauText(fx, root), 'Jetzt geöffnet', 'die Vorschau „jetzt“ bleibt')
}))
test('Vorschau-Regler: 409 keine_zeiten ist ein Hinweis, kein Fehler; ein anderer Fehler steht als Fehler; leere Uhrzeit fragt nichts', mitDom({}, async (fx) => {
const { root } = await oeffne(fx, { start: stand(null) })
fx.antworten.push(fehler(409, 'Bitte speichere zuerst deine Öffnungszeiten.', { code: 'keine_zeiten' }))
await fx.setze(regler(fx, root, 'zeit'), '10:00')
const ergebnis = fx.finde(root, '[data-vorschau="test"]')
assert.match(fx.text(ergebnis), /Speichere zuerst deine Öffnungszeiten/)
assert.notEqual(ergebnis.getAttribute('role'), 'alert', 'kein Fehler')
assert.equal(root.querySelector('[data-meldung="editor"]').textContent, '', 'nichts über dem Editor')
// anderer Fehler
fx.antworten.push(fehler(500, 'Fehler 500'))
await fx.setze(regler(fx, root, 'zeit'), '11:00')
assert.equal(fx.finde(root, '[data-vorschau="test"]').getAttribute('role'), 'alert')
assert.match(fx.text(fx.finde(root, '[data-vorschau="test"]')), /Fehler 500/)
// Uhrzeit leer ⇒ keine Anfrage
const n = vorschauAufrufe(fx).length
await fx.setze(regler(fx, root, 'zeit'), '')
assert.equal(vorschauAufrufe(fx).length, n)
}))
test('Vorschau-Regler: nur die letzte Anfrage zählt – eine späte Antwort auf eine frühere überschreibt nichts', mitDom({}, async (fx) => {
const { root } = await oeffne(fx)
const echtes = globalThis.fetch
let freigeben
const wartet = new Promise((resolve) => { freigeben = resolve })
let nr = 0
globalThis.fetch = async (url, optionen) => {
if (!/\/vorschau\?/.test(String(url))) return echtes(url, optionen)
nr += 1
const meine = nr
if (meine === 1) await wartet // die erste Antwort kommt zuletzt
fx.fetchLog.push({ method: 'GET', url: String(url) })
return { ok: true, status: 200, json: async () => ({ offen: meine === 1, text: meine === 1 ? 'Erste Frage' : 'Zweite Frage', kurz: '' }) }
}
try {
const erste = fx.setze(regler(fx, root, 'zeit'), '09:00')
await fx.ruhe()
await fx.setze(regler(fx, root, 'zeit'), '20:00')
assert.match(fx.text(fx.finde(root, '[data-vorschau="test"]')), /Zweite Frage/)
freigeben()
await erste
await fx.ruhe()
assert.match(fx.text(fx.finde(root, '[data-vorschau="test"]')), /Zweite Frage/, 'die späte erste Antwort wird verworfen')
assert.doesNotMatch(fx.text(fx.finde(root, '[data-vorschau="test"]')), /Erste Frage/)
} finally {
globalThis.fetch = echtes
}
}))
test('Vorschau-Regler: nach dem Speichern wird dieselbe Uhrzeit mit den neuen Daten erneut gefragt', mitDom({}, async (fx) => {
const { root } = await oeffne(fx)
fx.antworten.push({ status: 200, body: { offen: true, text: 'Alt', kurz: '' } })
await fx.setze(regler(fx, root, 'zeit'), '10:00')
assert.match(fx.text(fx.finde(root, '[data-vorschau="test"]')), /Alt/)
fx.antworten.push(antwort(daten(), 4), { status: 200, body: { offen: false, text: 'Neu', kurz: '' } })
await fx.klick(speichernKnopf(fx, root, P1))
await fx.ruhe()
assert.match(fx.text(fx.finde(root, '[data-vorschau="test"]')), /Neu/)
assert.equal(new URL(vorschauAufrufe(fx).at(-1).url, 'http://x').searchParams.get('zeit').slice(-5), '10:00')
}))
test('Vorschau abgeschaltet (nur ansehen): keine Vorschau und kein Regler (die Website zeigt nichts mehr)', mitDom({}, async (fx) => {
const { root } = await oeffne(fx, { state: 'disabled', start: stand(daten(), { state: 'disabled' }) })
assert.equal(fx.finde(root, '.zt-vorschau'), null)
assert.equal(fx.finde(root, '[data-regler]'), null)
}))
test('naechstesVorkommen: der erste Tag ab heute (einschließlich) mit dem Wochentag', async () => {
const { naechstesVorkommen, wochentagVon } = await import('../public/zeiten-view.js')
assert.equal(wochentagVon('2026-10-05'), 1, 'Montag')
assert.equal(wochentagVon('2026-10-11'), 7, 'Sonntag')
assert.equal(naechstesVorkommen('2026-10-05', 1), '2026-10-05', 'heute zählt')
assert.equal(naechstesVorkommen('2026-10-05', 3), '2026-10-07')
assert.equal(naechstesVorkommen('2026-10-07', 1), '2026-10-12', 'nächste Woche')
assert.equal(naechstesVorkommen('2026-10-05', 7), '2026-10-11')
assert.equal(naechstesVorkommen('2026-12-30', 5), '2027-01-01', 'über den Jahreswechsel')
})
// ---------------------------------------------------------------------------------------------------------------- Quelltext
const ohneKommentare = (quelle) => quelle.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '')
const DATEIEN = ['zeiten-speichern.js', 'zeiten-editor.js', 'zeiten-plaene.js', 'zeiten-ausnahmen.js']
const DATEIEN = ['zeiten-speichern.js', 'zeiten-editor.js', 'zeiten-plaene.js', 'zeiten-ausnahmen.js', 'zeiten-leisten.js', 'zeiten-google.js']
test('Quelltext: vier neue Dateien, nur relative Importe, kein HTML aus Daten, keine Browser-Rückfragen, kein Speicher, Du-Form', () => {
test('Quelltext: sechs Editor-Dateien, nur relative Importe, kein HTML aus Daten, keine Browser-Rückfragen, kein Speicher, Du-Form', () => {
const vorhanden = readdirSync(new URL('../public/', import.meta.url))
for (const datei of DATEIEN) {
assert.ok(vorhanden.includes(datei), `${datei} fehlt`)