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>
300 lines
17 KiB
JavaScript
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 }
|
|
})
|
|
}
|