zeiten.js zeichnet mit denselben Daten wie die Platzhalter eine Hinweis-Leiste (Shadow DOM, erstes Kind von body, rückt feste und klebende Köpfe, scroll-padding-top), eine Kontakt-Leiste nur auf schmalen Schirmen (Abstandhalter, scroll-padding-bottom, entfällt bei eigener Anruf-Leiste) und ergänzt das JSON-LD der Seite (nur die beiden Zeiten-Felder, sonst eigener Block). Alle Adressen aus der Schnittstelle werden im Browser erneut geprüft. Ruling R29: tick mit try/finally, sofort werfender fetch, erst zeichnen dann laden beim Zurückkehren in den Tab. Tests: statische Funktionstests der reinen Funktionen und eine Playwright-Prüfung gegen den UI-Testserver (echte öffentliche Schnittstelle, Nachbau von Appwrite). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1076 lines
52 KiB
JavaScript
1076 lines
52 KiB
JavaScript
/*! 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
|
||
|
||
// @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 (sonst ''). 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
|
||
}
|
||
// @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 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) }) }
|
||
|
||
function render () {
|
||
sammle()
|
||
platzhalter = platzhalter.filter(function (p) { return p.root.isConnected !== false })
|
||
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)
|
||
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++) l.fest[i].el.style.setProperty('top', (l.fest[i].basis + s) + 'px', 'important')
|
||
}
|
||
|
||
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 = JSON.stringify(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 = JSON.stringify(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) {
|
||
var a = skript ? attr(skript, 'data-api') : ''
|
||
for (var i = 0; !a && i < platzhalter.length; i++) a = attr(platzhalter[i].root, 'data-api')
|
||
a = a.replace(/\/+$/, '')
|
||
if (a) return a
|
||
if (skript && skript.src) {
|
||
try { return new URL(skript.src).origin } catch (e) { return '' }
|
||
}
|
||
return ''
|
||
}
|
||
|
||
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()
|
||
})
|
||
lade()
|
||
}
|
||
|
||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start)
|
||
else start()
|
||
})()
|