/* Kundenansicht neu: Start und Zusammenbau der Hülle. Start über GET /api/auth/me (Anmeldung, Mitarbeiter, Kundenansicht), dann Konto, Streifen, Navigation, Etage 2, Zähler und Router. Der Router ruft zeige(route, { nav }) bei jedem Wechsel; die Hülle lädt die Ansicht über einen einspritzbaren Lader (Standard: import()) und ruft zeigeX(wurzel, { route, me, nav }) bzw. die Funktionsmodule wie bisher (init*(wurzel)). Kein DOM-Zugriff beim Import. */ import { h, leeren, text } from './dom.js' import { api as apiStandard } from './api.js' import * as routerStandard from './router.js' import { BEREICHE, FUNKTIONS_ETAGEN, hashFuerAnsicht, hashFuerRoute, elternVon, titelFuer, zeigtWiderrufsstreifen } from './routen.js' import { HANDY_ABFRAGE, baueNavigation, beobachteTastatur, leisteSichtbar } from './leiste.js' import { baueEtage } from './etage.js' import { erzeugeZaehler } from './zaehler.js' import { baueStreifen, ladeFristen, ladeFristenNeu } from './fristen-streifen.js' import { baueAvatar, baueKonto, installAngebot } from './konto.js' export const GERUEST_IDS = ['kn-banner', 'kn-ansage', 'kn-fehler', 'kn-menue', 'kn-kopf', 'kn-kopf-titel', 'kn-kopf-konto', 'kn-inhalt', 'bereich-website', 'bereich-funktionen', 'bereich-chat', 'bereich-vertraege', 'website-body', 'funktionen-body', 'chat-body', 'vertraege-body', 'kn-blatt', 'kn-brot', 'kn-etage-titel', 'kn-reiter-slot', 'news-body', 'termine-body', 'webshop-body', 'buchungen-body', 'zeiten-body', 'analyse-body', 'etage-body', 'kn-streifen', 'kn-leiste', 'kn-zeile-haupt', 'kn-zeile-etage', 'kn-konto-wurzel'] // Bereich → [Modul, Funktion, Wurzel]; die Ansicht heißt ansicht-.js und exportiert zeige export const ANSICHTEN = { website: ['/neu/ansicht-website.js', 'zeigeWebsite', 'website-body'], funktionen: ['/neu/ansicht-funktionen.js', 'zeigeFunktionen', 'funktionen-body'], chat: ['/neu/ansicht-chat.js', 'zeigeChat', 'chat-body'], vertraege: ['/neu/ansicht-vertraege.js', 'zeigeVertraege', 'vertraege-body'], } // Etagen mit eigener Ansicht; alle zeichnen in #etage-body export const ETAGE_ANSICHTEN = { projekt: ['/neu/ansicht-website.js', 'zeigeWebsite', 'etage-body'], passwort: ['/neu/ansicht-passwort.js', 'zeigePasswort', 'etage-body'], gutschrift: ['/neu/ansicht-gutschrift.js', 'zeigeGutschrift', 'etage-body'], wechsel: ['/neu/ansicht-wechsel.js', 'zeigeWechsel', 'etage-body'], bestellen: ['/neu/ansicht-bestellen.js', 'zeigeBestellen', 'etage-body'], } // Gemeinsame Funktionsmodule, wörtlich aus app.js:1938-1945 (Wurzel: der Behälter mit der alten ID) export const FUNKTIONS_MODULE = { news: ['/funktionen.js', 'initNews', 'news-body'], termine: ['/termine.js', 'initTermine', 'termine-body'], webshop: ['/shop.js', 'initShop', 'webshop-body'], buchungen: ['/buchungen.js', 'initBuchungen', 'buchungen-body'], zeiten: ['/zeiten.js', 'initZeiten', 'zeiten-body'], analyse: ['/analyse.js', 'initAnalyse', 'analyse-body'], } // Diese Module bekommen init*(wurzel, { einschalten }); die Analyse nutzt POST /api/funktionen/analyse/activate und bleibt init*(wurzel) export const MIT_EINSCHALTEN = ['news', 'termine', 'webshop', 'buchungen', 'zeiten'] /** * einschalten(platz, { key, projectId, onFertig, viewAs? }): lädt die Einschalt-Ansicht erst beim Aufruf; ein Ladefehler geht an das Modul zurück. * In der Mitarbeiter-Kundenansicht (viewAs der Sitzung) gilt immer viewAs true: Der Knopf ist gesperrt, auch wenn das Modul es nicht nennt. */ export const einschaltenOption = (lade, { viewAs = false } = {}) => async (platz, optionen) => { const modul = await lade('/neu/funktion-einschalten.js') return modul.zeigeEinschalten(platz, viewAs ? { ...optionen, viewAs: true } : optionen) } /** Antwort von /api/auth/me → weiterleiten (Anmeldung, Mitarbeiter, abgelaufen, Passwort geändert) oder zeigen (Kunde, Kundenansicht). */ export function leiteAb(me, { hash = '' } = {}) { if (me?.authenticated !== true || !me.customer) { // Alte Mitarbeiter-Sitzung: Mitarbeiter arbeiten im Ticketsystem if (me?.authenticated === false && me.reason === 'staff' && me.redirect) return { art: 'weiter', ziel: me.redirect } const suche = new URLSearchParams() if (hash && hash !== '#') suche.set('next', `/dashboard.html${hash}`) // nach dem Login zurück zur aufgerufenen Ansicht if (me?.authenticated === false && (me.reason === 'password_changed' || me.reason === 'view_expired') && me.message) suche.set('error', me.message) const anhang = suche.toString() return { art: 'weiter', ziel: `/login.html${anhang ? `?${anhang}` : ''}` } } const kunde = me.customer const viewAs = me.viewAs === true && Boolean(me.viewId) return { art: 'zeigen', kunde, viewAs, viewId: viewAs ? me.viewId : '', admin: me.admin ?? null, anzeigename: kunde.companyName || kunde.name || kunde.email || '' } } /** Wohin scrollt der Wechsel? push beginnt oben, same scrollt weich nach oben, back und tab stellen den gemerkten Stand wieder her. */ export function scrollZiel(nav, gemerkt) { if (nav === 'push') return { top: 0, weich: false } if (nav === 'same') return { top: 0, weich: true } return { top: Number.isFinite(gemerkt) ? gemerkt : 0, weich: false } } export async function starteHuelle({ win = window, doc = document, api = apiStandard, lade = (pfad) => import(pfad), router = routerStandard, hash = win.location.hash } = {}) { const el = (id) => doc.getElementById(id) const zeigeFehler = (nachricht) => { const f = el('kn-fehler'); text(f, nachricht); f.hidden = false } // 1. Wer ist da? Fehler oder fremder Zustand ergeben eine Weiterleitung oder eine Meldung, nie eine leere Seite. let me try { me = await api('/api/auth/me') } catch (fehler) { if (fehler?.status === 401 || /Nicht angemeldet/.test(fehler?.message ?? '')) win.location.href = leiteAb({ authenticated: false }, { hash }).ziel else zeigeFehler(fehler?.message || 'Der Kundenbereich konnte nicht geladen werden.') return } const start = leiteAb(me, { hash }) if (start.art === 'weiter') { win.location.href = start.ziel; return } const { kunde, viewAs, viewId, admin, anzeigename } = start // Kundenansicht: ab jetzt trägt jede /api-Anfrage die Kennung dieser Ansicht (public/kundenansicht.js) if (viewAs) win.KbView?.setId(viewId) if (viewAs) baueBanner() // 2. Bausteine const handyMq = win.matchMedia(HANDY_ABFRAGE) const istHandy = () => handyMq.matches const reduziert = () => Boolean(win.matchMedia('(prefers-reduced-motion: reduce)').matches) const standalone = () => win.matchMedia('(display-mode: standalone)').matches || win.navigator?.standalone === true const istIos = () => /iphone|ipad|ipod/i.test(win.navigator?.userAgent ?? '') || (win.navigator?.platform === 'MacIntel' && win.navigator?.maxTouchPoints > 1) let fristen = null let installPrompt = null const installStand = () => installAngebot({ handy: istHandy(), standalone: standalone(), prompt: Boolean(installPrompt), ios: istIos() }) const avatar = baueAvatar({ name: anzeigename, kundenId: kunde.id }) el('kn-kopf-konto').append(avatar) const konto = baueKonto({ me, fristen, install: installStand(), onAbmelden: async () => { await api('/api/auth/logout', { method: 'POST' }).catch(() => {}); win.location.href = '/login.html' }, onInstall: async () => { const prompt = installPrompt if (!prompt) return installPrompt = null await prompt.prompt().catch(() => {}) aktualisiereKonto() }, overlay: { oeffne: (o) => router.oeffneOverlay(o), beende: () => router.beendeOverlay() }, navigiere: (ziel) => router.geheZu(ziel), }) el('kn-konto-wurzel').append(konto.wurzel) avatar.addEventListener('click', () => (konto.istOffen() ? konto.schliesse() : konto.oeffne(avatar))) const aktualisiereKonto = () => konto.aktualisiere({ fristen, install: installStand() }) const streifen = baueStreifen(el('kn-streifen'), { koerper: doc.body }) streifen.setze(null) const navigation = baueNavigation({ haupt: el('kn-zeile-haupt'), menue: el('kn-menue'), onWahl: (bereich) => router.geheZu(hashFuerRoute({ bereich })) }) const etage = baueEtage({ blatt: el('kn-blatt'), haupt: el('kn-inhalt'), zeile: el('kn-zeile-etage'), titel: el('kn-etage-titel'), brot: el('kn-brot'), reiterSlot: el('kn-reiter-slot'), istHandy, onZurueck: () => router.zurueck() }) let aktuell = null // die Route, die gerade gezeigt wird let herkunft = '' // Hash der Route davor (Rückweg der Passwort-Seite) const zaehler = erzeugeZaehler({ api, onChange: (stand) => { navigation.setzeZaehler(stand); etage.setzeChatPunkt(stand.chat > 0) }, sichtbar: () => doc.visibilityState !== 'hidden', imChat: () => aktuell?.bereich === 'chat', }) function baueBanner() { const banner = el('kn-banner') const beenden = async () => { const antwort = await api('/api/auth/kundenansicht/ende', { method: 'POST' }).catch(() => ({})) win.location.href = antwort?.redirect || 'https://ticket.webklar.com/customers' } leeren(banner).append(h('span', null, 'Kundenansicht: ', h('strong', null, kunde.name || 'Kunde'), ` · geöffnet von ${admin?.name || 'Mitarbeiter'}`), h('button', { type: 'button', on: { click: beenden } }, 'Beenden')) banner.hidden = false // Höhe des Banners für Kopf, Menü, Blatt und Konto (E-Fix M4: --kn-banner-h). Der Banner bleibt oben stehen (sticky) und wird am Handy zweizeilig. const setzeHoehe = () => doc.body.style?.setProperty?.('--kn-banner-h', `${banner.offsetHeight || 0}px`) setzeHoehe() if (typeof win.ResizeObserver === 'function') new win.ResizeObserver(setzeHoehe).observe(banner) else win.addEventListener('resize', setzeHoehe) } // 3. Scroll: Stand je Route merken, Kopf (Rand, kleiner Titel) nachführen const merker = new Map() const schluessel = (route) => hashFuerRoute({ ...route, query: '' }) const blattScrollt = (route) => Boolean(route?.etage) && istHandy() // am Handy scrollt das Blatt, sonst das Fenster const leseScroll = (route) => (blattScrollt(route) ? el('kn-blatt').scrollTop : (win.scrollY ?? 0)) function setzeScroll(route, ziel) { const verhalten = ziel.weich && !reduziert() ? 'smooth' : 'auto' const flaeche = blattScrollt(route) ? el('kn-blatt') : win if (typeof flaeche.scrollTo === 'function') flaeche.scrollTo({ top: ziel.top, behavior: verhalten }) else if (flaeche !== win) flaeche.scrollTop = ziel.top } function aktualisiereKopf() { if (!aktuell) return doc.body.classList.toggle('kn-scrolled', leseScroll(aktuell) > 4) const titel = aktuell.etage ? el('kn-etage-titel') : el(`bereich-${aktuell.bereich}`)?.querySelector('h1') const kopf = el('kn-kopf') const verdeckt = titel && kopf && typeof titel.getBoundingClientRect === 'function' ? titel.getBoundingClientRect().bottom < kopf.getBoundingClientRect().bottom + 4 : false doc.body.classList.toggle('kn-titled', verdeckt) } const frame = (fn) => (win.requestAnimationFrame ? win.requestAnimationFrame(fn) : fn()) let scrollGeplant = false const beiScroll = () => { if (scrollGeplant) return scrollGeplant = true frame(() => { scrollGeplant = false; if (aktuell) merker.set(schluessel(aktuell), leseScroll(aktuell)); aktualisiereKopf() }) } // 4. Zeigen: Bereich oder Etage, Navigation, Titel; dann die Ansicht laden (nicht abwarten) let erstesZeigen = true function zeige(route, { nav }) { if (aktuell) merker.set(schluessel(aktuell), leseScroll(aktuell)) if (aktuell && hashFuerRoute(aktuell) !== hashFuerRoute(route)) herkunft = hashFuerRoute(aktuell) aktuell = route const titel = titelFuer(route) for (const bereich of BEREICHE) el(`bereich-${bereich}`).hidden = bereich !== route.bereich if (route.etage) { const eltern = elternVon(route, herkunft) etage.oeffne({ titel, eltern: { label: titelFuer(eltern), hash: hashFuerRoute(eltern) }, seite: FUNKTIONS_ETAGEN.includes(route.etage) ? route.etage : 'seite' }) } else etage.schliesse() navigation.setzeAktiv(route.bereich) navigation.setzeEtage(Boolean(route.etage)) streifen.erlaube(zeigtWiderrufsstreifen(route)) // Bestellseiten: Kerninfo und Knopf auf einem Bildschirm, ohne den festen Streifen text(el('kn-kopf-titel'), titel) text(el('kn-ansage'), titel) if (route.bereich === 'chat') zaehler.setzeChat(0) if (route.bereich === 'vertraege' && !erstesZeigen) zaehler.aktualisiere('vertraege') // beim ersten Zeigen lädt zaehler.start() ohnehin alles erstesZeigen = false return ladeAnsicht(route, nav) } async function ladeAnsicht(route, nav) { const modul = route.etage ? FUNKTIONS_MODULE[route.etage] : null const eintrag = modul ?? (route.etage ? ETAGE_ANSICHTEN[route.etage] : ANSICHTEN[route.bereich]) if (!eintrag) return const [pfad, name, wurzelId] = eintrag const wurzel = el(wurzelId) // #etage-body dient mehreren Etagen: beim Wechsel der Etage den Rest der vorigen wegräumen if (wurzelId === 'etage-body' && wurzel.dataset.knEtage !== route.etage) { leeren(wurzel); wurzel.dataset.knEtage = route.etage } if (!wurzel.firstChild) wurzel.append(h('p', { class: 'kn-leer' }, 'Wird geladen …')) const gemerkt = merker.get(schluessel(route)) const ziel = scrollZiel(nav, gemerkt) setzeScroll(route, ziel) aktualisiereKopf() try { const geladen = await lade(pfad) if (modul) { const projekt = new URLSearchParams(route.query).get('projekt') if (projekt) wurzel.dataset.wunschProjekt = projekt if (MIT_EINSCHALTEN.includes(route.etage)) await geladen[name](wurzel, { einschalten: einschaltenOption(lade, { viewAs }) }) else await geladen[name](wurzel) router.stempeln() // die Module löschen mit replaceState(null, …) den Verlaufsstempel } else { await geladen[name](wurzel, { route, me, nav }) } } catch { leeren(wurzel).append(h('p', { class: 'kn-fehler' }, 'Konnte nicht geladen werden.')) } if (ziel.top > 0 && aktuell === route) setzeScroll(route, ziel) // wiederherstellen, sobald der Inhalt da ist aktualisiereKopf() } // 5. Titel aus dem Inhalt der Etage (kn:titel) auch im Kopf und in der Ansage el('kn-blatt').addEventListener('kn:titel', (ev) => { if (!aktuell?.etage || !ev.detail?.text) return text(el('kn-kopf-titel'), String(ev.detail.text)) text(el('kn-ansage'), String(ev.detail.text)) }) // 6. Hörer try { win.history.scrollRestoration = 'manual' } catch { /* ohne Verlaufssteuerung bleibt der Browser-Standard */ } doc.body.addEventListener('click', (ev) => { // die data-view-Brücke der gemeinsamen Module const ziel = ev.target?.closest?.('[data-view]') if (!ziel || ziel.disabled) return let ansicht = hashFuerAnsicht(ziel.getAttribute('data-view')) if (ansicht === null) return ev.preventDefault() const projekt = ziel.getAttribute('data-project') if (projekt) ansicht += `${ansicht.includes('?') ? '&' : '?'}projekt=${encodeURIComponent(projekt)}` router.geheZu(ansicht) }) doc.querySelector?.('.kn-skip')?.addEventListener('click', (ev) => { ev.preventDefault(); el('kn-inhalt').focus() }) // kein Fragment-Link: der Router läse #kn-inhalt als Route // Widerrufsstreifen und Pille „Frist läuft“ (E-Fix M1): nach einem Kauf, Wechsel oder Speicher-Plus in der Sitzung und beim Zurückkehren in den Tab neu laden, // damit eine neue Frist erscheint und ein abgelaufener Streifen nicht stehen bleibt. Ein Fehler lässt den bisherigen Stand stehen; die jüngste Antwort gilt. let fristenLauf = 0 async function frischeFristen() { const nr = ++fristenLauf const daten = await ladeFristenNeu(api) if (nr !== fristenLauf || daten === undefined) return fristen = daten streifen.setze(daten) aktualisiereKonto() } doc.addEventListener('kn:zaehler-neu', (ev) => { const was = ev.detail?.was ?? 'alle' zaehler.aktualisiere(was) if (was === 'vertraege' || was === 'alle') frischeFristen() }) doc.addEventListener('visibilitychange', () => { if (doc.visibilityState === 'visible') { zaehler.aktualisiere('alle'); frischeFristen() } }) win.addEventListener('scroll', beiScroll, { passive: true }) el('kn-blatt').addEventListener('scroll', beiScroll, { passive: true }) win.addEventListener('beforeinstallprompt', (ev) => { ev.preventDefault(); installPrompt = ev; aktualisiereKonto() }) win.addEventListener('appinstalled', () => { installPrompt = null; aktualisiereKonto() }) const beiMedien = () => { etage.neuZeichnen(); aktualisiereKonto(); aktualisiereKopf() } if (handyMq.addEventListener) handyMq.addEventListener('change', beiMedien) else handyMq.addListener?.(beiMedien) beobachteTastatur({ win, doc, onChange: (z) => doc.body.classList.toggle('kn-tastatur', !leisteSichtbar(z)) }) // 7. Fristen und Zähler laden parallel und brechen den Start nie ab ladeFristen(api).then((daten) => { fristen = daten; streifen.setze(daten); aktualisiereKonto() }) zaehler.start() // 8. Router starten (ruft zeige einmal mit der Startroute), Service Worker für Installation und Offline-Seite router.starteRouter({ zeige }) if (win.navigator?.serviceWorker && win.isSecureContext) { const registriere = () => win.navigator.serviceWorker.register('/sw.js').catch((fehler) => console.warn('Service Worker:', fehler.message)) if (doc.readyState === 'complete') registriere() else win.addEventListener('load', registriere) } } if (typeof document !== 'undefined' && document.body?.classList.contains('kn') && document.getElementById('kn-leiste')) { starteHuelle().catch((fehler) => { console.error(fehler) const f = document.getElementById('kn-fehler') if (f) { f.textContent = 'Der Kundenbereich konnte nicht gestartet werden. Bitte lade die Seite neu.'; f.hidden = false } }) }