diff --git a/public/zeiten-ausnahmen.js b/public/zeiten-ausnahmen.js index 50d394e..2533c50 100644 --- a/public/zeiten-ausnahmen.js +++ b/public/zeiten-ausnahmen.js @@ -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 } diff --git a/public/zeiten-editor.js b/public/zeiten-editor.js index fb5b998..e28730b 100644 --- a/public/zeiten-editor.js +++ b/public/zeiten-editor.js @@ -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//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)) diff --git a/public/zeiten-google.js b/public/zeiten-google.js new file mode 100644 index 0000000..1198e3b --- /dev/null +++ b/public/zeiten-google.js @@ -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 +} diff --git a/public/zeiten-leisten.js b/public/zeiten-leisten.js new file mode 100644 index 0000000..bb52f42 --- /dev/null +++ b/public/zeiten-leisten.js @@ -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..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) +} diff --git a/public/zeiten-view.js b/public/zeiten-view.js index 6b95863..2da2b00 100644 --- a/public/zeiten-view.js +++ b/public/zeiten-view.js @@ -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) diff --git a/public/zeiten.css b/public/zeiten.css index a683967..8f05037 100644 --- a/public/zeiten.css +++ b/public/zeiten.css @@ -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%; } diff --git a/test/ui/zeiten-portal.ui.mjs b/test/ui/zeiten-portal.ui.mjs new file mode 100644 index 0000000..ec1e569 --- /dev/null +++ b/test/ui/zeiten-portal.ui.mjs @@ -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-.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= 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) diff --git a/test/zeitenEditor.test.js b/test/zeitenEditor.test.js index 92e47ee..80c2344 100644 --- a/test/zeitenEditor.test.js +++ b/test/zeitenEditor.test.js @@ -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 = '' + 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`)