Öffnungszeiten: Editor für Hinweis- und Kontakt-Leiste, Google und Vorschau

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 06:43:22 +00:00
1 parent 242fbb01e5
commit b3e96d4a56
8 files changed
+1684 -13

No files matched your search

+2 -2
View File
@@ -9,7 +9,7 @@
„geschlossen“, ein „bitte prüfen“-Tag (Heiligabend, Silvester, nur ortsuebliche Feiertage) folgt dem Wochenplan.
Ausnahmen: Betriebsurlaub (von–bis, Text) und Sondertag (Datum, geschlossen oder Zeiten), Geltung „alle Pläne“ oder ein Plan. Nach dem Speichern
eines Urlaubs ab 2 Tagen fragt der Editor „Hinweis-Leiste dazu anlegen?“ (ctx.hinweisFrage); „Ja“ legt den Vorschlag (hinweisVorschlag) in
daten.hinweise an und speichert. */
daten.hinweise an und speichert (danach werden Ausnahmen und die Hinweis-Liste neu gezeichnet). */
import { BUNDESLAENDER, datumKurz, datumLang, heuteBerlin, istTag } from './termine-view.js'
import { abschnitt, allgemeinerFehler, editorZeile, feld, frage, gesperrt, h, neuerEintrag, zeigeFehler } from './termine-dom.js'
@@ -196,7 +196,7 @@ function hinweisFrage(t) {
}, { container: form })
if (!ok || !sp.nochHier()) return
ctx.hinweisFrage = null
nachSpeichern(t, `Die Hinweis-Leiste „${v.text}“ ist angelegt.`, '[data-werkzeug="urlaub-neu"]', () => t.zeichne(['ausnahmen']))
nachSpeichern(t, `Die Hinweis-Leiste „${v.text}“ ist angelegt.`, '[data-werkzeug="urlaub-neu"]', () => t.zeichne(['ausnahmen', 'hinweise']))
})
return form
}
+96 -5
View File
@@ -4,9 +4,14 @@
root.dataset.state = Zustand der Funktion (setup, active, disabled): disabled heisst nur lesen (alles gesperrt, PUT antwortet mit 403).
ctx von zeiten.js: { projectId, kundenansicht, neuZeichnen() } – neuZeichnen holt die Liste der Funktionen neu und zeichnet den Kopf der
Ansicht (Statuskarte); der Editor ruft es einmal nach dem ersten Speichern (zeitenLeer wechselt), nie sonst.
Der Editor-ctx t.ctx der Abschnitte (aus Aufgabe 29; Aufgabe 30 haengt ihre Abschnitte an ABSCHNITTE und die Live-Vorschau in den Kopf):
Der Editor-ctx t.ctx der Abschnitte:
{ projectId, kundenansicht, nurLesen, stand, entwurfsplan, offen, hinweisFrage, aktuell(), neuLaden(), meldung(text, art), uebernimm(antwort, { erstes }),
leereDaten(), leer() }
beiStand(fn), leereDaten(), leer() }
Die sechs Abschnitte (ABSCHNITTE): Zeitplaene, Feiertage, Ausnahmen (zeiten-plaene.js, zeiten-ausnahmen.js), Hinweis-Leiste, Kontakt-Leiste (zeiten-leisten.js) und
Google (zeiten-google.js). Oben im Kopfbereich steht die Live-Vorschau „So steht es gerade auf deiner Seite: …“ (Status aus GET /api/zeiten bzw. der Antwort des
letzten Speicherns) mit dem Regler „Uhrzeit testen“ (Wochentag und Uhrzeit, GET /api/zeiten/<projectId>/vorschau?zeit=JJJJ-MM-TTTHH:MM, dieselbe Serverlogik; vor dem
ersten Speichern antwortet der Server mit 409 keine_zeiten: das ist ein Hinweis, kein Fehler). Abgeschaltet (nur lesen) gibt es keine Vorschau: die Website zeigt dann nichts mehr.
ctx.beiStand(fn): fn() laeuft nach jedem Speichern, wenn ctx.stand neu ist (Vorschau, Google-Text); gibt fn false zurueck, wird sie nicht mehr gerufen (Abschnitt weg).
stand = Antwort des Servers { state, daten (null vor dem ersten Speichern), version, kundenansicht, vorschau, feiertage, googleText }; sie wird bei jedem
Speichern und bei „Neu laden“ ersetzt. Ein Abschnitt ist eine Funktion (t) => Element mit t = { ctx, sp, zeichne, wurzel }.
t.zeichne() zeichnet alles neu (nur nach „Neu laden“, wenn die Daten vom Server neu sind); t.zeichne(['ausnahmen', …]) zeichnet nur die genannten Abschnitte
@@ -16,11 +21,13 @@
kontakt.whatsapp.bestaetigtAt (R12, das tut Aufgabe 30 mit dem Haken des Kunden). */
import { fehlerKarte, h, request } from './termine-dom.js'
import { istTag } from './termine-view.js'
import { leererPlan } from './zeiten-view.js'
import { WOCHENTAGE_PLAN, datumLang, heuteBerlin, istTag } from './termine-view.js'
import { leererPlan, naechstesVorkommen, wochentagVon } from './zeiten-view.js'
import { neuerZeitenSpeicher } from './zeiten-speichern.js'
import { plaene } from './zeiten-plaene.js'
import { ausnahmen, feiertage, feiertageUebernehmen } from './zeiten-ausnahmen.js'
import { hinweise, kontakt } from './zeiten-leisten.js'
import { google } from './zeiten-google.js'
/**
* Abschnitte in der Reihenfolge der Seite (Spec 3.4). mitDaten: erst nach dem ersten Speichern. uebernehmen(alt, neu): wird gerufen, wenn der Abschnitt allein
@@ -30,6 +37,9 @@ const ABSCHNITTE = [
{ key: 'plaene', bauen: plaene, mitDaten: false },
{ key: 'feiertage', bauen: feiertage, mitDaten: true, uebernehmen: feiertageUebernehmen },
{ key: 'ausnahmen', bauen: ausnahmen, mitDaten: true },
{ key: 'hinweise', bauen: hinweise, mitDaten: true },
{ key: 'kontakt', bauen: kontakt, mitDaten: true },
{ key: 'google', bauen: google, mitDaten: true },
]
const NICHT_GELADEN = 'Deine Öffnungszeiten konnten gerade nicht geladen werden. Bitte versuch es noch einmal.'
@@ -38,6 +48,10 @@ const KUNDENANSICHT = 'Kundenansicht: Was du hier speicherst, ändert die Öffnu
const LEER = 'Trag deine Öffnungszeiten ein. Sobald du speicherst, kennt deine Website sie.'
const SPAETER = 'Feiertage, Ausnahmen und alles Weitere kannst du eintragen, sobald deine Öffnungszeiten gespeichert sind.'
const VORSCHAU_LEER = 'Noch keine Öffnungszeiten gespeichert.'
const VORSCHAU_ERST_SPEICHERN = 'Speichere zuerst deine Öffnungszeiten, dann kannst du Uhrzeiten testen.'
const VORSCHAU_KEINE_ANZEIGE = 'Dazu gibt es gerade keine Anzeige.'
// Welcher Aufruf von renderEditor gehoert zu einem Platz? Ein neuer Aufruf macht den alten ungueltig (seine Antworten fassen nichts mehr an).
const LAEUFE = new WeakMap()
@@ -58,6 +72,78 @@ function standAus(antwort) {
}
}
/** Berliner Uhrzeit 'HH:MM' (Vorgabe des Reglers). */
const berlinUhrzeit = (jetzt = new Date()) => jetzt.toLocaleTimeString('de-DE', { timeZone: 'Europe/Berlin', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' })
/**
* Live-Vorschau oben im Editor: der Status, wie ihn die Website jetzt zeigt (ec.stand.vorschau = { offen, text, kurz }), und der Regler „Uhrzeit testen“. Der
* Regler fragt den Server mit dem naechsten Vorkommen des gewaehlten Wochentags (ab heute, Berliner Kalendertag) und der Uhrzeit (Berliner Wanduhr); die Logik ist
* die der Website. Nur die letzte Anfrage zaehlt. Nach jedem Speichern (ec.beiStand) zeigt die Vorschau den neuen Status, und eine schon gestellte Frage wird mit
* den neuen Daten wiederholt.
*/
function vorschauKarte(ec) {
const jetzt = h('strong', { class: 'zt-vorschau-text', 'data-vorschau': 'jetzt' })
const zeigeJetzt = () => {
const v = ec.stand.vorschau
const gueltig = v !== null && typeof v === 'object' && typeof v.text === 'string' && v.text !== ''
jetzt.textContent = gueltig ? v.text : VORSCHAU_LEER
jetzt.setAttribute('data-offen', gueltig ? String(v.offen === true || v.offen === false ? v.offen : null) : 'leer')
}
zeigeJetzt()
const heute = heuteBerlin()
const tag = h('select', { id: 'zt-regler-tag', 'data-regler': 'tag' }, WOCHENTAGE_PLAN.map(([nr, name]) => h('option', { value: nr, selected: Number(nr) === wochentagVon(heute) }, name)))
const zeit = h('input', { id: 'zt-regler-zeit', type: 'time', 'data-regler': 'zeit', value: berlinUhrzeit() })
const ergebnis = h('p', { class: 'tm-hinweis zt-regler-ergebnis', 'data-vorschau': 'test', 'aria-live': 'polite' })
const karte = h('section', { class: 'kb-panel zt-vorschau', 'aria-label': 'Vorschau deiner Öffnungszeiten' },
h('p', { class: 'zt-vorschau-zeile' }, 'So steht es gerade auf deiner Seite: ', jetzt),
h('fieldset', { class: 'zt-regler' },
h('legend', null, 'Uhrzeit testen'),
h('div', { class: 'zt-regler-felder' },
h('div', { class: 'tm-feld' }, h('label', { for: 'zt-regler-tag' }, 'Wochentag'), tag),
h('div', { class: 'tm-feld' }, h('label', { for: 'zt-regler-zeit' }, 'Uhrzeit'), zeit)),
ergebnis))
let lauf = 0
let benutzt = false
async function frage() {
const wann = zeit.value
const nr = ++lauf
if (!/^\d{2}:\d{2}$/.test(wann)) {
ergebnis.textContent = ''
ergebnis.removeAttribute('role')
return
}
benutzt = true
const datum = naechstesVorkommen(heuteBerlin(), Number(tag.value))
const ort = () => nr === lauf && karte.isConnected && ec.aktuell() // eine veraltete Antwort oder ein anderer Editor zaehlt nicht
try {
const antwort = await request(`/api/zeiten/${encodeURIComponent(ec.projectId)}/vorschau?zeit=${encodeURIComponent(`${datum}T${wann}`)}`)
if (!ort()) return
ergebnis.removeAttribute('role')
ergebnis.textContent = typeof antwort?.text === 'string' && antwort.text !== '' ? `${datumLang(datum)}, ${wann} Uhr: ${antwort.text}` : VORSCHAU_KEINE_ANZEIGE
} catch (err) {
if (!ort()) return
if (err.code === 'keine_zeiten') {
ergebnis.removeAttribute('role') // vor dem ersten Speichern gibt es nichts zu testen: ein Hinweis, kein Fehler
ergebnis.textContent = VORSCHAU_ERST_SPEICHERN
return
}
ergebnis.setAttribute('role', 'alert')
ergebnis.textContent = err.message
}
}
tag.addEventListener('change', frage)
zeit.addEventListener('change', frage)
ec.beiStand(() => {
if (!karte.isConnected) return false
zeigeJetzt()
if (benutzt) frage()
return true
})
return karte
}
export async function renderEditor(root, ctx = {}) {
const token = {}
LAEUFE.set(root, token)
@@ -78,6 +164,7 @@ export async function renderEditor(root, ctx = {}) {
const nurLesen = ctx.nurLesen === true || root.dataset.state === 'disabled'
const meldungEl = h('div', { class: 'tm-meldung', 'data-meldung': 'editor', role: 'status', 'aria-live': 'polite', tabindex: '-1' })
const entwurfsplan = leererPlan({ name: 'Öffnungszeiten', kurz: 'oeffnungszeiten', haupt: true })
const beobachter = new Set() // Funktionen, die nach jedem Speichern laufen (beiStand)
const ec = {
projectId: ctx.projectId,
kundenansicht: ctx.kundenansicht === true,
@@ -87,6 +174,7 @@ export async function renderEditor(root, ctx = {}) {
offen: new Set([entwurfsplan.id, ...(stand.daten ? stand.daten.plaene.filter((p) => p?.haupt === true).map((p) => p.id) : [])]), // aufgeklappte Plaene
hinweisFrage: null, // { vorschlag } nach einem Urlaub ab 2 Tagen
aktuell,
beiStand: (fn) => { beobachter.add(fn) },
leer: () => ec.stand.daten === null,
/** Daten fuer das erste Speichern: der leere Hauptplan; die Kontaktdaten setzt der Server auf seine Vorgabe. */
leereDaten: () => ({ land: 'RP', plaene: [ec.entwurfsplan], feiertage: {}, ausnahmen: [], hinweise: [] }),
@@ -109,6 +197,9 @@ export async function renderEditor(root, ctx = {}) {
/** Antwort auf ein erfolgreiches Speichern. Das erste Speichern aendert, was die Huelle zeigt (zeitenLeer): sie zeichnet ihren Kopf neu. */
uebernimm(antwort, { erstes = false } = {}) {
ec.stand = standAus(antwort)
for (const fn of [...beobachter]) {
if (fn() === false) beobachter.delete(fn)
}
if (erstes && typeof ctx.neuZeichnen === 'function') {
try {
Promise.resolve(ctx.neuZeichnen()).catch(() => {}) // scheitert das, zeigt der Kopf den alten Stand bis zum naechsten Besuch
@@ -150,7 +241,7 @@ export async function renderEditor(root, ctx = {}) {
if (ec.kundenansicht || ec.stand.kundenansicht) teile.push(h('div', { class: 'tm-box tm-box-info', role: 'note', 'data-hinweis': 'kundenansicht' }, h('span', null, KUNDENANSICHT)))
if (nurLesen) teile.push(h('div', { class: 'tm-box tm-box-warn', role: 'note', 'data-hinweis': 'nurlesen' }, h('span', null, NUR_LESEN)))
else if (leer) teile.push(h('div', { class: 'tm-box tm-box-info', role: 'note', 'data-hinweis': 'leer' }, h('span', null, LEER)))
teile.push(h('div', { class: 'zt-kopfbereich' })) // Platz der Live-Vorschau (Aufgabe 30)
teile.push(h('div', { class: 'zt-kopfbereich' }, nurLesen ? null : vorschauKarte(ec))) // Live-Vorschau; abgeschaltet zeigt die Website nichts mehr
for (const abschnitt of ABSCHNITTE) {
if (abschnitt.mitDaten && leer) continue
teile.push(baueAbschnitt(abschnitt))
+66
View File
@@ -0,0 +1,66 @@
/* Oeffnungszeiten live im Kundenbereich: Editor, Bereich 6 „Google“ (Spec 3.4). google(t) ist ein Abschnitt des Editors (t = { ctx, sp, zeichne, wurzel }
aus zeiten-editor.js). Der Text fuer das Google-Profil kommt vom Server (ctx.stand.googleText aus GET/PUT /api/zeiten: Montag bis Sonntag, danach die
Sonderzeiten der naechsten 60 Tage) und wird hier nie selbst gebaut. „Für Google kopieren“ legt ihn in die Zwischenablage; fehlt die Schnittstelle oder
lehnt der Browser ab, bleibt der Text im (schreibgeschuetzten) Textfeld markiert und eine Meldung sagt, wie der Kunde ihn selbst kopiert. Nach jedem Speichern
folgt die Anzeige dem neuen Text (ctx.beiStand). Das Google-Profil selbst oeffnet der Link in einem neuen Fenster (noopener); wir schreiben dort nichts. */
import { abschnitt, h } from './termine-dom.js'
const GOOGLE_PROFIL = 'https://business.google.com/'
const KOPIERT = 'Kopiert. Füge den Text jetzt in dein Google-Profil ein.'
const KEIN_TEXT = 'Es gibt noch keinen Text zum Kopieren. Speichere zuerst deine Öffnungszeiten.'
const NICHT_KOPIERT = 'Das Kopieren hat nicht geklappt. Der Text ist unten markiert: Kopiere ihn selbst (Strg+C, am Handy: lange drücken und „Kopieren“ wählen).'
export function google(t) {
const { ctx } = t
const text = () => (typeof ctx.stand.googleText === 'string' ? ctx.stand.googleText : '')
const feld = h('textarea', { class: 'zt-google-text', readonly: true, 'aria-label': 'Öffnungszeiten für dein Google-Profil', 'data-google': 'text' })
const meldung = h('p', { class: 'tm-hinweis zt-google-meldung', role: 'status', 'aria-live': 'polite', 'data-meldung': 'google' })
const knopf = h('button', { type: 'button', class: 'kb-btn-primary', 'data-aktion': 'google-kopieren' }, 'Für Google kopieren')
const zeige = () => {
feld.value = text()
feld.setAttribute('rows', String(Math.min(Math.max(text().split('\n').length + 1, 4), 16)))
}
zeige()
knopf.addEventListener('click', async () => {
const inhalt = text() // der Stand zum Zeitpunkt des Klicks, nicht der beim Zeichnen
if (inhalt === '') {
meldung.textContent = KEIN_TEXT
return
}
let kopiert = false
try {
const zwischenablage = typeof navigator !== 'undefined' ? navigator.clipboard : undefined
if (zwischenablage && typeof zwischenablage.writeText === 'function') {
await zwischenablage.writeText(inhalt)
kopiert = true
}
} catch {
kopiert = false // nicht erlaubt: weiter mit dem markierten Text
}
if (kopiert) {
meldung.textContent = KOPIERT
return
}
feld.focus()
feld.select()
if (typeof feld.setSelectionRange === 'function') feld.setSelectionRange(0, feld.value.length) // iOS markiert sonst nichts
meldung.textContent = NICHT_KOPIERT
})
const el = abschnitt('Google',
'Die Google-Daten im Seitencode laufen automatisch – dafür musst du nichts tun. Dein Google-Unternehmensprofil pflegst du selbst: Kopiere hier den Text mit deinen Öffnungszeiten (auch die Sonderzeiten der nächsten 60 Tage) und füge ihn dort ein.',
feld,
h('div', { class: 'zt-google-aktionen' },
knopf,
h('a', { class: 'kb-btn-outline', href: GOOGLE_PROFIL, target: '_blank', rel: 'noopener noreferrer', 'data-google': 'profil' }, 'Google-Profil öffnen')),
meldung)
ctx.beiStand(() => {
if (!el.isConnected) return false // der Abschnitt wurde neu gezeichnet: diese Anzeige gibt es nicht mehr
zeige()
return true
})
return el
}
+312
View File
@@ -0,0 +1,312 @@
/* Oeffnungszeiten live im Kundenbereich: Editor, Bereiche 4 „Hinweis-Leiste“ und 5 „Kontakt-Leiste (Handy)“ (Spec 3.4). Jeder Bereich ist eine Funktion
(t) => Element; t = { ctx, sp, zeichne, wurzel } kommt aus zeiten-editor.js (sp = Speicher des Editors, zeichne([…]) zeichnet einzelne Abschnitte neu).
Jede Aenderung speichert die ganzen Daten (zeiten-speichern.js); nach einem Speichern wird nur der eigene Abschnitt neu gezeichnet, ungespeicherte Eingaben
in anderen Formularen bleiben stehen. Abgeschaltete Funktion (ctx.nurLesen): dieselben Felder gesperrt, ohne Knoepfe zum Aendern. Stil: zeiten-ausnahmen.js.
Hinweis-Leiste: Liste der laufenden und geplanten Hinweise (Vergangenes eingeklappt), „Hinweis-Leiste anlegen“ (dieser Text ist fest: die Relaunch-Station
tippt den Knopf an) und je Hinweis Text (<= 140), Art (Info, Urlaub, Aktion, Wichtig mit festen Farben wie auf der Website), optionaler Knopf (Link
https oder Anrufen) und Zeitraum von–bis (Kalendertage, beide optional; „Aktion“ verlangt ein Enddatum). Der Browser prueft frueh (Aktion ohne Ende, Ende vor
Beginn, leerer Text) und zeigt bei Link und Rufnummer nur einen Hinweis (linkOk): entschieden wird auf dem Server, sein Fehler steht am Feld.
Kontakt-Leiste: Ein/Aus, Telefon, Adresse, WhatsApp (Nummer, Starttext, Haken „Datenschutzerklaerung“). Der Server speichert Rufnummern als Ziffern mit
Laendervorwahl; der Editor zeigt die gespeicherte Nummer aus der Antwort (+49 …) im Feld und in der Meldung, damit der Kunde sieht, was gilt.
R12: den Zeitpunkt der WhatsApp-Bestaetigung (kontakt.whatsapp.bestaetigtAt) setzt nur der Kunde selbst. In der Kundenansicht ist der Haken gesperrt und der
gespeicherte Wert geht unveraendert zurueck (ohne gespeicherten kein Speichern mit WhatsApp); beim Kunden geht ein frischer Zeitpunkt nur hinaus, wenn der Haken
neu gesetzt wurde (der Server ersetzt ihn durch seine Zeit), sonst der gespeicherte Wert. */
import { datumKurz, heuteBerlin, istTag } from './termine-view.js'
import { abschnitt, allgemeinerFehler, editorZeile, feld, gesperrt, h, hakenFeld, neuerEintrag, zeigeFehler } from './termine-dom.js'
import { HINWEIS_ARTEN, ZEITEN_LIMITS, ZEITEN_MAX, linkOk, neueZeitenId, preisHinweisNoetig } from './zeiten-view.js'
import { entfernenKnopf, nachSpeichern, stelleVon } from './zeiten-speichern.js'
const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert)
/* ----------------------------------------------------------- Hinweis-Leiste --- */
const ART_NAME = Object.freeze({ info: 'Info', urlaub: 'Urlaub', aktion: 'Aktion', wichtig: 'Wichtig' })
const KNOPF_WAHL = Object.freeze([['', 'Kein Knopf'], ['link', 'Link'], ['anruf', 'Anrufen']])
const PREIS_HINWEIS = 'Bei Preisangaben gelten Endpreise. Bei Waren nennst du dazu den niedrigsten Preis der letzten 30 Tage.'
const LINK_HINWEIS = 'Bitte gib eine vollständige Adresse mit https:// an.'
const ANRUF_HINWEIS = 'Bitte nur Ziffern, Leerzeichen, + / und - (höchstens 30 Zeichen).'
const KNOPF_ARTEN = Object.freeze(['link', 'anruf'])
/** Rufnummer eines Anruf-Knopfs wie auf dem Server: nur Ziffern, Leerzeichen, / und -, ein + nur vorn, mindestens eine Ziffer, hoechstens 30 Zeichen. */
const anrufOk = (ziel) => {
const s = typeof ziel === 'string' ? ziel.trim() : ''
return s !== '' && [...s].length <= ZEITEN_MAX.telefon && /^\+?[0-9 /-]+$/.test(s) && /\d/.test(s)
}
/** 'JJJJ-MM-TT' oder '' (fehlt, null oder kein Kalendertag). */
const tagOder = (wert) => (typeof wert === 'string' && istTag(wert) ? wert : '')
/** „12.10.2026 – 26.10.2026“, „ab …“, „bis …“ oder „immer sichtbar“. */
function zeitraumText(x) {
const von = tagOder(x.von)
const bis = tagOder(x.bis)
if (von && bis) return von === bis ? datumKurz(von) : `${datumKurz(von)} – ${datumKurz(bis)}`
if (von) return `ab ${datumKurz(von)}`
if (bis) return `bis ${datumKurz(bis)}`
return 'immer sichtbar'
}
const nachBeginn = (a, b) => (tagOder(a.von) < tagOder(b.von) ? -1 : tagOder(a.von) > tagOder(b.von) ? 1 : 0)
export function hinweise(t) {
const { ctx } = t
const alle = (Array.isArray(ctx.stand.daten.hinweise) ? ctx.stand.daten.hinweise : []).filter((x) => istObjekt(x) && typeof x.id === 'string')
const heute = heuteBerlin()
const vorbei = (x) => tagOder(x.bis) !== '' && x.bis < heute
const kommend = alle.filter((x) => !vorbei(x)).sort(nachBeginn)
const alt = alle.filter(vorbei).sort(nachBeginn).reverse()
const voll = alle.length >= ZEITEN_LIMITS.hinweise
const neu = neuerEintrag({
nurLesen: ctx.nurLesen, text: 'Hinweis-Leiste anlegen', werkzeug: 'hinweis-neu', primaer: true, voll, grenzText: `Mehr als ${ZEITEN_LIMITS.hinweise} Hinweise sind nicht möglich.`,
baue: (schliessen) => formular(t, { a: null, schliessen }),
})
const reihe = (x) => {
const art = HINWEIS_ARTEN.includes(x.art) ? x.art : 'info'
const knopf = istObjekt(x.knopf) && KNOPF_ARTEN.includes(x.knopf.art) ? x.knopf : null
const von = tagOder(x.von)
const zustand = vorbei(x) ? ['vorbei', 'off'] : von && von > heute ? ['geplant', 'wait'] : ['läuft', 'ok']
const text = typeof x.text === 'string' ? x.text : ''
return editorZeile({
attrs: { 'data-id': x.id },
nurLesen: ctx.nurLesen,
label: `Hinweis ${text}`,
haupt: [
h('strong', { class: 'tm-name' }, text),
h('span', { class: 'tm-meta' },
h('span', { class: `zt-art zt-art-${art}` }, ART_NAME[art]),
` · ${zeitraumText(x)}${knopf ? ` · Knopf „${typeof knopf.text === 'string' ? knopf.text : ''}“ (${knopf.art === 'anruf' ? 'Anrufen' : 'Link'})` : ''}`),
],
pille: h('span', { class: `tm-pille tm-pille-${zustand[1]}` }, zustand[0]),
baue: (schliessen) => formular(t, { a: x, schliessen }),
})
}
return abschnitt('Hinweis-Leiste',
'Eine Leiste ganz oben auf deiner Website – zum Beispiel für Betriebsurlaub, eine Aktion oder etwas Wichtiges. Die Leiste erscheint nur in dem Zeitraum, den du einstellst. Gelten mehrere gleichzeitig, zeigt die Website nur einen: zuerst „Wichtig“, dann „Urlaub“, „Aktion“ und „Info“.',
h('div', { class: 'zt-werkzeuge' }, neu.leiste),
neu.box,
kommend.length
? h('ul', { class: 'tm-eintraege', 'aria-label': 'Laufende und geplante Hinweise' }, kommend.map(reihe))
: h('p', { class: 'tm-leer' }, 'Keine Hinweise eingetragen.'),
alt.length
? h('details', { class: 'tm-details' }, h('summary', null, `Vergangene Hinweise (${alt.length})`), h('ul', { class: 'tm-eintraege' }, alt.map(reihe)))
: null)
}
/**
* Formular eines neuen (a null) oder bestehenden Hinweises. Die Felder heissen nach dem Pfad der Fehler des Servers (hinweise.<ID>.text, .art, .knopf.art,
* .knopf.text, .knopf.ziel, .von, .bis); Absenden sucht den Hinweis per ID.
*/
function formular(t, { a, schliessen }) {
const { ctx, sp } = t
const neu = a === null
const alt = a || { id: neueZeitenId('h'), art: 'info', text: '', knopf: null, von: null, bis: null }
const p = `hinweise.${alt.id}`
const knopfAlt = istObjekt(alt.knopf) && KNOPF_ARTEN.includes(alt.knopf.art) ? alt.knopf : null
const text = h('input', { type: 'text', maxlength: ZEITEN_MAX.hinweisText, autocomplete: 'off', placeholder: 'z. B. Heute geschlossen – morgen wieder für dich da', value: typeof alt.text === 'string' ? alt.text : '' })
const art = h('select', null, HINWEIS_ARTEN.map((k) => h('option', { value: k, selected: k === (HINWEIS_ARTEN.includes(alt.art) ? alt.art : 'info') }, ART_NAME[k])))
const knopfArt = h('select', null, KNOPF_WAHL.map(([wert, name]) => h('option', { value: wert, selected: wert === (knopfAlt?.art ?? '') }, name)))
const knopfText = h('input', { type: 'text', maxlength: ZEITEN_MAX.knopfText, autocomplete: 'off', placeholder: 'z. B. Mehr erfahren', value: typeof knopfAlt?.text === 'string' ? knopfAlt.text : '' })
const knopfZiel = h('input', { type: 'url', maxlength: ZEITEN_MAX.link, autocomplete: 'off', value: typeof knopfAlt?.ziel === 'string' ? knopfAlt.ziel : '' })
const von = h('input', { type: 'date', value: tagOder(alt.von) })
const bis = h('input', { type: 'date', value: tagOder(alt.bis) })
const preisHinweis = h('p', { class: 'tm-hinweis', 'data-hinweis': 'preis', hidden: true }, PREIS_HINWEIS)
const zielHinweis = h('p', { class: 'tm-hinweis', 'data-hinweis': 'knopf-ziel', hidden: true }, LINK_HINWEIS)
const zielFeld = feld(`${p}.knopf.ziel`, 'Link', knopfZiel)
zielFeld.insertBefore(zielHinweis, zielFeld.querySelector('[data-fehler]'))
const textFeld = feld(`${p}.text`, 'Text', text, `Höchstens ${ZEITEN_MAX.hinweisText} Zeichen.`)
textFeld.insertBefore(preisHinweis, textFeld.querySelector('[data-fehler]'))
const knopfFelder = h('div', { class: 'tm-raster zt-knopf-felder', 'data-knopf-felder': '' },
feld(`${p}.knopf.text`, 'Beschriftung des Knopfs', knopfText, `Höchstens ${ZEITEN_MAX.knopfText} Zeichen.`),
zielFeld)
// Muster: so sieht die Leiste in der Farbe der Art aus (feste Farben wie auf der Website)
const musterText = h('span', { class: 'zt-muster-text' })
const musterKnopf = h('span', { class: 'zt-muster-knopf' })
const muster = h('div', { class: 'zt-muster', 'aria-hidden': 'true', 'data-muster': '' }, musterText, musterKnopf)
const aktualisiere = () => {
preisHinweis.hidden = !preisHinweisNoetig(text.value)
const wahl = knopfArt.value
knopfFelder.hidden = wahl === ''
knopfZiel.type = wahl === 'anruf' ? 'tel' : 'url'
knopfZiel.placeholder = wahl === 'anruf' ? 'z. B. 06301 123456' : 'https://'
zielFeld.querySelector('label').textContent = wahl === 'anruf' ? 'Telefonnummer' : 'Link'
const ziel = knopfZiel.value.trim()
zielHinweis.textContent = wahl === 'anruf' ? ANRUF_HINWEIS : LINK_HINWEIS
zielHinweis.hidden = wahl === '' || ziel === '' || (wahl === 'anruf' ? anrufOk(ziel) : linkOk(ziel))
muster.className = `zt-muster zt-art-${art.value}`
musterText.textContent = text.value.trim() || 'Dein Hinweistext'
musterKnopf.textContent = knopfText.value.trim()
musterKnopf.hidden = wahl === '' || knopfText.value.trim() === ''
}
for (const steuer of [text, art, knopfArt, knopfText, knopfZiel]) {
steuer.addEventListener('input', aktualisiere)
steuer.addEventListener('change', aktualisiere)
}
aktualisiere()
const entfernen = neu || ctx.nurLesen ? null : entfernenKnopf(sp, {
titel: 'Hinweis entfernen?',
text: 'Die Leiste verschwindet von deiner Website.',
aendere: (entwurf) => { entwurf.hinweise = (Array.isArray(entwurf.hinweise) ? entwurf.hinweise : []).filter((x) => x?.id !== alt.id) },
nachher: () => nachSpeichern(t, 'Hinweis entfernt.', '[data-werkzeug="hinweis-neu"]', () => t.zeichne(['hinweise'])),
})
const speichern = h('button', { type: 'submit', class: 'kb-btn-primary', 'data-aktion': 'hinweis-speichern' }, neu ? 'Hinweis speichern' : 'Speichern')
const form = h('form', { class: 'kb-panel tm-formular zt-hinweis-form', novalidate: true, 'aria-label': neu ? 'Neuer Hinweis' : 'Hinweis bearbeiten' },
h('h3', null, neu ? 'Neuer Hinweis' : 'Hinweis'),
gesperrt(ctx.nurLesen, [
textFeld,
feld(`${p}.art`, 'Art', art, 'Jede Art hat ihre feste Farbe.'),
muster,
feld(`${p}.knopf.art`, 'Knopf (optional)', knopfArt, 'Ein Knopf in der Leiste: ein Link auf eine Seite oder ein Anruf.'),
knopfFelder,
h('div', { class: 'tm-raster' },
feld(`${p}.von`, 'Sichtbar ab (optional)', von),
feld(`${p}.bis`, 'Sichtbar bis (bei einer Aktion nötig)', bis)),
h('p', { class: 'tm-hinweis' }, 'Ohne Datum gilt der Hinweis immer. Bei einer Aktion brauchst du ein Enddatum.'),
]),
allgemeinerFehler(),
h('div', { class: 'tm-frage-knoepfe' },
ctx.nurLesen ? null : speichern,
h('button', { type: 'button', class: 'kb-btn-outline', onclick: schliessen }, ctx.nurLesen ? 'Schließen' : 'Abbrechen'),
entfernen?.knopf))
form.addEventListener('submit', async (ev) => {
ev.preventDefault()
const ok = await sp.speichere((entwurf) => {
const fehler = {}
const vonWert = von.value.trim()
const bisWert = bis.value.trim()
const hinweisText = text.value.trim()
if (hinweisText === '') fehler[`${p}.text`] = 'Bitte gib einen Text ein.'
if (vonWert !== '' && !istTag(vonWert)) fehler[`${p}.von`] = 'Bitte gib ein gültiges Datum an.'
if (bisWert !== '' && !istTag(bisWert)) fehler[`${p}.bis`] = 'Bitte gib ein gültiges Datum an.'
if (!fehler[`${p}.von`] && !fehler[`${p}.bis`]) {
if (vonWert !== '' && bisWert !== '' && bisWert < vonWert) fehler[`${p}.bis`] = 'Das Ende darf nicht vor dem Beginn liegen.'
else if (art.value === 'aktion' && bisWert === '') fehler[`${p}.bis`] = 'Eine Aktion braucht ein Enddatum.'
}
if (Object.keys(fehler).length) {
zeigeFehler(form, { fehler })
return false
}
// ein Knopf ohne Beschriftung und Ziel ist kein Knopf (wie auf dem Server)
const ziel = knopfZiel.value.trim()
const beschriftung = knopfText.value.trim()
const knopf = knopfArt.value !== '' && (ziel !== '' || beschriftung !== '') ? { art: knopfArt.value, ziel, text: beschriftung } : null
const eintrag = { id: alt.id, art: art.value, text: hinweisText, knopf, von: vonWert || null, bis: bisWert || null }
if (neu) {
entwurf.hinweise = [...(Array.isArray(entwurf.hinweise) ? entwurf.hinweise : []), eintrag]
return true
}
const stelle = stelleVon(entwurf.hinweise, alt.id, form, 'Dieser Hinweis')
if (stelle < 0) return false
entwurf.hinweise[stelle] = eintrag
return true
}, { container: form })
if (!ok || !sp.nochHier()) return
nachSpeichern(t, 'Hinweis gespeichert.', neu ? '[data-werkzeug="hinweis-neu"]' : `li[data-id="${CSS.escape(alt.id)}"] [data-aktion="bearbeiten"]`, () => t.zeichne(['hinweise']))
})
return h('div', { class: 'tm-editor' }, form, entfernen?.box)
}
/* ------------------------------------------------------------ Kontakt-Leiste --- */
const WHATSAPP_HAKEN = 'Ich habe den WhatsApp-Hinweis in meine Datenschutzerklärung aufgenommen'
const HAKEN_FEHLT = 'Bitte bestätige, dass du den WhatsApp-Hinweis in deine Datenschutzerklärung aufgenommen hast.'
const KUNDE_BESTAETIGT = 'Der Kunde muss den WhatsApp-Hinweis selbst bestätigen – in der Kundenansicht geht das nicht.'
/** Gespeicherte Rufnummer (Ziffern mit Laendervorwahl, z. B. 491711234567) als „+49 1711234567“; alles andere bleibt, wie es gespeichert ist. */
export function nummerAnzeige(wert) {
const s = typeof wert === 'string' ? wert.trim() : ''
if (!/^\d{6,15}$/.test(s)) return s
return s.startsWith('49') ? `+49 ${s.slice(2)}` : `+${s}`
}
export function kontakt(t) {
const { ctx, sp } = t
const nurLesen = ctx.nurLesen
const kundenansicht = ctx.kundenansicht === true || ctx.stand.kundenansicht === true
const k = istObjekt(ctx.stand.daten.kontakt) ? ctx.stand.daten.kontakt : {}
const wa = istObjekt(k.whatsapp) ? k.whatsapp : null
const gespeichertAt = typeof wa?.bestaetigtAt === 'string' ? wa.bestaetigtAt : ''
const telefonAlt = typeof k.telefon === 'string' ? k.telefon : ''
const nummerAlt = typeof wa?.nummer === 'string' ? wa.nummer : ''
const telefonAnzeige = nummerAnzeige(telefonAlt)
const nummerAnz = nummerAnzeige(nummerAlt)
const an = h('input', { type: 'checkbox', checked: k.an === true })
const telefon = h('input', { type: 'tel', maxlength: ZEITEN_MAX.telefon, autocomplete: 'off', placeholder: 'z. B. 06301 123456', value: telefonAnzeige })
const adresse = h('input', { type: 'text', maxlength: ZEITEN_MAX.adresse, autocomplete: 'off', placeholder: 'z. B. Hauptstraße 1, 67722 Winnweiler', value: typeof k.adresse === 'string' ? k.adresse : '' })
const waAn = h('input', { type: 'checkbox', checked: wa !== null })
const nummer = h('input', { type: 'tel', maxlength: ZEITEN_MAX.telefon, autocomplete: 'off', placeholder: 'z. B. 0171 1234567', value: nummerAnz })
const starttext = h('textarea', { rows: 3, maxlength: ZEITEN_MAX.starttext, placeholder: 'z. B. Hallo, ich habe eine Frage.' })
starttext.value = typeof wa?.starttext === 'string' ? wa.starttext : ''
const bestaetigt = h('input', { type: 'checkbox', checked: gespeichertAt !== '', disabled: kundenansicht })
const waBlock = h('div', { class: 'zt-whatsapp', 'data-whatsapp': '', hidden: wa === null },
feld('kontakt.whatsapp.nummer', 'WhatsApp-Nummer', nummer, 'Mit Vorwahl. Wir speichern sie mit Ländervorwahl, zum Beispiel +49 …'),
feld('kontakt.whatsapp.starttext', 'Vorgeschriebener Text (optional)', starttext, `Dieser Text steht schon im Nachrichtenfeld, wenn jemand auf den WhatsApp-Knopf tippt. Höchstens ${ZEITEN_MAX.starttext} Zeichen.`),
hakenFeld('kontakt.whatsapp.bestaetigtAt', bestaetigt, WHATSAPP_HAKEN, 'Beim Tippen öffnet sich WhatsApp (ein Dienst von Meta). Das gehört in die Datenschutzerklärung deiner Website – frag uns im Chat, wenn du den passenden Absatz brauchst.'),
kundenansicht ? h('p', { class: 'tm-hinweis', 'data-hinweis': 'whatsapp-kundenansicht' }, 'Kundenansicht: Den Haken zur Datenschutzerklärung kann nur der Kunde selbst setzen.') : null)
waAn.addEventListener('change', () => { waBlock.hidden = !waAn.checked })
const speichern = h('button', { type: 'submit', class: 'kb-btn-primary', 'data-aktion': 'kontakt-speichern' }, 'Kontakt-Leiste speichern')
const form = h('form', { novalidate: true, 'aria-label': 'Kontakt-Leiste' },
gesperrt(nurLesen, [
hakenFeld('kontakt.an', an, 'Kontakt-Leiste auf dem Handy anzeigen', 'Eine Leiste unten auf deiner Website, nur auf dem Handy: Anrufen, Route und WhatsApp – je nachdem, was du einträgst.'),
feld('kontakt.telefon', 'Telefon', telefon, 'Für „Anrufen“. Wir speichern die Nummer mit Ländervorwahl (+49 für Deutschland).'),
feld('kontakt.adresse', 'Adresse für „Route“', adresse, `Höchstens ${ZEITEN_MAX.adresse} Zeichen.`),
hakenFeld('kontakt.whatsapp', waAn, 'WhatsApp-Knopf anbieten'),
waBlock,
]),
allgemeinerFehler(),
nurLesen ? null : h('div', { class: 'tm-frage-knoepfe' }, speichern))
form.addEventListener('submit', async (ev) => {
ev.preventDefault()
const ok = await sp.speichere((entwurf) => {
const fehler = {}
let whatsapp = null
if (waAn.checked) {
const nummerWert = nummer.value === nummerAnz ? nummerAlt : nummer.value.trim()
if (nummerWert === '') fehler['kontakt.whatsapp.nummer'] = 'Bitte gib die Nummer an.'
let bestaetigtAt
if (kundenansicht) {
// R12: im Namen des Kunden gibt es nie einen neuen Zeitpunkt; ein gespeicherter geht unveraendert zurueck
if (gespeichertAt !== '') bestaetigtAt = gespeichertAt
else fehler['kontakt.whatsapp.bestaetigtAt'] = KUNDE_BESTAETIGT
} else if (bestaetigt.checked) {
bestaetigtAt = gespeichertAt !== '' ? gespeichertAt : new Date().toISOString() // der Server ersetzt einen neuen Zeitpunkt durch seine Zeit
} else {
fehler['kontakt.whatsapp.bestaetigtAt'] = HAKEN_FEHLT
}
whatsapp = { nummer: nummerWert, starttext: starttext.value.trim(), bestaetigtAt }
}
if (Object.keys(fehler).length) {
zeigeFehler(form, { fehler })
return false
}
entwurf.kontakt = {
...(istObjekt(entwurf.kontakt) ? entwurf.kontakt : {}),
an: an.checked,
telefon: telefon.value === telefonAnzeige ? telefonAlt : telefon.value.trim(),
adresse: adresse.value.trim(),
whatsapp,
}
return true
}, { container: form })
if (!ok || !sp.nochHier()) return
// was der Server gespeichert hat, steht danach im Feld (nur dieser Abschnitt wird neu gezeichnet) und in der Meldung
const neu = istObjekt(ctx.stand.daten.kontakt) ? ctx.stand.daten.kontakt : {}
const nummern = [neu.telefon ? `Telefon ${nummerAnzeige(neu.telefon)}` : '', neu.whatsapp?.nummer ? `WhatsApp ${nummerAnzeige(neu.whatsapp.nummer)}` : ''].filter(Boolean)
nachSpeichern(t, `Kontakt-Leiste gespeichert.${nummern.length ? ` Gespeichert als ${nummern.join(', ')}.` : ''}`, 'form[aria-label="Kontakt-Leiste"] button[type="submit"]', () => t.zeichne(['kontakt']))
})
return abschnitt('Kontakt-Leiste (Handy)',
'Auf dem Handy erscheint unten auf deiner Website eine Leiste mit Knöpfen zum Anrufen, für die Route und für WhatsApp. Auf größeren Bildschirmen erscheint sie nicht.',
form)
}
+12
View File
@@ -209,6 +209,18 @@ export function planLoeschenGrund(daten, planId) {
return null
}
/* ---------------------------------------------------------- Wochentage --- */
/** Wochentag eines Kalendertags 'JJJJ-MM-TT': 1 = Montag … 7 = Sonntag. */
export function wochentagVon(tag) {
const nr = new Date(`${tag}T00:00:00Z`).getUTCDay() // 0 = Sonntag
return nr === 0 ? 7 : nr
}
/** Der erste Tag ab heute (heute eingeschlossen), der auf den Wochentag (1 = Montag … 7 = Sonntag) fällt: Tag für den Regler „Uhrzeit testen“. */
export function naechstesVorkommen(heute, wochentag) {
return tagPlus(heute, (wochentag - wochentagVon(heute) + 7) % 7)
}
/* ------------------------------------------------------ Hinweis-Leiste --- */
const VORLAUF_TAGE = 7
const tageZwischen = (von, bis) => Math.round((Date.parse(`${bis}T00:00:00Z`) - Date.parse(`${von}T00:00:00Z`)) / 86400000)
+40 -2
View File
@@ -1,4 +1,5 @@
/* Öffnungszeiten live im Kundenbereich (zeiten.js, zeiten-bestellen.js und der Editor: zeiten-editor.js, zeiten-plaene.js, zeiten-ausnahmen.js).
/* Öffnungszeiten live im Kundenbereich (zeiten.js, zeiten-bestellen.js und der Editor: zeiten-editor.js, zeiten-plaene.js, zeiten-ausnahmen.js,
zeiten-leisten.js, zeiten-google.js).
Farben und Abstände aus den Tokens von kundenbereich.css (.kb-page). Die Bausteine der Bestell- und Statusseiten (tm-bestellen, tm-statuskarte,
tm-rechtstext, tm-haken, tm-pille, tm-box, tm-meldung, tm-info …) stehen in termine.css und gelten hier mit; neu sind nur Klassen mit zt-
und die Regeln für #view-zeiten. Handy: bis 880px wie die App-Ansicht, Tippflächen mindestens 44px. */
@@ -66,11 +67,48 @@
.zt-tag > .tm-fehler { margin-top: 8px; }
.zt-feiertag .zt-tag { margin-top: 10px; padding: 0; border: 0; background: transparent; }
/* Editor II: Live-Vorschau mit Regler „Uhrzeit testen“, Hinweis-Leiste, Kontakt-Leiste, Google (zeiten-editor.js, zeiten-leisten.js, zeiten-google.js) */
.zt-editor [hidden] { display: none; }
.zt-vorschau { padding: 16px 18px; }
.zt-vorschau-zeile { margin: 0; font-size: 15px; line-height: 1.5; overflow-wrap: anywhere; }
.zt-vorschau-text { font-size: 17px; }
.zt-vorschau-text::before { content: ''; display: inline-block; width: 10px; height: 10px; margin-right: 8px; border-radius: 50%; background: var(--kb-muted); }
.zt-vorschau-text[data-offen='true']::before { background: #4ade80; }
.zt-vorschau-text[data-offen='false']::before { background: #f87171; }
.zt-vorschau-text[data-offen='null']::before { background: #fbbf24; }
.zt-regler { min-width: 0; margin: 14px 0 0; padding: 0; border: 0; }
.zt-regler legend { padding: 0; margin-bottom: 2px; font-size: 13px; font-weight: 600; }
.zt-regler-felder { display: flex; flex-wrap: wrap; gap: 0 14px; }
.zt-regler-felder .tm-feld { margin-top: 8px; }
.zt-regler-ergebnis { margin-top: 10px; font-size: 14px; line-height: 1.5; color: inherit; overflow-wrap: anywhere; }
.zt-regler-ergebnis:empty { display: none; }
.zt-regler-ergebnis[role='alert'] { color: #fecaca; }
.zt-regler .tm-feld select, .zt-regler .tm-feld input { min-height: 40px; }
.zt-art { display: inline-block; padding: 1px 10px; border-radius: 999px; font-size: 12px; font-weight: 700; }
.zt-art-info { background: #e0f2fe; color: #0c4a6e; }
.zt-art-aktion { background: #dcfce7; color: #14532d; }
.zt-art-urlaub { background: #fef3c7; color: #78350f; }
.zt-art-wichtig { background: #b91c1c; color: #ffffff; }
.zt-muster { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 6px 12px; margin: 14px 0 0; padding: 10px 14px; border-radius: 10px; font-size: 14px; line-height: 1.4; text-align: center; }
.zt-muster-text { overflow-wrap: anywhere; }
.zt-muster-knopf { padding: 3px 12px; border-radius: 999px; border: 1.5px solid currentColor; font-size: 13px; font-weight: 700; }
.zt-knopf-felder { margin-top: 0; }
.zt-hinweis-form .zt-tag { margin-top: 0; }
.zt-whatsapp { margin-top: 10px; padding: 0 14px 10px; border-radius: 12px; border: 1px solid var(--kb-border); background: rgba(255, 255, 255, 0.02); }
.zt-google-text {
display: block; width: 100%; box-sizing: border-box; padding: 10px 12px; border-radius: 12px; font: inherit; font-size: 14px; line-height: 1.6; resize: vertical; white-space: pre; overflow: auto;
border: 1px solid var(--kb-border-strong); background: rgba(255, 255, 255, 0.05); color: inherit;
}
.zt-google-aktionen { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-top: 12px; }
.zt-google-aktionen a { display: inline-flex; align-items: center; text-decoration: none; }
.zt-google-meldung { margin-top: 10px; font-size: 13.5px; line-height: 1.5; color: inherit; }
.zt-google-meldung:empty { display: none; }
@media (max-width: 880px), (pointer: coarse) {
#view-zeiten button, #view-zeiten .kb-btn-outline, #view-zeiten .kb-btn-primary { min-height: 44px; }
#view-zeiten .tm-haken { align-items: center; min-height: 44px; margin-top: 0; margin-bottom: 0; }
#view-zeiten .tm-haken input { margin-top: 0; }
#view-zeiten .zt-zeit input, #view-zeiten .zt-tag select, #view-zeiten .zt-feiertag select { min-height: 44px; }
#view-zeiten .zt-zeit input, #view-zeiten .zt-tag select, #view-zeiten .zt-feiertag select, #view-zeiten .zt-regler .tm-feld select, #view-zeiten .zt-regler .tm-feld input { min-height: 44px; }
}
@media (max-width: 600px) {
.zt-fenster-name { min-width: 100%; }