From 75958441708e7683e6311f65aefbbf539346e54a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 16:24:16 +0000 Subject: [PATCH] =?UTF-8?q?Kundenansicht=20neu=20P1:=20H=C3=BClle=20mit=20?= =?UTF-8?q?Start=20=C3=BCber=20/api/auth/me=20und=20Modul-Lader?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- public/neu/huelle.js | 276 ++++++++++++++++++++++++++++++++++++ public/neu/leiste.js | 3 +- public/neu/reiter.js | 3 + public/neu/zaehler.js | 2 +- test/knHuelleReiter.test.js | 18 +++ test/knHuelleStart.test.js | 123 ++++++++++++++++ 6 files changed, 423 insertions(+), 2 deletions(-) create mode 100644 public/neu/huelle.js create mode 100644 test/knHuelleStart.test.js diff --git a/public/neu/huelle.js b/public/neu/huelle.js new file mode 100644 index 0000000..390aa93 --- /dev/null +++ b/public/neu/huelle.js @@ -0,0 +1,276 @@ +/* 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 } 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. */ +export const einschaltenOption = (lade) => async (platz, optionen) => { + const modul = await lade('/neu/funktion-einschalten.js') + return modul.zeigeEinschalten(platz, 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 + } + + // 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) }) + 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 + doc.addEventListener('kn:zaehler-neu', (ev) => { zaehler.aktualisiere(ev.detail?.was ?? 'alle') }) + doc.addEventListener('visibilitychange', () => { if (doc.visibilityState === 'visible') zaehler.aktualisiere('alle') }) + 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 } + }) +} diff --git a/public/neu/leiste.js b/public/neu/leiste.js index 63715bc..0cb2738 100644 --- a/public/neu/leiste.js +++ b/public/neu/leiste.js @@ -98,7 +98,8 @@ export function beobachteTastatur({ win = window, doc = document, onChange }) { } const beiFokus = (ev) => wende({ art: 'fokus', textfeld: istTextfeld(ev.target), grob: grob() }) // Nach dem Blur kurz warten: wechselt der Fokus von Feld zu Feld, bleibt die Tastatur - const beiUnfokus = () => win.setTimeout(() => wende({ art: 'unfokus', textfeldAktiv: istTextfeld(doc.activeElement) }), 80) + const warte = (fn, ms) => (win.setTimeout ? win.setTimeout(fn, ms) : setTimeout(fn, ms)) + const beiUnfokus = () => warte(() => wende({ art: 'unfokus', textfeldAktiv: istTextfeld(doc.activeElement) }), 80) const beiVv = () => wende({ art: 'vv', hoehe: vv.height, breite: vv.width, textfeldAktiv: istTextfeld(doc.activeElement), grob: grob() }) doc.addEventListener('focusin', beiFokus) doc.addEventListener('focusout', beiUnfokus) diff --git a/public/neu/reiter.js b/public/neu/reiter.js index f2c9948..3bb6bdd 100644 --- a/public/neu/reiter.js +++ b/public/neu/reiter.js @@ -94,8 +94,11 @@ export function baueReiter({ eintraege, aktiv, art = 'unterstrich', ariaLabel = const rect = (el) => el.getBoundingClientRect() const reduziert = () => Boolean(globalThis.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches) + let platziert = false function platziere(animiert) { if (!kannMessen()) return + // Die erste Platzierung springt (der Gleiter fährt beim Laden nicht aus der Ecke ein), erst die folgenden federn + if (!platziert) { animiert = false; platziert = true } const bezug = rect(basisEl ?? wurzel) const i = liste.findIndex((e) => e.key === aktuell) if (balken) { diff --git a/public/neu/zaehler.js b/public/neu/zaehler.js index 0f08030..32923b0 100644 --- a/public/neu/zaehler.js +++ b/public/neu/zaehler.js @@ -20,7 +20,7 @@ export function berechneVertraege({ rechnungen = [], rueckfragen = [], paymentPr return { zahl: offen + fragen, ausruf: paymentProblem === true } } -export function erzeugeZaehler({ api, onChange = () => {}, sichtbar = () => true, imChat = () => false, zeitgeber = { setze: (fn, ms) => setInterval(fn, ms), loesche: (id) => clearInterval(id) } }) { +export function erzeugeZaehler({ api, onChange = () => {}, sichtbar = () => true, imChat = () => false, zeitgeber = { setze: (fn, ms) => { const id = setInterval(fn, ms); id.unref?.(); return id }, loesche: (id) => clearInterval(id) } }) { const daten = { chat: 0, rechnungen: [], rueckfragen: [], abo: false } const lauf = {} // je Quelle die Nummer der jüngsten Anfrage: eine ältere, später eintreffende Antwort gilt nicht let letzte = '' diff --git a/test/knHuelleReiter.test.js b/test/knHuelleReiter.test.js index df22af9..6f8685e 100644 --- a/test/knHuelleReiter.test.js +++ b/test/knHuelleReiter.test.js @@ -84,3 +84,21 @@ test('messen: Gleiter, Overlay-Gegenbewegung, Schriftstufe, Rückfall und Sichtb r.setzeAktiv('zzz'); assert.equal(gleiter.style.width, '0px') } finally { fx.restore() } }) + +test('messen: die erste Platzierung springt, erst die folgenden Wechsel federn', () => { + const fx = installFakeDom() + try { + const r = baueReiter({ eintraege: ['a', 'b'].map((key) => ({ key, label: key })), aktiv: 'a', art: 'balken', bei: () => {} }) + fx.document.body.append(r) + const gleiter = fx.finde(r, '.kn-gleiter'); const overlay = fx.finde(r, '.kn-overlay'); const basis = fx.finde(r, '.kn-balken-basis'); const tabs = fx.alle(r, '[role=tab]') + const verlauf = [] + gleiter.style = new Proxy({}, { set(ziel, name, wert) { if (name === 'transition') verlauf.push(wert); ziel[name] = wert; return true } }); overlay.style = {}; gleiter.offsetWidth = 0 + r.clientWidth = 200; r.scrollLeft = 0; basis.scrollWidth = 200 + r.getBoundingClientRect = () => ({ left: 0, width: 200 }); basis.getBoundingClientRect = () => ({ left: 0, width: 200 }) + tabs.forEach((t, i) => { t.getBoundingClientRect = () => ({ left: i * 100, width: 100 }) }) + fx.alle(overlay, '.kn-t').forEach((k) => { k.getBoundingClientRect = () => ({ left: 0, width: 100 }) }) + r.setzeAktiv('b'); assert.deepEqual(verlauf, ['none', '']) // erste Platzierung: ohne Übergang + r.setzeAktiv('a'); assert.deepEqual(verlauf, ['none', '']) // danach mit Übergang (die Feder des CSS läuft) + assert.equal(gleiter.style.transform, 'translateX(0px)') + } finally { fx.restore() } +}) diff --git a/test/knHuelleStart.test.js b/test/knHuelleStart.test.js new file mode 100644 index 0000000..c8ba38e --- /dev/null +++ b/test/knHuelleStart.test.js @@ -0,0 +1,123 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { installFakeDom } from './support/fakeDom.js' +import { h } from '../public/neu/dom.js' +import { routeAusHash } from '../public/neu/routen.js' +import { leiteAb, scrollZiel, GERUEST_IDS, starteHuelle } from '../public/neu/huelle.js' + +const ME_KUNDE = { authenticated: true, role: 'customer', customer: { id: 'c1', name: 'Anna', companyName: '', email: 'a@b.de' } } +const ME_VIEW = { ...ME_KUNDE, viewAs: true, viewId: 'v1', admin: { name: 'Kenso' } } +const KARTE_KUNDE = { '/api/auth/me': ME_KUNDE, '/api/chat/unread-count': { unread: 0 }, '/api/invoices': { invoices: [] }, '/api/abo': { subscription: null }, '/api/abo/takeover-credits': { requests: [] } } + +// Hilfen: jede ID des Gerüsts als div am Körper, ein Fenster aus dem fakeDom, ein api mit Karte, ein Router zum Mitschreiben +function baueGeruest(fx) { for (const id of GERUEST_IDS) fx.document.body.append(h('div', { id })) } +async function boot({ karte, hash = '', lade = async () => new Proxy({}, { get: () => async () => {} }) } = {}) { + const fx = installFakeDom(); baueGeruest(fx); const log = [] + const win = Object.assign(fx.window, { matchMedia: () => ({ matches: true, addEventListener() {} }), KbView: { setId: (id) => log.push(['setId', id]) } }) + win.location.hash = hash + const ziel = new EventTarget() + const doc = Object.assign(fx.document, { addEventListener: (...a) => ziel.addEventListener(...a), removeEventListener: (...a) => ziel.removeEventListener(...a), dispatchEvent: (ev) => ziel.dispatchEvent(ev) }) + const api = async (pfad, opt = {}) => { + log.push(['api', pfad, opt.method ?? 'GET']) + if (!(pfad in karte)) throw Object.assign(new Error('Nicht gefunden'), { status: 404 }) + if (karte[pfad] instanceof Error) throw karte[pfad] + return karte[pfad] + } + let zeigeFn + const router = { + starteRouter: ({ zeige }) => { zeigeFn = zeige; zeige(routeAusHash(hash), { nav: 'tab' }); return { beende() {} } }, + geheZu: (hashNeu) => log.push(['geheZu', hashNeu]), stempeln: () => log.push(['stempeln']), zurueck() {}, oeffneOverlay() {}, beendeOverlay() {}, + } + await starteHuelle({ win, doc, api, lade, router, hash }); await fx.ruhe() + return { fx, win, doc, log, el: (id) => fx.document.getElementById(id), zeige: async (hashNeu, nav) => { zeigeFn(routeAusHash(hashNeu), { nav }); await fx.ruhe() } } +} + +test('leiteAb: Anmeldung, Mitarbeiter, abgelaufen, Passwort geändert, Kundenansicht (wie app.js:2043-2064)', () => { + assert.deepEqual(leiteAb({ authenticated: false }, { hash: '' }), { art: 'weiter', ziel: '/login.html' }) + assert.deepEqual(leiteAb({ authenticated: false }, { hash: '#chat' }), { art: 'weiter', ziel: '/login.html?next=%2Fdashboard.html%23chat' }) + assert.deepEqual(leiteAb({ authenticated: false, reason: 'staff', redirect: 'https://ticket.webklar.com' }, {}), { art: 'weiter', ziel: 'https://ticket.webklar.com' }) + assert.equal(leiteAb({ authenticated: false, reason: 'view_expired', message: 'Bitte neu anmelden.' }, { hash: '#chat' }).ziel, '/login.html?next=%2Fdashboard.html%23chat&error=Bitte+neu+anmelden.') + assert.equal(leiteAb({ authenticated: false, reason: 'password_changed', message: 'Bitte neu anmelden.' }, {}).ziel, '/login.html?error=Bitte+neu+anmelden.') + assert.equal(leiteAb({ authenticated: true }, {}).ziel, '/login.html') + const z = leiteAb(ME_VIEW, {}); assert.deepEqual([z.art, z.viewAs, z.viewId, z.anzeigename], ['zeigen', true, 'v1', 'Anna']) +}) + +test('scrollZiel: push beginnt oben, same scrollt weich nach oben, back und tab stellen wieder her', () => { + assert.deepEqual([scrollZiel('push', 120), scrollZiel('same', 120), scrollZiel('back', 120), scrollZiel('tab', undefined)], [{ top: 0, weich: false }, { top: 0, weich: true }, { top: 120, weich: false }, { top: 0, weich: false }]) +}) + +test('Kundenansicht: setId vor jedem weiteren API-Aufruf, Banner, Beenden', async () => { + const b = await boot({ hash: '#chat', karte: { ...KARTE_KUNDE, '/api/auth/me': ME_VIEW, '/api/auth/kundenansicht/ende': { redirect: 'https://ticket.webklar.com/customers/c1' } } }) + assert.deepEqual(b.log.slice(0, 2), [['api', '/api/auth/me', 'GET'], ['setId', 'v1']]) + assert.equal(b.fx.text(b.el('kn-banner')), 'Kundenansicht: Anna · geöffnet von KensoBeenden') + await b.fx.klick(b.fx.finde(b.el('kn-banner'), 'button')); assert.equal(b.win.location.href, 'https://ticket.webklar.com/customers/c1') +}) + +test('abgemeldet leitet um ohne weitere Aufrufe; me 500 zeigt eine Meldung statt leerer Seite (Review Focus 5)', async () => { + const a = await boot({ hash: '#chat', karte: { '/api/auth/me': { authenticated: false } } }) + assert.equal(a.win.location.href, '/login.html?next=%2Fdashboard.html%23chat'); assert.equal(a.log.filter((l) => l[0] === 'api').length, 1) + const f = await boot({ hash: '', karte: { '/api/auth/me': Object.assign(new Error('Fehler 500'), { status: 500 }) } }) + assert.deepEqual([f.el('kn-fehler').hidden, f.el('kn-fehler').textContent], [false, 'Fehler 500']) +}) + +test('Route Chat: Ansicht bekommt Wurzel, route, me, nav; Navigation, Titel, Ansage folgen', async () => { + const gerufen = [] + const b = await boot({ hash: '#chat', karte: KARTE_KUNDE, lade: async (p) => ({ zeigeChat: (w, c) => gerufen.push([p, w.id, c.route, c.nav, c.me.customer.id]) }) }) + assert.deepEqual(gerufen, [['/neu/ansicht-chat.js', 'chat-body', { bereich: 'chat', query: '' }, 'tab', 'c1']]) + assert.deepEqual([b.el('bereich-chat').hidden, b.el('bereich-website').hidden, b.el('kn-blatt').hidden, b.el('kn-kopf-titel').textContent, b.el('kn-ansage').textContent], [false, true, true, 'Chat', 'Chat']) +}) + +test('Ladezustand als Text, solange die Ansicht lädt', async () => { + const b = await boot({ hash: '#chat', karte: KARTE_KUNDE, lade: () => new Promise(() => {}) }) + assert.equal(b.el('chat-body').textContent, 'Wird geladen …') +}) + +test('Etage Terminplaner: wunschProjekt aus ?projekt=, Modul auf #termine-body mit Option einschalten, danach stempeln, Chat-Punkt am Zurück; Ladefehler zeigt Text', async () => { + const seen = [] + const b = await boot({ hash: '#termine?bestellt=1&projekt=p7', karte: { ...KARTE_KUNDE, '/api/chat/unread-count': { unread: 3 } }, lade: async () => ({ initTermine: (root, opt) => seen.push([root.id, root.dataset.wunschProjekt, typeof opt?.einschalten]) }) }) + assert.deepEqual(seen, [['termine-body', 'p7', 'function']]); assert.ok(b.log.some((l) => l[0] === 'stempeln')) + assert.deepEqual([b.el('kn-blatt').hidden, b.el('kn-etage-titel').textContent, Boolean(b.fx.finde(b.el('kn-zeile-etage'), '.kn-punkt'))], [false, 'Terminplaner', true]) + const x = await boot({ hash: '#webshop', karte: KARTE_KUNDE, lade: async () => { throw new Error('404') } }) + assert.equal(x.el('webshop-body').textContent, 'Konnte nicht geladen werden.') +}) + +test('Option einschalten: news, termine, webshop, buchungen und zeiten bekommen sie, analyse nicht; der Aufruf lädt funktion-einschalten.js erst dann', async () => { + const MODULE = { news: ['/funktionen.js', 'initNews'], termine: ['/termine.js', 'initTermine'], webshop: ['/shop.js', 'initShop'], buchungen: ['/buchungen.js', 'initBuchungen'], zeiten: ['/zeiten.js', 'initZeiten'], analyse: ['/analyse.js', 'initAnalyse'] } + for (const [etage, [pfad, init]] of Object.entries(MODULE)) { + const geladen = []; const aufrufe = [] + const lade = async (p) => { geladen.push(p); return p === '/neu/funktion-einschalten.js' ? { zeigeEinschalten: (platz, o) => aufrufe.push(['zeige', platz, o]) } : { [init]: (root, opt) => aufrufe.push(['init', root.id, opt]) } } + await boot({ hash: `#${etage}`, karte: KARTE_KUNDE, lade }) + const aufruf = aufrufe.find((a) => a[0] === 'init'); assert.ok(geladen.includes(pfad), pfad); assert.equal(aufruf[1], `${etage}-body`) + if (etage === 'analyse') { assert.equal(aufruf[2], undefined, 'analyse bekommt keine Option'); continue } + assert.equal(typeof aufruf[2].einschalten, 'function', etage); assert.equal(geladen.includes('/neu/funktion-einschalten.js'), false, `${etage}: lazy`) + const platz = { id: 'platz' }; await aufruf[2].einschalten(platz, { key: etage, projectId: 'p1' }) + assert.ok(geladen.includes('/neu/funktion-einschalten.js')); assert.deepEqual(aufrufe.at(-1), ['zeige', platz, { key: etage, projectId: 'p1' }]) + } + let versprechen // Ladefehler der Datei geht an das Modul (es zeigt E4), die Hülle schluckt ihn nicht + await boot({ hash: '#termine', karte: KARTE_KUNDE, lade: async (p) => { if (p.endsWith('funktion-einschalten.js')) throw new Error('404'); return { initTermine: (_w, o) => { versprechen = o.einschalten({}, {}); versprechen.catch(() => {}) } } } }) + await assert.rejects(versprechen, /404/) +}) + +test('data-view-Brücke der Module: Name zu Hash, data-project wird ?projekt=', async () => { + const b = await boot({ hash: '#termine', karte: KARTE_KUNDE, lade: async () => ({ initTermine() {} }) }) + const chat = h('button', { 'data-view': 'chat' }); const shop = h('button', { 'data-view': 'webshop', 'data-project': 'p1' }); b.el('termine-body').append(chat, shop) + await b.fx.klick(chat); await b.fx.klick(shop) + assert.deepEqual(b.log.filter((l) => l[0] === 'geheZu').map((l) => l[1]), ['#chat', '#webshop?projekt=p1']) +}) + +test('Fristen 404 (Server ohne P5): kein Streifen, "Vertrag widerrufen" bleibt ohne Pille; mit Frist Streifen und Pille', async () => { + const b = await boot({ hash: '', karte: KARTE_KUNDE }) + assert.equal(b.el('kn-streifen').hidden, true) + const konto = b.fx.text(b.el('kn-konto-wurzel')); assert.match(konto, /Vertrag widerrufen/); assert.doesNotMatch(konto, /Frist läuft/) + const p = await boot({ hash: '', karte: { ...KARTE_KUNDE, '/api/fristen': { fristen: [{ quelle: 'abschluss', bezeichnung: 'Abo', bis: '2026-10-21T21:59:59.999Z' }], fruehestesEnde: '2026-10-21T21:59:59.999Z', anzeigen: true } } }) + assert.deepEqual([p.el('kn-streifen').hidden, p.fx.text(p.el('kn-streifen'))], [false, 'Widerrufsfrist bis 21.10. · Widerrufen']); assert.match(p.fx.text(p.el('kn-konto-wurzel')), /Frist läuft/) +}) + +test('Zähler: Verträge laden beim Betreten und bei kn:zaehler-neu (document) neu; der Chat-Zähler fällt beim Öffnen des Chats weg', async () => { + const b = await boot({ hash: '', karte: { ...KARTE_KUNDE, '/api/chat/unread-count': { unread: 3 } } }); const n = (pfad) => b.log.filter((l) => l[1] === pfad).length + const i0 = n('/api/invoices'); const chatTab = () => b.fx.finde(b.el('kn-zeile-haupt'), '[role=tab][aria-label="Chat, 3 ungelesen"]') + assert.ok(chatTab()) + await b.zeige('#rechnungen', 'tab'); assert.equal(n('/api/invoices'), i0 + 1) + b.doc.dispatchEvent(new CustomEvent('kn:zaehler-neu', { detail: { was: 'vertraege' } })); await b.fx.ruhe(); assert.equal(n('/api/invoices'), i0 + 2) + await b.zeige('#chat', 'tab'); assert.equal(chatTab(), null) +})