Files
Webklar-Kundenbereich/public/embed/zeiten.js
T
ClaudeandClaude Opus 5.5 aa9ee35ab6 Öffnungszeiten: Fixrunde Website-Skript und Editor (JSON-LD, Kopf beim Scrollen, späte Platzhalter, Links, Bestätigung)
- JSON-LD wird ohne "<" geschrieben (<), ein </script> im Firmennamen bricht keinen Block mehr
- verschiebe(): ist die Hinweis-Leiste weggescrollt, bekommen feste Elemente ihren eigenen Inline-top zurück
  (Köpfe, die per Klasse beim Scrollen wegfahren, funktionieren wieder)
- Späte Platzhalter (Einzelseiten-Apps): entprellter MutationObserver auf body zeichnet neue oder wieder
  eingehängte Platzhalter nach
- sichererLink lehnt Zugangsdaten in der Adresse ab; die Adresse der Schnittstelle bestimmt nur das eigene
  Script-Tag, kein Platzhalter mehr (der siteKey am Platzhalter bleibt laut Spec 4.2)
- Editor: die Bestätigung klebt unter der Kopfleiste und bleibt nach dem Speichern eines unteren Abschnitts sichtbar

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

1136 lines
55 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*! WEBklar Oeffnungszeiten live: Einbau-Skript fuer Kundenwebsites.
* <script src=".../embed/zeiten.js" data-site="SITEKEY" defer></script>
* data-api="ADRESSE" am Script-Tag ist optional (sonst die Herkunft des Skripts); den siteKey darf auch ein Platzhalter tragen
* (data-webklar-zeiten="SITEKEY").
*
* Platzhalter (vom Team gesetzt, das Ersatz-HTML darin bleibt ohne JavaScript, ohne Daten und bei Fehlern unveraendert stehen):
* <div data-webklar-zeiten data-modus="tabelle|status|badge" data-plan="KURZNAME">Ersatz-HTML</div>
* tabelle Wochentabelle (<table> mit Caption, Zeilenkoepfen und heutigem Tag mit aria-current="date") plus die Abweichungen der
* naechsten 14 Tage
* status Punkt und Text ("Jetzt geoeffnet · schliesst um 18 Uhr"), ohne aria-live
* badge Kurzform ("Geoeffnet", "Geschlossen", "Nach Vereinbarung")
* Ohne data-plan gilt der Hauptplan; ein unbekannter data-plan laesst das Ersatz-HTML stehen und schreibt eine Konsole-Warnung.
*
* Gestaltung: Light DOM, alle Klassen wkz-..., Stile in @layer wkz (jede Regel der Seite schlaegt sie), Schrift und Farben erbt die Seite.
* Variablen (alle optional, an den Platzhalter oder :root): --wkz-offen, --wkz-zu, --wkz-unklar (Punktfarben), --wkz-offen-hell,
* --wkz-zu-hell, --wkz-unklar-hell, --wkz-offen-text, --wkz-zu-text, --wkz-unklar-text (Badge), --wkz-rand, --wkz-heute.
*
* Leisten und JSON-LD (kommen mit den Daten, ohne Platzhalter; das Team kann sie je Seite abschalten):
* Hinweis-Leiste Shadow-DOM-Host [data-wkz-leiste="hinweis"] als erstes Kind von <body>, im Fluss (schiebt den Inhalt). Zeigt den
* gueltigen Hinweis mit dem hoechsten Rang (Info, Aktion, Urlaub, Wichtig); role="region" aria-label="Hinweis",
* kein Schliessen-Knopf. Feste und klebende Elemente mit top: 0 (und die des Team-Selektors navSelektor) rutschen per
* Inline-top um max(0, Leistenhoehe - Scrollposition) nach unten (beim Entfernen wie vorher), scroll-padding-top des
* Dokuments waechst um die Hoehe. Ein festes Element, das schon ueber der Leiste endet (die Seite hat seinen Platz per
* body-Polster freigehalten, die Leiste steht darunter), bleibt, wo es ist. Mit prefers-reduced-motion ohne Einblendung.
* Kontakt-Leiste nur bei (max-width: 768px): Shadow-DOM-Host [data-wkz-leiste="kontakt"] unten fest (z-index 40) mit Anrufen, Route
* und WhatsApp (nur, was angegeben ist), dazu ein Abstandhalter [data-wkz-abstand] am Ende von <body> und
* scroll-padding-bottom. Entfaellt, wenn die Seite selbst ein festes Element am unteren Rand mit tel:-Link hat.
* JSON-LD ein vorhandener application/ld+json-Block mit einem Unternehmen vor Ort (LocalBusiness oder Untertyp, auch in @graph)
* bekommt nur openingHoursSpecification und specialOpeningHoursSpecification neu; gibt es keinen, legt das Skript
* einen Block [data-wkz-jsonld] an. Unlesbare Bloecke bleiben unberuehrt.
* Jede Adresse aus der Schnittstelle wird im Browser noch einmal geprueft (https:, tel: nur ueber telefonLink); Links tragen
* rel="noopener noreferrer".
*
* Ein Abruf je Seite fuer alle Teile; keine Abhaengigkeiten, keine Cookies, kein Storage, keine Anfragen an Dritte. Kundentexte nur
* ueber textContent. Das Skript rechnet nie mit Feiertagen, Kalender oder Zeitzone des Browsers: "jetzt" ist Date.now() (weicht die
* Uhr laut Date-Kopf um mehr als 5 Minuten ab, die Serverzeit), alles andere kommt als Zeichenkette vom Server. Es laedt hoechstens
* alle 10 Minuten neu (und beim Zurueckkehren in den Tab); Daten aelter als 24 Stunden oder ein Fehler lassen das Ersatz-HTML stehen. */
(function () {
'use strict'
if (window.__wkZeiten) return
window.__wkZeiten = true
var SELF = document.currentScript
var STYLE_ID = 'wkz-style'
var API_PFAD = '/api/public/zeiten/v1/'
var SELEKTOR = '[data-webklar-zeiten]'
var TIMER_MAX_MS = 600000 // jede Wartezeit hoechstens 10 Minuten (setTimeout laeuft ab 2^31-1 ms ueber; "ohne Ende" ist 8.64e15)
var TIMER_PUFFER_MS = 25 // ein Timer darf nie vor dem Wechsel feuern
var RELOAD_MS = 600000 // hoechstens alle 10 Minuten ein Abruf
var ALTER_MAX_MS = 24 * 60 * 60 * 1000 // aeltere Daten gelten nicht mehr
var ABRUF_TIMEOUT_MS = 15000
var BEOBACHTER_MS = 200 // Entprellung: nach Aenderungen der Seite hoechstens alle 200 ms nach neuen Platzhaltern sehen
var GETRENNT_MAX = 20
// @rein-anfang (reine Funktionen: kein Zugriff auf Seite oder Uhr; test/zeitenEmbedStatic.test.js fuehrt sie einzeln in einem leeren Kontext aus)
var UHR_TOLERANZ_MS = 5 * 60 * 1000 // groessere Abweichung der Uhr laut Date-Kopf: Serverzeit
var ENDLOS_MS = 8.64e15 // "ohne Ende" der Hinweise (letzter darstellbarer Zeitpunkt)
var TAG_ZEILE = /^\d{4}-\d{2}-\d{2}$/
var WOCHENTAGE = ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag']
var ART_NAMEN = { feiertag: 'Feiertag', urlaub: 'Betriebsurlaub', sondertag: 'Sondertag' }
function istZahl (v) { return typeof v === 'number' && v > -Infinity && v < Infinity }
function artName (art) { return Object.prototype.hasOwnProperty.call(ART_NAMEN, art) ? ART_NAMEN[art] : '' }
// Der Eintrag, der jetzt gilt: der letzte mit ab <= jetzt (null, wenn noch keiner begonnen hat).
function waehleWechsel (wechsel, jetztMs) {
if (!Array.isArray(wechsel)) return null
var best = null
for (var i = 0; i < wechsel.length; i++) {
var w = wechsel[i]
if (w && istZahl(w.ab) && w.ab <= jetztMs && (best === null || w.ab >= best.ab)) best = w
}
return best
}
// Der naechste Zeitpunkt nach jetzt, an dem sich etwas aendert: Statuswechsel, Hinweis-Beginn oder Hinweis-Ende (null, wenn keiner folgt;
// "ohne Ende" zaehlt nicht).
function naechsterWechsel (wechsel, hinweise, jetztMs) {
var best = null
function pruefe (ms) {
if (istZahl(ms) && ms > jetztMs && ms < ENDLOS_MS && (best === null || ms < best)) best = ms
}
var i
if (Array.isArray(wechsel)) for (i = 0; i < wechsel.length; i++) if (wechsel[i]) pruefe(wechsel[i].ab)
if (Array.isArray(hinweise)) {
for (i = 0; i < hinweise.length; i++) {
if (!hinweise[i]) continue
pruefe(hinweise[i].ab)
pruefe(hinweise[i].bis)
}
}
return best
}
// Der Hinweis, der jetzt gilt (ab <= jetzt < bis): hoechster Rang, bei Gleichstand der spaetere Beginn.
function waehleHinweis (hinweise, jetztMs) {
if (!Array.isArray(hinweise)) return null
var best = null
for (var i = 0; i < hinweise.length; i++) {
var h = hinweise[i]
if (!h || !istZahl(h.rang) || !istZahl(h.ab) || !istZahl(h.bis) || h.ab > jetztMs || h.bis <= jetztMs) continue
if (best === null || h.rang > best.rang || (h.rang === best.rang && h.ab > best.ab)) best = h
}
return best
}
// "Jetzt" in Millisekunden: die lokale Uhr, ausser sie weicht laut Date-Kopf der Antwort um mehr als 5 Minuten von der Serverzeit ab.
function jetztMitServer (datumsKopf, lokalMs) {
var server = typeof datumsKopf === 'string' ? Date.parse(datumsKopf) : NaN
if (!istZahl(server) || Math.abs(server - lokalMs) <= UHR_TOLERANZ_MS) return lokalMs
return server
}
// 'YYYY-MM-DD' -> Tage seit 1970-01-01 (reine Ganzzahlrechnung auf der Zeichenkette des Servers, ohne Kalender des Browsers)
function tageNr (tag) {
var m = parseInt(tag.slice(5, 7), 10)
var y = parseInt(tag.slice(0, 4), 10) - (m <= 2 ? 1 : 0)
var era = Math.floor(y / 400)
var yoe = y - era * 400
var doy = Math.floor((153 * (m > 2 ? m - 3 : m + 9) + 2) / 5) + parseInt(tag.slice(8, 10), 10) - 1
return era * 146097 + yoe * 365 + Math.floor(yoe / 4) - Math.floor(yoe / 100) + doy - 719468
}
// ISO-Wochentag eines Tages 'YYYY-MM-DD': 1 = Montag ... 7 = Sonntag (der 1.1.1970 war ein Donnerstag)
function wochentagVon (tag) { return ((((tageNr(tag) + 3) % 7) + 7) % 7) + 1 }
// 'Montag, 12.10.'
function datumKurz (tag) { return WOCHENTAGE[wochentagVon(tag) - 1] + ', ' + tag.slice(8, 10) + '.' + tag.slice(5, 7) + '.' }
// Tageszeiten als Text; nie '24:00' (ein Ende um Mitternacht heisst 00:00).
function zeitenText (modus, fenster) {
if (!Array.isArray(fenster)) fenster = []
if (modus === 'rund' || (modus === 'zeiten' && fenster.length === 1 && fenster[0] && fenster[0][0] === '00:00' && fenster[0][1] === '24:00')) return 'rund um die Uhr'
if (modus === 'vereinbarung') return 'nach Vereinbarung'
if (modus !== 'zeiten' || fenster.length === 0) return 'geschlossen'
var teile = []
for (var i = 0; i < fenster.length; i++) {
var f = fenster[i]
if (Array.isArray(f) && typeof f[0] === 'string' && typeof f[1] === 'string') teile.push(f[0] + '–' + (f[1] === '24:00' ? '00:00' : f[1]))
}
return teile.length ? teile.join(', ') : 'geschlossen'
}
// Daten der Tabelle: woche = sieben Zeilen Montag bis Sonntag { tag, name, modus, fenster, text, heute } (heute = der Wochentag von
// heuteTag, dem Berliner Datum aus dem aktuellen Wechsel) und abweichungen14 = die Tage von heuteTag bis heuteTag + 13, an denen etwas
// vom Wochenplan abweicht (Feiertag, Urlaub, Sondertag oder andere Zeiten): { datum, art, name, modus, fenster, text }.
function tabelleDaten (plan, heuteTag) {
var heuteOk = typeof heuteTag === 'string' && TAG_ZEILE.test(heuteTag)
var heuteWt = heuteOk ? wochentagVon(heuteTag) : 0
var roh = plan && Array.isArray(plan.woche) ? plan.woche : []
var woche = []
var t, i
for (t = 1; t <= 7; t++) {
var e = null
for (i = 0; i < roh.length; i++) if (roh[i] && roh[i].tag === t) e = roh[i]
var modus = e ? e.modus : 'zu'
var fenster = e && Array.isArray(e.fenster) ? e.fenster : []
woche.push({ tag: t, name: WOCHENTAGE[t - 1], modus: modus, fenster: fenster, text: zeitenText(modus, fenster), heute: t === heuteWt })
}
var abweichungen14 = []
if (heuteOk && plan && Array.isArray(plan.tage)) {
var start = tageNr(heuteTag)
for (i = 0; i < plan.tage.length; i++) {
var z = plan.tage[i]
if (!z || typeof z.datum !== 'string' || !TAG_ZEILE.test(z.datum)) continue
var abstand = tageNr(z.datum) - start
if (abstand < 0 || abstand > 13) continue
var text = zeitenText(z.modus, z.fenster)
if (!artName(z.art) && text === woche[wochentagVon(z.datum) - 1].text) continue
abweichungen14.push({
datum: z.datum,
art: z.art,
name: typeof z.name === 'string' && z.name !== '' ? z.name : null,
modus: z.modus,
fenster: Array.isArray(z.fenster) ? z.fenster : [],
text: text
})
}
abweichungen14.sort(function (a, b) { return a.datum < b.datum ? -1 : a.datum > b.datum ? 1 : 0 })
}
return { woche: woche, abweichungen14: abweichungen14 }
}
// Aufeinanderfolgende Tage mit gleicher Art, gleichem Namen und gleichen Zeiten in einer Zeile: [{ von, bis, art, name, text }]
function gruppiereAbweichungen (liste) {
var gruppen = []
if (!Array.isArray(liste)) return gruppen
for (var i = 0; i < liste.length; i++) {
var a = liste[i]
var g = gruppen.length ? gruppen[gruppen.length - 1] : null
if (g && g.art === a.art && g.name === a.name && g.text === a.text && tageNr(a.datum) - tageNr(g.bis) === 1) g.bis = a.datum
else gruppen.push({ von: a.datum, bis: a.datum, art: a.art, name: a.name, text: a.text })
}
return gruppen
}
// Eine Zeile der Abweichungen: { datum: 'Dienstag, 06.10. – Donnerstag, 08.10.', text: 'Betriebsurlaub: geschlossen' }
function abweichungsZeile (g) {
var datum = datumKurz(g.von)
if (g.bis && g.bis !== g.von) datum += ' – ' + datumKurz(g.bis)
return { datum: datum, text: (g.name || artName(g.art) || 'Abweichung') + ': ' + g.text }
}
function istObjekt (v) { return v !== null && typeof v === 'object' && !Array.isArray(v) }
// Farben der Hinweis-Leiste je Art (Hintergrund, Schrift): Kontrast jeweils mindestens 4,5:1 (geprueft in test/zeitenEmbedStatic.test.js)
var HINWEIS_FARBEN = {
info: { bg: '#e0f2fe', fg: '#0c4a6e' },
aktion: { bg: '#dcfce7', fg: '#14532d' },
urlaub: { bg: '#fef3c7', fg: '#78350f' },
wichtig: { bg: '#b91c1c', fg: '#ffffff' }
}
// schema.org: LocalBusiness und seine Untertypen (Unternehmen vor Ort); Person, Organization, WebSite, WebPage u. a. gehoeren nicht dazu
var LOKALE_TYPEN = ' LocalBusiness AnimalShelter ArchiveOrganization AutomotiveBusiness AutoBodyShop AutoDealer AutoPartsStore AutoRental AutoRepair AutoWash GasStation MotorcycleDealer MotorcycleRepair ' +
'ChildCare Dentist DryCleaningOrLaundry EmergencyService FireStation Hospital PoliceStation EmploymentAgency EntertainmentBusiness AdultEntertainment AmusementPark ArtGallery Casino ComedyClub MovieTheater NightClub ' +
'FinancialService AccountingService AutomatedTeller BankOrCreditUnion InsuranceAgency FoodEstablishment Bakery BarOrPub Brewery CafeOrCoffeeShop Distillery FastFoodRestaurant IceCreamShop Restaurant Winery ' +
'GovernmentOffice PostOffice HealthAndBeautyBusiness BeautySalon DaySpa HairSalon HealthClub NailSalon TattooParlor HomeAndConstructionBusiness Electrician GeneralContractor HVACBusiness HousePainter Locksmith MovingCompany Plumber RoofingContractor ' +
'InternetCafe LegalService Attorney Notary Library LodgingBusiness BedAndBreakfast Campground Hostel Hotel Motel Resort SkiResort VacationRental ' +
'MedicalBusiness CommunityHealth Dermatology DietNutrition Emergency Geriatric Gynecologic MedicalClinic Midwifery Nursing Obstetric Oncologic Optician Optometric Otolaryngologic Pediatric Pharmacy Physician Physiotherapy PlasticSurgery Podiatric PrimaryCare Psychiatric PublicHealth ' +
'ProfessionalService RadioStation RealEstateAgent RecyclingCenter SelfStorage ShoppingCenter SportsActivityLocation BowlingAlley ExerciseGym GolfCourse PublicSwimmingPool SportsClub StadiumOrArena TennisComplex ' +
'Store BikeStore BookStore ClothingStore ComputerStore ConvenienceStore DepartmentStore ElectronicsStore Florist FurnitureStore GardenStore GroceryStore HardwareStore HobbyShop HomeGoodsStore JewelryStore LiquorStore MensClothingStore MobilePhoneStore MovieRentalStore MusicStore OfficeEquipmentStore OutletStore PawnShop PetStore ShoeStore SportingGoodsStore TireShop ToyStore WholesaleStore ' +
'TelevisionStation TouristInformationCenter TravelAgency '
// Eine Adresse aus der Schnittstelle: nur https mit Host, ohne Leer- und Steuerzeichen und ohne Zugangsdaten (sonst ''; wie der Server und
// linkOk des Portals). Ein @ vor dem ersten /, ?, # oder \ gehoert zu Benutzer und Kennwort (die Rueckwaertsschraege zaehlt wie / im Browser).
// Es steht keine feste Webadresse im Skript, nur diese Pruefung des Schemas.
function sichererLink (url) {
if (typeof url !== 'string' || url.length > 2000 || /[\s\u0000-\u001f\u007f]/.test(url)) return ''
return /^https:\/\/[^\/?#\\@]+(?:[\/?#]|$)/i.test(url) ? url : ''
}
// Knopf eines Hinweises { art: 'link'|'anruf', ziel, text } als { art, href, text } oder null. Links: nur https; Anruf: tel: nur ueber telefonLink.
function hinweisKnopf (knopf) {
if (!istObjekt(knopf)) return null
var href = knopf.art === 'link' ? sichererLink(knopf.ziel) : knopf.art === 'anruf' ? telefonLink(knopf.ziel) : ''
if (!href) return null
var text = typeof knopf.text === 'string' ? knopf.text.replace(/^\s+|\s+$/g, '') : ''
return { art: knopf.art, href: href, text: text || (knopf.art === 'anruf' ? 'Anrufen' : 'Mehr erfahren') }
}
// Knoepfe der Kontakt-Leiste aus { telefon, routeUrl, whatsappUrl }: [{ art: 'anruf'|'route'|'whatsapp', href, text }], nur mit brauchbaren Angaben.
// telefon = Ziffern mit Laendervorwahl ohne "+" (E.164), daraus tel:+... ueber telefonLink; Route und WhatsApp nur als https-Adresse.
function kontaktKnoepfe (kontakt) {
var liste = []
if (!istObjekt(kontakt)) return liste
var tel = typeof kontakt.telefon === 'string' && /^[1-9][0-9]{5,14}$/.test(kontakt.telefon) ? telefonLink('+' + kontakt.telefon) : ''
if (tel) liste.push({ art: 'anruf', href: tel, text: 'Anrufen' })
var route = sichererLink(kontakt.routeUrl)
if (route) liste.push({ art: 'route', href: route, text: 'Route' })
var wa = sichererLink(kontakt.whatsappUrl)
if (wa) liste.push({ art: 'whatsapp', href: wa, text: 'WhatsApp' })
return liste
}
// Hat die Seite schon eine eigene Anruf-Leiste? elemente = die festen/klebenden Vorfahren aller a[href^="tel:"] der Seite als
// { position, bottom (berechnet), oben, unten (Rand im Fenster), fenster (Fensterhoehe), sichtbar }. Zaehlt: sichtbar, fest (oder klebend mit
// bottom) und in der unteren Haelfte, hoechstens 160 px ueber dem unteren Rand (auch ein schwebender Anruf-Knopf). Kopfzeilen, bildschirmhohe
// Seitenleisten und Ausgeblendetes zaehlen nicht.
function istEigeneAnrufLeiste (elemente) {
if (!Array.isArray(elemente)) return false
for (var i = 0; i < elemente.length; i++) {
var e = elemente[i]
if (!e || e.sichtbar !== true || !istZahl(e.oben) || !istZahl(e.unten) || !istZahl(e.fenster)) continue
if (e.position !== 'fixed' && !(e.position === 'sticky' && e.bottom !== 'auto')) continue
if (e.oben >= e.fenster / 2 && e.oben < e.fenster && e.unten >= e.fenster - 160) return true
}
return false
}
function typVon (t) { return typeof t === 'string' ? t.replace(/^.*[\/:#]/, '') : '' }
// Ein Knoten ist ein Unternehmen vor Ort, wenn einer seiner @type (Text oder Liste, auch mit Praefix oder als Adresse) LocalBusiness oder ein Untertyp ist.
function istLokalesUnternehmen (knoten) {
var typen = Array.isArray(knoten['@type']) ? knoten['@type'] : [knoten['@type']]
for (var i = 0; i < typen.length; i++) {
var t = typVon(typen[i])
if (/^[A-Za-z]+$/.test(t) && LOKALE_TYPEN.indexOf(' ' + t + ' ') >= 0) return true
}
return false
}
// Alle Objekte eines geparsten JSON-LD-Blocks: er selbst (Objekt oder Liste von Objekten) und die Knoten seines @graph.
function sammleKnoten (wert, aus, tiefe) {
if (tiefe > 4) return
if (Array.isArray(wert)) {
for (var i = 0; i < wert.length; i++) sammleKnoten(wert[i], aus, tiefe + 1)
return
}
if (!istObjekt(wert)) return
aus.push(wert)
if (Array.isArray(wert['@graph'])) sammleKnoten(wert['@graph'], aus, tiefe + 1)
}
function kopie (v) { return JSON.parse(JSON.stringify(v)) }
// Zeiten fuer schema.org: bloecke = geparste application/ld+json-Inhalte der Seite (unparsbare als null), jsonld = { openingHoursSpecification,
// specialOpeningHoursSpecification, neuerBlock } der Schnittstelle, seite = { name, adresse, url }. In jedem Unternehmen vor Ort der Bloecke
// werden NUR openingHoursSpecification und specialOpeningHoursSpecification ersetzt (leere Listen entfernen das Feld); die Bloecke werden
// dabei veraendert, geaendert = ihre Indizes (nur wo sich etwas aendert). Gibt es kein Unternehmen vor Ort, ist neu der neuerBlock des Servers
// mit name, address (wenn bekannt) und url (ohne Namen: null, ein LocalBusiness ohne name taugt nicht). Ohne jsonld bleibt alles, wie es ist.
function jsonLdEinbauen (bloecke, jsonld, seite) {
var erg = { geaendert: [], neu: null }
if (!istObjekt(jsonld)) return erg
if (!Array.isArray(bloecke)) bloecke = []
var zeiten = Array.isArray(jsonld.openingHoursSpecification) ? jsonld.openingHoursSpecification : []
var sonder = Array.isArray(jsonld.specialOpeningHoursSpecification) ? jsonld.specialOpeningHoursSpecification : []
var gefunden = false
var i, k
function setze (knoten, schluessel, liste) {
if (liste.length) knoten[schluessel] = kopie(liste)
else delete knoten[schluessel]
}
for (i = 0; i < bloecke.length; i++) {
var knoten = []
sammleKnoten(bloecke[i], knoten, 0)
var geaendert = false
for (k = 0; k < knoten.length; k++) {
if (!istLokalesUnternehmen(knoten[k])) continue
gefunden = true
var vorher = JSON.stringify([knoten[k].openingHoursSpecification, knoten[k].specialOpeningHoursSpecification])
setze(knoten[k], 'openingHoursSpecification', zeiten)
setze(knoten[k], 'specialOpeningHoursSpecification', sonder)
if (JSON.stringify([knoten[k].openingHoursSpecification, knoten[k].specialOpeningHoursSpecification]) !== vorher) geaendert = true
}
if (geaendert) erg.geaendert.push(i)
}
var s = istObjekt(seite) ? seite : {}
var name = typeof s.name === 'string' ? s.name.replace(/^\s+|\s+$/g, '') : ''
if (!gefunden && name && istObjekt(jsonld.neuerBlock)) {
var neu = kopie(jsonld.neuerBlock)
neu.name = name
if (typeof s.adresse === 'string' && s.adresse.replace(/^\s+|\s+$/g, '')) neu.address = s.adresse.replace(/^\s+|\s+$/g, '')
if (typeof s.url === 'string' && s.url) neu.url = s.url
if (Array.isArray(neu.openingHoursSpecification) && !neu.openingHoursSpecification.length) delete neu.openingHoursSpecification
if (Array.isArray(neu.specialOpeningHoursSpecification) && !neu.specialOpeningHoursSpecification.length) delete neu.specialOpeningHoursSpecification
erg.neu = neu
}
return erg
}
// JSON-LD als Text fuer ein <script type="application/ld+json">: ohne "<" (\u003c), damit ein </script> im Firmennamen oder in einem schon
// vorhandenen Block den Block nie beendet, wenn die Seite serialisiert wird (Suchroboter, "Seite speichern"). JSON.parse liest es zurueck.
function jsonLdText (wert) { return JSON.stringify(wert).replace(/</g, '\\u003c') }
// @rein-ende
// @telefon-anfang (reine Funktion, test/zeitenEmbedStatic.test.js fuehrt sie einzeln aus; dieselbe Regel wie in public/embed/termine.js; fuer Kontakt-Leiste und Hinweis-Knoepfe)
// Telefonnummer des Betriebs (freier Text) als tel:-Adresse. Steht ein Buchstabe oder ein anderes Zeichen als Ziffern, Leerzeichen,
// + - ( ) / . darin ("06301 1234 (ab 9 Uhr)", "0171 1, 0171 2"), ist es keine Nummer, die man waehlen kann: dann '' und der Text bleibt
// Text. Sonst bleiben ein fuehrendes + und die Ziffern; die nationale Null "(0)" hinter der Landesvorwahl entfaellt.
function telefonLink (nr) {
if (typeof nr !== 'string') return ''
var s = nr.replace(/^\s+|\s+$/g, '')
if (!/^[0-9 +()\/.-]+$/.test(s)) return ''
var lv = /^((?:\+|00)\s*\d{1,3})[\s.\/-]*\(\s*0\s*\)/.exec(s)
if (lv) s = lv[1] + s.slice(lv[0].length)
var ziffern = s.replace(/[^0-9]/g, '')
if (ziffern.length < 3) return ''
return 'tel:' + (s.charAt(0) === '+' ? '+' : '') + ziffern
}
// @telefon-ende
var CSS = [
'@layer wkz{',
'.wkz-status,.wkz-badge{--wkz-i-farbe:var(--wkz-unklar,#a16207);--wkz-i-hell:var(--wkz-unklar-hell,#fef3c7);--wkz-i-dunkel:var(--wkz-unklar-text,#78350f)}',
'.wkz-zustand-offen{--wkz-i-farbe:var(--wkz-offen,#15803d);--wkz-i-hell:var(--wkz-offen-hell,#dcfce7);--wkz-i-dunkel:var(--wkz-offen-text,#14532d)}',
'.wkz-zustand-zu{--wkz-i-farbe:var(--wkz-zu,#b91c1c);--wkz-i-hell:var(--wkz-zu-hell,#fee2e2);--wkz-i-dunkel:var(--wkz-zu-text,#7f1d1d)}',
'.wkz-status{display:inline-flex;align-items:center;gap:.5em;font:inherit;color:inherit;line-height:1.4}',
'.wkz-punkt{flex:none;width:.65em;height:.65em;border-radius:50%;background:var(--wkz-i-farbe)}',
'.wkz-badge{display:inline-flex;align-items:center;gap:.45em;padding:.1em .75em;border-radius:999px;font:inherit;font-size:.9em;font-weight:600;line-height:1.5;background:var(--wkz-i-hell);color:var(--wkz-i-dunkel)}',
'.wkz-tabelle{border-collapse:collapse;width:100%;max-width:34rem;font:inherit;color:inherit}',
'.wkz-caption{caption-side:top;text-align:left;font-weight:700;padding:0 0 .5em}',
'.wkz-tabelle th,.wkz-tabelle td{padding:.4em .75em;text-align:left;vertical-align:top;border-bottom:1px solid var(--wkz-rand,rgba(128,128,128,.35))}',
'.wkz-tabelle th{font-weight:600;white-space:nowrap}',
'.wkz-heute{background:var(--wkz-heute,rgba(128,128,128,.14));font-weight:700}',
'.wkz-heute th{font-weight:700}',
'.wkz-abw{margin-top:1em;max-width:34rem;font:inherit;color:inherit}',
'.wkz-abw-titel{margin:0 0 .35em;font-weight:700}',
'.wkz-abw-liste{margin:0;padding:0;list-style:none}',
'.wkz-abw-eintrag{padding:.3em 0;border-bottom:1px solid var(--wkz-rand,rgba(128,128,128,.35))}',
'.wkz-abw-datum{font-weight:600}',
'}'
].join('\n')
// ---------------------------------------------------------------- Zustand (nur im Speicher der Seite)
var daten = null // letzte gueltige Antwort { plaene, hinweise, kontakt, jsonld, firma, team } oder null (aktiv: false, Fehler, noch kein Abruf)
var geladenMs = 0 // Date.now() beim letzten ERFOLGREICHEN Abruf (nicht body.stand: ein 304 liefert alte Daten)
var versatz = 0 // Serverzeit minus lokale Uhr, nur bei mehr als 5 Minuten Abweichung (sonst 0)
var versuchMs = 0 // Beginn des letzten Abrufs, ob erfolgreich oder nicht
var laeuft = false
var timerId = 0
var platzhalter = []
var getrennt = [] // Platzhalter, die gerade nicht mehr auf der Seite stehen (hoechstens GETRENNT_MAX): kommt derselbe Knoten zurueck, gilt er wieder
var siteKey = ''
var api = ''
var HANDY_MQ = '(max-width: 768px)' // Kontakt-Leiste nur auf schmalen Schirmen
var hinweisLeiste = null // { host, sig, nav, hoehe, fest } der Hinweis-Leiste auf der Seite (null: keine)
var kontaktLeiste = null // { host, abstand, sig } der Kontakt-Leiste samt Abstandhalter (null: keine)
var SCROLL_PADDING = { top: 'scroll-padding-top', bottom: 'scroll-padding-bottom' }
var scrollPadding = { top: null, bottom: null } // was die Seite vor uns in scroll-padding-top/-bottom hatte: { wert, prio, basis }
var handyAbfrage = null
var hoererAn = false
var beobachter = null
var scrollWartet = false
var groesseWartet = false
var ldSig = ''
var ldEigenes = null // der von uns angelegte JSON-LD-Block (nur wenn die Seite keinen eigenen hat)
function timer (fn, ms) {
var d = Number(ms)
if (d !== d) d = TIMER_MAX_MS
return setTimeout(function () { fn() }, Math.max(0, Math.min(TIMER_MAX_MS, d)))
}
function jetzt () { return Date.now() + versatz }
function frisch () { return daten !== null && Math.abs(Date.now() - geladenMs) <= ALTER_MAX_MS }
function warne (text) {
if (typeof console !== 'undefined' && console.warn) console.warn('WEBklar Öffnungszeiten: ' + text)
}
function injectStyle () {
if (document.getElementById(STYLE_ID)) return
var st = document.createElement('style')
st.id = STYLE_ID
st.textContent = CSS
;(document.head || document.documentElement).appendChild(st)
}
function el (tag, cls, text) {
var e = document.createElement(tag)
if (cls) e.className = cls
if (text != null) e.textContent = text
return e
}
function attr (node, name) { return String(node.getAttribute(name) || '').replace(/^\s+|\s+$/g, '') }
// ---------------------------------------------------------------- Abruf
function planGueltig (p) {
return !!p && typeof p === 'object' && typeof p.kurz === 'string' && Array.isArray(p.woche) && Array.isArray(p.wechsel) && p.wechsel.length > 0
}
// Antwort uebernehmen. { aktiv: false } ist ein erfolgreicher Abruf (die Platzhalter bekommen ihr Ersatz-HTML zurueck); alles andere als ein
// Objekt zaehlt als Fehler (die alten Daten bleiben bis zur 24-Stunden-Grenze).
function uebernimm (body, datumsKopf) {
if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error('Antwort')
var lokal = Date.now()
geladenMs = lokal
versatz = jetztMitServer(datumsKopf, lokal) - lokal
if (body.aktiv !== true || !Array.isArray(body.plaene)) {
daten = null
return
}
daten = {
plaene: body.plaene.filter(planGueltig),
hinweise: Array.isArray(body.hinweise) ? body.hinweise : [],
kontakt: istObjekt(body.kontakt) ? body.kontakt : null,
jsonld: istObjekt(body.jsonld) ? body.jsonld : null,
firma: istObjekt(body.firma) ? body.firma : {},
team: istObjekt(body.team) ? body.team : {}
}
}
function lade () {
if (laeuft || !siteKey || !api) return
laeuft = true
versuchMs = Date.now()
var opts = { credentials: 'omit' }
var ctrl = typeof AbortController === 'function' ? new AbortController() : null
var abbruch = 0
if (ctrl) {
opts.signal = ctrl.signal
abbruch = timer(function () { ctrl.abort() }, ABRUF_TIMEOUT_MS)
}
function fertig () {
if (abbruch) clearTimeout(abbruch)
laeuft = false
try { render() } finally { plane() }
}
var anfrage
try {
anfrage = fetch(api + API_PFAD + encodeURIComponent(siteKey), opts)
} catch (e) {
fertig() // ein sofort werfender fetch beendet den Abruf wie ein Fehler (laeuft bleibt nie haengen)
return
}
anfrage.then(function (r) {
if (!r.ok) throw new Error('Status ' + r.status)
var kopf = r.headers && typeof r.headers.get === 'function' ? r.headers.get('Date') : null
return r.json().then(function (body) { uebernimm(body, kopf) })
}).then(fertig, fertig)
}
// ---------------------------------------------------------------- Platzhalter
function neuerPlatzhalter (root) {
// Kopie des Ersatz-HTML: das Original bleibt stehen, bis es ersetzt wird, und kommt (als weitere Kopie) zurueck
var ausweich = document.createDocumentFragment()
for (var c = root.firstChild; c; c = c.nextSibling) ausweich.appendChild(c.cloneNode(true))
var modus = attr(root, 'data-modus')
if (modus !== 'tabelle' && modus !== 'status' && modus !== 'badge') {
warne('data-modus="' + modus + '" ist unbekannt (tabelle, status oder badge): das Ersatz-HTML bleibt stehen.')
modus = ''
}
return { root: root, ausweich: ausweich, modus: modus, plan: attr(root, 'data-plan'), sig: '', gewarnt: '' }
}
function sammle () {
var knoten = document.querySelectorAll(SELEKTOR)
for (var i = 0; i < knoten.length; i++) {
if (knoten[i].hasAttribute('data-wkz-init')) continue
knoten[i].setAttribute('data-wkz-init', '1')
platzhalter.push(neuerPlatzhalter(knoten[i]))
}
}
function leere (root) {
while (root.firstChild) root.removeChild(root.firstChild)
}
function ersetze (p, knoten, sig) {
injectStyle()
leere(p.root)
p.root.appendChild(knoten)
p.sig = sig
}
// Das Ersatz-HTML zurueck, falls das Skript es ersetzt hatte (sonst bleibt alles, wie es ist)
function stelleWiederHer (p) {
if (p.sig === '') return
leere(p.root)
p.root.appendChild(p.ausweich.cloneNode(true))
p.sig = ''
}
function findePlan (d, kurz) {
var i
if (kurz) {
for (i = 0; i < d.plaene.length; i++) if (d.plaene[i].kurz === kurz) return d.plaene[i]
return null
}
for (i = 0; i < d.plaene.length; i++) if (d.plaene[i].haupt === true) return d.plaene[i]
return d.plaene.length ? d.plaene[0] : null
}
// Der Eintrag, der jetzt gilt. Geht die Uhr der Seite (innerhalb der Toleranz) nach, ist der erste Eintrag (Beginn der Minute des Abrufs)
// noch "in der Zukunft": er gilt trotzdem.
function aktuellerEintrag (plan, jetztMs) {
var e = waehleWechsel(plan.wechsel, jetztMs)
if (e) return e
var erster = plan.wechsel[0]
return erster && istZahl(erster.ab) && erster.ab - jetztMs <= UHR_TOLERANZ_MS ? erster : null
}
function zustandKlasse (offen) {
return offen === true ? 'wkz-zustand-offen' : offen === false ? 'wkz-zustand-zu' : 'wkz-zustand-unklar'
}
function zeigeTabelle (p, plan, eintrag) {
var t = tabelleDaten(plan, eintrag.tag)
var gruppen = gruppiereAbweichungen(t.abweichungen14)
var name = typeof plan.name === 'string' && plan.name !== '' ? plan.name : 'Öffnungszeiten'
var sig = 'tabelle|' + JSON.stringify([name, t.woche.map(function (w) { return [w.text, w.heute] }), gruppen])
if (p.sig === sig) return
var tabelle = el('table', 'wkz-tabelle')
tabelle.appendChild(el('caption', 'wkz-caption', name))
var tbody = el('tbody')
for (var i = 0; i < t.woche.length; i++) {
var w = t.woche[i]
var tr = el('tr', w.heute ? 'wkz-zeile wkz-heute' : 'wkz-zeile')
if (w.heute) tr.setAttribute('aria-current', 'date')
var th = el('th', 'wkz-tag', w.name)
th.setAttribute('scope', 'row')
tr.appendChild(th)
tr.appendChild(el('td', 'wkz-zeiten', w.text))
tbody.appendChild(tr)
}
tabelle.appendChild(tbody)
var box = document.createDocumentFragment()
box.appendChild(tabelle)
if (gruppen.length) {
var abw = el('div', 'wkz-abw')
abw.appendChild(el('p', 'wkz-abw-titel', 'Abweichungen in den nächsten 14 Tagen'))
var liste = el('ul', 'wkz-abw-liste')
for (var k = 0; k < gruppen.length; k++) {
var z = abweichungsZeile(gruppen[k])
var li = el('li', 'wkz-abw-eintrag')
li.appendChild(el('span', 'wkz-abw-datum', z.datum))
li.appendChild(document.createTextNode(' '))
li.appendChild(el('span', 'wkz-abw-text', z.text))
liste.appendChild(li)
}
abw.appendChild(liste)
box.appendChild(abw)
}
ersetze(p, box, sig)
}
// Punkt und Text in einem Element (Status: der ausfuehrliche Text, Badge: die Kurzform); kein aria-live
function zeigeZustand (p, cls, text, eintrag) {
if (typeof text !== 'string' || text === '') {
stelleWiederHer(p)
return
}
var sig = cls + '|' + String(eintrag.offen) + '|' + text
if (p.sig === sig) return
var box = el('span', cls + ' ' + zustandKlasse(eintrag.offen))
var punkt = el('span', 'wkz-punkt')
punkt.setAttribute('aria-hidden', 'true')
box.appendChild(punkt)
box.appendChild(el('span', 'wkz-text', text))
ersetze(p, box, sig)
}
function fuelle (p, d, jetztMs) {
var plan = d ? findePlan(d, p.plan) : null
if (d && d.plaene.length && p.plan && !plan && p.gewarnt !== p.plan) {
p.gewarnt = p.plan
warne('Plan "' + p.plan + '" nicht gefunden: das Ersatz-HTML bleibt stehen.')
}
var eintrag = plan && p.modus ? aktuellerEintrag(plan, jetztMs) : null
if (!eintrag) {
stelleWiederHer(p)
return
}
if (p.modus === 'tabelle') zeigeTabelle(p, plan, eintrag)
else if (p.modus === 'status') zeigeZustand(p, 'wkz-status', eintrag.text || eintrag.kurz, eintrag)
else zeigeZustand(p, 'wkz-badge', eintrag.kurz, eintrag)
}
// Ein Fehler in einem Teil (Platzhalter, Hinweis-Leiste, Kontakt-Leiste, JSON-LD) darf die anderen nicht aufhalten
function sicher (fn) {
try { fn() } catch (e) { warne('Fehler beim Zeichnen: ' + (e && e.message ? e.message : e)) }
}
function fuelleSicher (p, d, t) { sicher(function () { fuelle(p, d, t) }) }
// Entfernte Platzhalter (Seitenwechsel einer Einzelseiten-App) wandern in `getrennt`; ein wieder eingehaengter Knoten kommt mit seinem
// Ersatz-HTML zurueck und wird neu gezeichnet (sig leer), denn die Seite hat seinen Inhalt unterdessen vielleicht zurueckgesetzt
function pflegePlatzhalter () {
var i, p
var bleibt = []
for (i = 0; i < platzhalter.length; i++) {
p = platzhalter[i]
if (p.root.isConnected !== false) bleibt.push(p)
else getrennt.push(p)
}
platzhalter = bleibt
var rest = []
for (i = 0; i < getrennt.length; i++) {
p = getrennt[i]
if (p.root.isConnected !== false) {
p.sig = ''
platzhalter.push(p)
} else rest.push(p)
}
getrennt = rest.slice(-GETRENNT_MAX)
}
function render () {
sammle()
pflegePlatzhalter()
var d = frisch() ? daten : null
var t = jetzt()
for (var i = 0; i < platzhalter.length; i++) fuelleSicher(platzhalter[i], d, t)
zeichneLeisten(d, t)
sicher(function () { zeichneJsonLd(d) })
}
// ---------------------------------------------------------------- Leisten (Shadow DOM, vom Skript gezeichnet)
var LEISTE_SCHRIFT = 'system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif'
function hinweisCss () {
var teile = [
':host{all:initial;display:block}',
'.wkz-leiste{box-sizing:border-box;display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:4px 16px;margin:0;padding:8px 16px;font:500 15px/1.4 ' + LEISTE_SCHRIFT + ';text-align:center;background:var(--bg);color:var(--fg);animation:wkz-ein .3s ease-out}',
'.wkz-text{margin:0;min-width:0;overflow-wrap:anywhere}',
'.wkz-knopf{box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;min-width:44px;min-height:44px;padding:0 18px;border-radius:999px;background:var(--fg);color:var(--bg);font:inherit;font-weight:700;text-decoration:none}',
'.wkz-knopf:focus-visible{outline:3px solid var(--fg);outline-offset:3px}',
'@keyframes wkz-ein{from{opacity:0;transform:translateY(-100%)}to{opacity:1;transform:none}}',
'@media (prefers-reduced-motion:reduce){.wkz-leiste{animation:none}}'
]
for (var art in HINWEIS_FARBEN) {
if (Object.prototype.hasOwnProperty.call(HINWEIS_FARBEN, art)) teile.push('.wkz-' + art + '{--bg:' + HINWEIS_FARBEN[art].bg + ';--fg:' + HINWEIS_FARBEN[art].fg + '}')
}
return teile.join('\n')
}
function kontaktCss () {
return [
':host{all:initial;display:block}',
'.wkz-leiste{box-sizing:border-box;display:flex;gap:8px;margin:0;padding:8px 12px calc(8px + env(safe-area-inset-bottom,0px));background:#fff;border-top:1px solid #d1d5db;box-shadow:0 -2px 10px rgba(0,0,0,.15);font:600 16px/1.2 ' + LEISTE_SCHRIFT + '}',
'.wkz-knopf{box-sizing:border-box;flex:1 1 0;min-width:44px;min-height:48px;display:flex;align-items:center;justify-content:center;padding:0 8px;border:2px solid #111827;border-radius:10px;background:#fff;color:#111827;font:inherit;text-decoration:none;text-align:center}',
'.wkz-anruf{background:#111827;color:#fff}',
'.wkz-knopf:focus-visible{outline:3px solid #2563eb;outline-offset:2px}'
].join('\n')
}
// Host mit Shadow DOM und eigenem Stil (null, wo der Browser kein Shadow DOM kann: dann gibt es keine Leiste)
function neueLeiste (art, css) {
var host = document.createElement('div')
if (typeof host.attachShadow !== 'function') return null
host.setAttribute('data-wkz-leiste', art)
var wurzel = host.attachShadow({ mode: 'open' })
var st = document.createElement('style')
st.textContent = css
wurzel.appendChild(st)
return { host: host, wurzel: wurzel }
}
// Link aus einem { href, text } der reinen Funktionen (href ist dort schon geprueft); nie ueber HTML-Text
function linkKnopf (k, klasse, neuerTab) {
var a = el('a', klasse, k.text)
a.setAttribute('href', k.href)
a.setAttribute('rel', 'noopener noreferrer')
if (neuerTab) a.setAttribute('target', '_blank')
return a
}
function naechstesBild (fn) {
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(fn)
else fn()
}
function pixel (wert) { return /^-?\d+(\.\d+)?px$/.test(wert) ? parseFloat(wert) : 0 }
// scroll-padding-top/-bottom des Dokuments um hoehePx erhoehen (der Wert der Seite wird gemerkt und beim Entfernen zurueckgesetzt)
function erhoeheScrollPadding (seite, hoehePx) {
var prop = SCROLL_PADDING[seite]
var st = document.documentElement.style
if (!scrollPadding[seite]) {
scrollPadding[seite] = { wert: st.getPropertyValue(prop), prio: st.getPropertyPriority(prop), basis: pixel(getComputedStyle(document.documentElement).getPropertyValue(prop)) }
}
st.setProperty(prop, (scrollPadding[seite].basis + hoehePx) + 'px', 'important')
}
function stelleScrollPaddingWieder (seite) {
var merk = scrollPadding[seite]
if (!merk) return
var prop = SCROLL_PADDING[seite]
var st = document.documentElement.style
if (merk.wert) st.setProperty(prop, merk.wert, merk.prio)
else st.removeProperty(prop)
scrollPadding[seite] = null
}
function istFest (cs) { return cs.position === 'fixed' || cs.position === 'sticky' }
function merke (e, cs) {
var top = parseFloat(cs.top)
return { el: e, wert: e.style.getPropertyValue('top'), prio: e.style.getPropertyPriority('top'), basis: top === top ? top : 0 }
}
// Feste und klebende Elemente, die die Leiste verdecken wuerde: die des Team-Selektors nav (jedes feste/klebende, auch mit anderem top) und
// alle mit top: 0, sichtbar und hoechstens halb so hoch wie das Fenster (kein Menue-Panel, kein Overlay). Ein festes Element, das schon
// ueber der Leiste endet (die Seite hat ihren Kopf per Polster oder Rand von body freigehalten, die Leiste steht erst darunter), bleibt,
// wo es ist: barOben ist der Abstand der Leiste vom Seitenanfang. nav kann ungueltig sein: nur in try/catch.
function sucheFeste (nav, barOben) {
var liste = []
var bekannt = []
var i, e, cs
if (nav) {
var treffer = []
try { treffer = document.querySelectorAll(nav) } catch (fehler) { treffer = [] }
for (i = 0; i < treffer.length; i++) {
cs = getComputedStyle(treffer[i])
if (!istFest(cs)) continue
liste.push(merke(treffer[i], cs))
bekannt.push(treffer[i])
}
}
var alle = document.body.getElementsByTagName('*')
var hoeheFenster = window.innerHeight || 0
for (i = 0; i < alle.length && i < 4000; i++) {
e = alle[i]
if (bekannt.indexOf(e) >= 0 || e.hasAttribute('data-wkz-leiste')) continue
cs = getComputedStyle(e)
if (!istFest(cs) || parseFloat(cs.top) !== 0 || cs.display === 'none') continue
var r = e.getBoundingClientRect()
if (r.height <= 0 || r.height > hoeheFenster * 0.5) continue
if (cs.position === 'fixed' && r.bottom <= barOben + 0.5) continue
liste.push(merke(e, cs))
}
return liste
}
// Feste Elemente um max(0, Leistenhoehe - Scrollposition) nach unten ruecken (Inline-top, hat Vorrang vor den Regeln der Seite). Ist die
// Leiste weggescrollt (s = 0), bekommt das Element seinen eigenen Inline-top zurueck (oder keinen): Koepfe, die per Klasse oder Skript beim
// Scrollen weg- und wieder einfahren (top: -80px), arbeiten dann wie auf der Seite gedacht.
function verschiebe (l) {
var y = window.pageYOffset || document.documentElement.scrollTop || 0
var s = Math.max(0, l.hoehe - Math.max(0, y))
for (var i = 0; i < l.fest.length; i++) {
var f = l.fest[i]
if (s > 0) f.el.style.setProperty('top', (f.basis + s) + 'px', 'important')
else if (f.wert) f.el.style.setProperty('top', f.wert, f.prio)
else f.el.style.removeProperty('top')
}
}
function setzeFesteZurueck (l) {
for (var i = 0; i < l.fest.length; i++) {
var f = l.fest[i]
if (f.wert) f.el.style.setProperty('top', f.wert, f.prio)
else f.el.style.removeProperty('top')
}
l.fest = []
}
// Hoehe der Hinweis-Leiste neu messen, feste Elemente neu suchen (erst die alten zuruecksetzen: berechnet wird immer der Stand der Seite) und ruecken
function misstHinweis () {
var l = hinweisLeiste
if (!l) return
setzeFesteZurueck(l)
var r = l.host.getBoundingClientRect()
l.hoehe = r.height
l.fest = sucheFeste(l.nav, r.top + (window.pageYOffset || document.documentElement.scrollTop || 0))
erhoeheScrollPadding('top', l.hoehe)
verschiebe(l)
}
function misstKontakt () {
var l = kontaktLeiste
if (!l) return
var h = l.host.getBoundingClientRect().height
l.abstand.style.cssText = 'display:block;width:100%;height:' + h + 'px;margin:0;padding:0;border:0;visibility:hidden'
erhoeheScrollPadding('bottom', h)
}
function beiScroll () {
if (!hinweisLeiste || scrollWartet) return
scrollWartet = true
naechstesBild(function () {
scrollWartet = false
if (hinweisLeiste) verschiebe(hinweisLeiste)
})
}
// Fenster oder Leiste aendern ihre Groesse: hoechstens alle 120 ms neu messen und suchen (beim Ziehen am Fenster nicht in jedem Bild)
function beiGroesse () {
if (groesseWartet) return
groesseWartet = true
timer(function () {
groesseWartet = false
sicher(misstHinweis)
sicher(misstKontakt)
}, 120)
}
// Scroll (nur gemerkt, ein Bild spaeter gerueckt) und Groesse (Fenster oder Leiste) werden einmal beobachtet, sobald es eine Leiste gibt
function sorgeFuerHoerer () {
if (hoererAn) return
hoererAn = true
window.addEventListener('scroll', beiScroll, { passive: true })
window.addEventListener('resize', beiGroesse)
if (typeof ResizeObserver === 'function') beobachter = new ResizeObserver(beiGroesse)
}
function entferneLeiste (host) {
if (beobachter) beobachter.unobserve(host)
if (host.parentNode) host.parentNode.removeChild(host)
}
function zeigeHinweisLeiste (h, nav) {
var art = Object.prototype.hasOwnProperty.call(HINWEIS_FARBEN, h.art) ? h.art : 'info'
var knopf = hinweisKnopf(h.knopf)
var sig = JSON.stringify([art, h.text, knopf, nav])
var l = hinweisLeiste
var body = document.body
if (l && l.sig === sig && l.host.parentNode === body) {
// unveraendert: nur dafuer sorgen, dass sie erstes Kind von body bleibt (die Seite kann danach etwas davorgesetzt haben)
if (body.firstChild !== l.host) {
body.insertBefore(l.host, body.firstChild)
misstHinweis()
}
return
}
entferneHinweisLeiste()
var teile = neueLeiste('hinweis', hinweisCss())
if (!teile) return
var leiste = el('div', 'wkz-leiste wkz-' + art)
leiste.setAttribute('role', 'region')
leiste.setAttribute('aria-label', 'Hinweis')
leiste.appendChild(el('p', 'wkz-text', h.text))
if (knopf) leiste.appendChild(linkKnopf(knopf, 'wkz-knopf', false))
teile.wurzel.appendChild(leiste)
body.insertBefore(teile.host, body.firstChild)
hinweisLeiste = { host: teile.host, sig: sig, nav: nav, hoehe: 0, fest: [] }
sorgeFuerHoerer()
if (beobachter) beobachter.observe(teile.host)
misstHinweis()
}
function entferneHinweisLeiste () {
var l = hinweisLeiste
if (!l) return
hinweisLeiste = null
setzeFesteZurueck(l)
stelleScrollPaddingWieder('top')
entferneLeiste(l.host)
}
function zeigeKontaktLeiste (knoepfe) {
var sig = JSON.stringify(knoepfe)
var l = kontaktLeiste
var body = document.body
if (l && l.sig === sig && l.host.parentNode === body && l.abstand.parentNode === body) {
if (l.abstand.nextSibling) body.appendChild(l.abstand) // der Abstandhalter bleibt das Letzte in body
return
}
entferneKontaktLeiste()
var teile = neueLeiste('kontakt', kontaktCss())
if (!teile) return
var leiste = el('div', 'wkz-leiste')
leiste.setAttribute('role', 'region')
leiste.setAttribute('aria-label', 'Kontakt')
for (var i = 0; i < knoepfe.length; i++) leiste.appendChild(linkKnopf(knoepfe[i], 'wkz-knopf wkz-' + knoepfe[i].art, knoepfe[i].art !== 'anruf'))
teile.wurzel.appendChild(leiste)
teile.host.style.cssText = 'position:fixed;left:0;right:0;bottom:0;z-index:40;display:block;margin:0;padding:0;border:0'
var abstand = document.createElement('div')
abstand.setAttribute('data-wkz-abstand', '')
abstand.setAttribute('aria-hidden', 'true')
body.appendChild(teile.host)
body.appendChild(abstand)
kontaktLeiste = { host: teile.host, abstand: abstand, sig: sig }
sorgeFuerHoerer()
if (beobachter) beobachter.observe(teile.host)
misstKontakt()
}
function entferneKontaktLeiste () {
var l = kontaktLeiste
if (!l) return
kontaktLeiste = null
stelleScrollPaddingWieder('bottom')
entferneLeiste(l.host)
if (l.abstand.parentNode) l.abstand.parentNode.removeChild(l.abstand)
}
function beiHandyWechsel () { sicher(render) }
function istHandy () {
if (typeof window.matchMedia !== 'function') return false
if (!handyAbfrage) {
handyAbfrage = window.matchMedia(HANDY_MQ)
if (typeof handyAbfrage.addEventListener === 'function') handyAbfrage.addEventListener('change', beiHandyWechsel)
else if (typeof handyAbfrage.addListener === 'function') handyAbfrage.addListener(beiHandyWechsel)
}
return handyAbfrage.matches
}
// Die festen/klebenden Vorfahren aller sichtbaren tel:-Links, als Beschreibung fuer istEigeneAnrufLeiste
function eigeneAnrufLeiste () {
var links = document.querySelectorAll('a[href^="tel:"]')
var elemente = []
var fenster = window.innerHeight || 0
for (var i = 0; i < links.length && i < 50; i++) {
if (!links[i].getClientRects().length) continue // ausgeblendet
for (var n = links[i]; n && n !== document.documentElement; n = n.parentElement) {
var cs = getComputedStyle(n)
if (cs.position !== 'fixed' && cs.position !== 'sticky') continue
var r = n.getBoundingClientRect()
elemente.push({ position: cs.position, bottom: cs.bottom, oben: r.top, unten: r.bottom, fenster: fenster, sichtbar: cs.display !== 'none' && cs.visibility !== 'hidden' && r.width > 0 && r.height > 0 })
break
}
}
return istEigeneAnrufLeiste(elemente)
}
// Beide Leisten aus denselben Daten (d = null: Fehler, aktiv: false oder Daten zu alt: keine Leiste). Hinweis: der gueltige mit dem hoechsten
// Rang, nicht bei team.hinweisleisteAus. Kontakt: nur mit Daten, auf schmalen Schirmen, nicht bei team.kontaktleisteAus und nicht neben einer
// eigenen Anruf-Leiste der Seite.
function zeichneLeisten (d, t) {
var team = d && istObjekt(d.team) ? d.team : {}
sicher(function () {
var h = d && team.hinweisleisteAus !== true && document.body ? waehleHinweis(d.hinweise, t) : null
if (h && typeof h.text === 'string' && h.text !== '') zeigeHinweisLeiste(h, typeof team.navSelektor === 'string' ? team.navSelektor : '')
else entferneHinweisLeiste()
})
sicher(function () {
var knoepfe = d && team.kontaktleisteAus !== true && document.body ? kontaktKnoepfe(d.kontakt) : []
if (knoepfe.length && istHandy() && !eigeneAnrufLeiste()) zeigeKontaktLeiste(knoepfe)
else entferneKontaktLeiste()
})
}
// ---------------------------------------------------------------- JSON-LD
function ldSkripte () {
var alle = document.getElementsByTagName('script')
var liste = []
for (var i = 0; i < alle.length; i++) {
if (alle[i] !== ldEigenes && String(alle[i].getAttribute('type') || '').toLowerCase().replace(/\s+/g, '') === 'application/ld+json') liste.push(alle[i])
}
return liste
}
function seitenUrl () {
var o = window.location && window.location.origin
return typeof o === 'string' && o !== '' && o !== 'null' ? o + '/' : ''
}
// Zeiten ins JSON-LD der Seite: vorhandene Unternehmens-Bloecke bekommen nur die beiden Zeiten-Felder neu (Text per JSON.stringify), sonst legt das
// Skript einen eigenen Block an. Ohne jsonld in der Antwort (noch keine Zeiten gespeichert) bleibt die Seite voellig unberuehrt.
function zeichneJsonLd (d) {
var jd = d && istObjekt(d.jsonld) ? d.jsonld : null
if (!jd) return
var firma = istObjekt(d.firma) ? d.firma : {}
var seite = { name: firma.name, adresse: firma.adresse, url: seitenUrl() }
var sig = JSON.stringify([jd, seite])
if (sig === ldSig) return
var skripte = ldSkripte()
var bloecke = []
var i
for (i = 0; i < skripte.length; i++) {
try { bloecke.push(JSON.parse(skripte[i].textContent)) } catch (e) { bloecke.push(null) }
}
var erg = jsonLdEinbauen(bloecke, jd, seite)
for (i = 0; i < erg.geaendert.length; i++) skripte[erg.geaendert[i]].textContent = jsonLdText(bloecke[erg.geaendert[i]])
if (erg.neu) {
if (!ldEigenes) {
ldEigenes = document.createElement('script')
ldEigenes.setAttribute('type', 'application/ld+json')
ldEigenes.setAttribute('data-wkz-jsonld', '')
;(document.head || document.documentElement).appendChild(ldEigenes)
}
ldEigenes.textContent = jsonLdText(erg.neu)
} else if (ldEigenes) {
if (ldEigenes.parentNode) ldEigenes.parentNode.removeChild(ldEigenes)
ldEigenes = null
}
ldSig = sig
}
// ---------------------------------------------------------------- Timer
// Ein Timer auf den naechsten Wechsel (Status, Hinweis-Beginn oder -Ende) oder das naechste erlaubte Neuladen, was frueher kommt.
function plane () {
if (timerId) clearTimeout(timerId)
var t = jetzt()
var alle = []
if (daten) for (var i = 0; i < daten.plaene.length; i++) alle = alle.concat(daten.plaene[i].wechsel)
var naechster = naechsterWechsel(alle, daten ? daten.hinweise : [], t)
// laeuft ein Abruf, plant dessen Ende neu (sonst wuerde ein faelliges Neuladen den Timer auf 0 setzen)
var bisReload = laeuft ? Infinity : versuchMs + RELOAD_MS - Date.now()
var warte = naechster === null ? bisReload : Math.min(naechster - t, bisReload)
timerId = timer(tick, warte + TIMER_PUFFER_MS)
}
function tick () {
if (timerId) clearTimeout(timerId)
timerId = 0
if (!laeuft && Date.now() - versuchMs >= RELOAD_MS) {
lade() // zeichnet und plant danach neu
return
}
try { render() } finally { plane() }
}
// ---------------------------------------------------------------- Start
function eigenesSkript () {
return SELF || document.querySelector('script[data-wk-zeiten], script[src*="/embed/zeiten.js"]')
}
function bestimmeSiteKey (skript) {
var k = skript ? attr(skript, 'data-site') : ''
for (var i = 0; !k && i < platzhalter.length; i++) k = attr(platzhalter[i].root, 'data-webklar-zeiten')
return k
}
function bestimmeApi (skript) {
// nur das eigene Script-Tag (oder dessen Herkunft) bestimmt die Adresse: ein Platzhalter der Seite kann die Anfragen nicht umleiten
var a = skript ? attr(skript, 'data-api') : ''
a = a.replace(/\/+$/, '')
if (a) return a
if (skript && skript.src) {
try { return new URL(skript.src).origin } catch (e) { return '' }
}
return ''
}
// Einzelseiten-Apps (Vite/React) haengen Platzhalter erst nach einem Seitenwechsel ein. Ein Beobachter auf body (childList, subtree) sieht
// einen neuen (noch nicht eingetragenen) oder wieder eingehaengten Platzhalter und zeichnet nach, hoechstens alle BEOBACHTER_MS; er bleibt
// fuer die Lebensdauer der Seite (die eigenen Aenderungen des Skripts loesen nur diese billige Pruefung aus, nie ein Zeichnen).
function pruefeNeue () {
var neu = document.querySelector(SELEKTOR + ':not([data-wkz-init])')
for (var i = 0; !neu && i < getrennt.length; i++) if (getrennt[i].root.isConnected !== false) neu = getrennt[i].root
if (neu) render()
}
function beobachteSeite () {
if (typeof MutationObserver !== 'function' || !document.body) return
var wartet = false
new MutationObserver(function () {
if (wartet) return
wartet = true
timer(function () {
wartet = false
sicher(pruefeNeue)
}, BEOBACHTER_MS)
}).observe(document.body, { childList: true, subtree: true })
}
function start () {
sammle()
var skript = eigenesSkript()
siteKey = bestimmeSiteKey(skript)
api = bestimmeApi(skript)
if (!siteKey || !api || typeof fetch !== 'function') {
if (platzhalter.length) warne('siteKey (data-site) oder Adresse der Schnittstelle fehlt: die Platzhalter bleiben beim Ersatz-HTML.')
return
}
document.addEventListener('visibilitychange', function () {
if (document.visibilityState !== 'visible') return
// Zeit ist vergangen: ist ein Neuladen faellig, erst mit den alten Daten zeichnen (Hinweis, Status und die 24-Stunden-Grenze stimmen sofort), dann laden
if (!laeuft && Date.now() - versuchMs >= RELOAD_MS) {
try { render() } finally { lade() }
return
}
tick()
})
beobachteSeite()
lade()
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start)
else start()
})()