Files
Webklar-Kundenbereich/public/neu/huelle.js
T
ClaudeandClaude Sonnet 5.5 f8962ffa50 Kundenansicht neu E-Fix M4: Mitarbeiter-Banner verschiebt Blatt und Menü nicht mehr unter den Kopf
Der Banner stand im Fluss (relative) und schob den Kopf nach unten, das am Handy feste Blatt begann aber fest bei der Kopfhöhe: es deckte den
unteren Teil des Kopfs samt Profilbild ab (Konto und Abmelden nicht erreichbar); am Desktop blieb das Menü bei fest 36 px stehen, der Banner
ist aber 40 px hoch und beim Scrollen blieb eine Lücke. Jetzt bleibt der Banner oben stehen (sticky), huelle.js setzt --kn-banner-h auf seine
Höhe (auch bei Umbruch am Handy), und Kopf, Menü, Blatt und Konto-Popover beginnen darunter. Werkzeug-Ansicht kn-huelle-banner-blatt.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 21:43:20 +00:00

300 lines
17 KiB
JavaScript

/* 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 } 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-<name>.js und exportiert zeige<Name>
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))
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 }
})
}