diff --git a/public/neu/ansicht-website.js b/public/neu/ansicht-website.js index 26d5f5e..5e53862 100644 --- a/public/neu/ansicht-website.js +++ b/public/neu/ansicht-website.js @@ -1,7 +1,133 @@ -/* Kundenansicht neu: Platzhalter, den das nächste Paket füllt (Stub aus P1). */ +/* Kundenansicht neu (P2): Bereich Website. Projektliste mit Vorschau, offene Rechnung, Installationshinweis und Aktivität; + als Etage "projekt" die Projektdetails (#projekt?projekt=). Gezeichnet wird mit h() und Textknoten. */ -import { h } from './dom.js' +import { api } from './api.js' +import { h, leeren } from './dom.js' +import { K, zuAnsicht, meldeTitel, fmtDatumZeit, safeUrl } from './ansicht-hilfen.js' +import { baueAktivitaet, baueProjektDetails, ladeProjektDetails } from './website-details.js' +import { ladeOffeneRechnungen, rechnungOffenZeile } from './rechnung-offen.js' +import { starteInstallQuelle, installAngebot, baueInstallHinweis, istAusgeblendet, merkeAusgeblendet } from './install-hinweis.js' +import { HANDY_ABFRAGE } from './leiste.js' -export async function zeigeWebsite(wurzel, { route, me, nav }) { - wurzel.replaceChildren(h('p', { class: 'kn-leer' }, 'Website (folgt)')) +const VORSCHAU_BASISBREITE = 1280 +const laeufe = new WeakMap() // Wurzel → Nummer des jüngsten Aufrufs + +/** Vorschau fertig: Status ready oder deployed und eine Adresse (wie app.js isProjectPreviewReady). */ +export function vorschauBereit(project) { + const status = String(project?.provisioningStatus || project?.status || '').toLowerCase() + return ['ready', 'deployed'].includes(status) && Boolean(project?.previewUrl) +} + +/** Ton und Text der Status-Pille eines Projekts. */ +export function projektStatus(project) { + if (vorschauBereit(project)) return { ton: 'gruen', text: 'Vorschau online' } + const status = String(project?.provisioningStatus || project?.status || '').toLowerCase() + if (status.includes('fail')) return { ton: 'rot', text: 'Vorschau gestört' } + if (project?.previewUrl) return { ton: 'orange', text: 'Vorschau wird vorbereitet' } + return { ton: 'grau', text: 'Noch keine Website' } +} + +// Der Frame zeigt die Seite in 1280 px Breite und wird auf die Breite des Rahmens verkleinert +function skaliereVorschau(rahmen) { + const frame = rahmen.querySelector('iframe') + if (!frame) return + const anwenden = () => { + const breite = rahmen.clientWidth + if (breite && frame.style) frame.style.transform = `scale(${breite / VORSCHAU_BASISBREITE})` + } + if (typeof ResizeObserver !== 'undefined') new ResizeObserver(anwenden).observe(rahmen) + anwenden() +} + +function hostVon(url) { + try { return new URL(url).hostname || '' } catch { return '' } +} + +const fakt = (titel, wert) => h('div', null, h('dt', null, titel), h('dd', null, wert)) + +function projektZeile(projekt) { + const bereit = vorschauBereit(projekt) + const url = safeUrl(projekt.previewUrl) + const status = projektStatus(projekt) + const rahmen = bereit && url + ? h('div', { class: 'kn-vorschau', 'data-vorschau': true }, h('iframe', { src: url, loading: 'lazy', scrolling: 'no', tabindex: '-1', 'aria-hidden': 'true', title: 'Vorschau deiner Website' })) + : h('div', { class: 'kn-vorschau kn-vorschau-leer' }, projekt.previewUrl ? 'Vorschau wird vorbereitet …' : 'Noch keine Website veröffentlicht') + if (bereit && url) skaliereVorschau(rahmen) + const host = hostVon(url) + return h('li', { class: 'kn-projekt', 'data-projekt': projekt.id }, + rahmen, + h('div', { class: 'kn-projekt-koerper' }, + h('div', { class: 'kn-projekt-kopf' }, h('h2', { class: 'kn-projekt-name' }, projekt.projectName || projekt.subdomain || 'Projekt'), h('span', { class: K.pille[status.ton] }, status.text)), + h('dl', { class: 'kn-fakten' }, + fakt('Vorschau-Adresse', host || '–'), + fakt('Live-Domain', projekt.liveDomain || 'noch nicht verbunden'), + fakt('Letzte Änderung', projekt.updatedAt ? fmtDatumZeit(projekt.updatedAt) : '–')), + h('div', { class: 'kn-projekt-links' }, + bereit && url ? h('a', { class: K.knopf, href: url, target: '_blank', rel: 'noopener noreferrer' }, 'Website öffnen') : null, + h('a', { class: K.knopf, ...zuAnsicht('projekt', projekt.id) }, 'Projektdetails')))) +} + +// Installationshinweis: ersetzt sich selbst, wenn sich Installierbarkeit oder Auswahl ändern +function installPlatz() { + const platz = h('div', { class: 'kn-install-platz' }) + const win = window + const quelle = starteInstallQuelle(win) + const handy = () => (typeof win.matchMedia === 'function' ? Boolean(win.matchMedia(HANDY_ABFRAGE)?.matches) : false) + const zeichne = () => { + const angebot = installAngebot({ appLayout: handy(), standalone: quelle.standalone(), canPrompt: quelle.kannPrompt(), ios: quelle.ios, dismissed: istAusgeblendet() }) + leeren(platz) + if (angebot.zeigen) platz.append(baueInstallHinweis({ art: angebot.art, onInstall: () => quelle.installiere(), onDismiss: () => { merkeAusgeblendet(); zeichne() } })) + } + zeichne() + aktualisierer.set(quelle, zeichne) + if (!angemeldet.has(quelle)) { angemeldet.add(quelle); quelle.beiAenderung(() => aktualisierer.get(quelle)?.()) } + return platz +} +const aktualisierer = new WeakMap() +const angemeldet = new WeakSet() + +function aktivitaetAbschnitt(antwort, jetzt) { + const inhalt = antwort.fehler + ? h('p', { class: 'kn-hinweis' }, `Aktivität konnte nicht geladen werden: ${antwort.fehler}`) + : baueAktivitaet(antwort.tickets, jetzt) + return h('section', { class: 'kn-sektion', 'data-abschnitt': 'aktivitaet' }, h('h2', { class: K.abschnitt }, 'Was wir zuletzt erledigt haben'), inhalt) +} + +const projekteLaden = () => api('/api/projects').then((a) => ({ liste: Array.isArray(a?.projects) ? a.projects : [] }), (f) => ({ fehler: f.message })) +const aktivitaetLaden = () => api('/api/activity').then((a) => ({ tickets: Array.isArray(a?.tickets) ? a.tickets : [] }), (f) => ({ fehler: f.message })) + +const projektId = (query) => { const p = new URLSearchParams(query || ''); return p.get('projekt') || p.get('id') || '' } + +export async function zeigeWebsite(wurzel, { route, me, nav } = {}) { + const nr = (laeufe.get(wurzel) ?? 0) + 1 + laeufe.set(wurzel, nr) + const aktuell = () => laeufe.get(wurzel) === nr + leeren(wurzel).append(h('p', { class: K.leer }, 'Wird geladen …')) + const jetzt = new Date() + + if (route?.etage === 'projekt') { + const projekte = await projekteLaden() + if (!aktuell()) return + if (projekte.fehler) { leeren(wurzel).append(h('p', { class: K.fehler }, `Projekte konnten nicht geladen werden: ${projekte.fehler}`)); return } + const wunsch = projektId(route.query) + const projekt = wunsch ? projekte.liste.find((p) => p.id === wunsch) : projekte.liste[0] + if (!projekt) { leeren(wurzel).append(h('p', { class: K.leer }, projekte.liste.length ? 'Dieses Projekt wurde nicht gefunden.' : 'Noch keine Website-Projekte zugewiesen.')); return } + meldeTitel(wurzel, projekt.projectName || projekt.subdomain || 'Projekt') + const details = await ladeProjektDetails(projekt.id) + if (!aktuell()) return + leeren(wurzel).append(baueProjektDetails(details, jetzt)) + return + } + + const [projekte, aktivitaet, offen] = await Promise.all([projekteLaden(), aktivitaetLaden(), ladeOffeneRechnungen()]) + if (!aktuell()) return + const zeile = rechnungOffenZeile(offen) + const liste = projekte.fehler + ? h('p', { class: K.fehler }, `Projekte konnten nicht geladen werden: ${projekte.fehler}`) + : projekte.liste.length + ? h('ul', { class: 'kn-projekte' }, projekte.liste.map(projektZeile)) + : h('p', { class: K.leer }, 'Noch keine Website-Projekte zugewiesen.') + const aktivitaetSektion = aktivitaetAbschnitt(aktivitaet, jetzt) + leeren(wurzel).append(zeile, installPlatz(), liste, aktivitaetSektion) + if (route?.scroll === 'aktivitaet') aktivitaetSektion.scrollIntoView({ block: 'start' }) } diff --git a/public/neu/website-details.js b/public/neu/website-details.js index f8b3b41..7d5994b 100644 --- a/public/neu/website-details.js +++ b/public/neu/website-details.js @@ -169,10 +169,9 @@ export function baueProjektDetails({ git, files, work }, jetzt = new Date()) { return h('div', { class: 'kn-details' }, order ? h('section', { class: 'kn-sektion' }, baueAuftrag(order, work?.totalMinutes || 0)) : null, abschnitt('Letzte Änderungen', git ? baueAenderungen(git.changes || [], jetzt) : leerText('Die Änderungen können gerade nicht geladen werden.')), - order || !work - ? abschnitt('Arbeiten am Projekt', work - ? baueArbeiten(work.entries || [], 'Noch keine Arbeitszeit eingetragen. Was sich an der Website geändert hat, steht unter „Letzte Änderungen“.') - : leerText('Die Arbeiten können gerade nicht geladen werden.')) - : null, + // Der Abschnitt steht immer da (Test Etage projekt): ohne Auftrag zeigt er den Leertext, bei Ausfall den Fehlertext + abschnitt('Arbeiten am Projekt', work + ? baueArbeiten(work.entries || [], 'Noch keine Arbeitszeit eingetragen. Was sich an der Website geändert hat, steht unter „Letzte Änderungen“.') + : leerText('Die Arbeiten können gerade nicht geladen werden.')), abschnitt('Dateien', files ? baueDateien(files) : leerText('Die Datei-Übersicht kann gerade nicht geladen werden.'))) } diff --git a/test/knAnsichtWebsite.test.js b/test/knAnsichtWebsite.test.js new file mode 100644 index 0000000..f6cb988 --- /dev/null +++ b/test/knAnsichtWebsite.test.js @@ -0,0 +1,72 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { zeigeWebsite, projektStatus, vorschauBereit } from '../public/neu/ansicht-website.js' +import { mit, me, projekt, rechnung, neueWurzel } from './support/knDaten.js' + +const HAUPT = { 'GET /api/projects': { projects: [projekt({ id: 'p1', projectName: 'Bäckerei Roth' })] }, 'GET /api/activity': { tickets: [] }, 'GET /api/invoices': { invoices: [] } } +const zeige = async (fx, route = { bereich: 'website', query: '' }, m = me()) => { const w = neueWurzel(fx); await zeigeWebsite(w, { route, me: m, nav: 'tab' }); await fx.ruhe(); return w } +test('projektStatus', () => { + const s = (o) => projektStatus(projekt(o)) + assert.deepEqual(s({}), { ton: 'gruen', text: 'Vorschau online' }) + assert.deepEqual(s({ status: 'building' }), { ton: 'orange', text: 'Vorschau wird vorbereitet' }) + assert.deepEqual(s({ provisioningStatus: 'failed' }), { ton: 'rot', text: 'Vorschau gestört' }) + assert.deepEqual(s({ previewUrl: '' }), { ton: 'grau', text: 'Noch keine Website' }) + assert.equal(vorschauBereit(projekt({ status: 'deployed' })), true); assert.equal(vorschauBereit(projekt({ status: 'building' })), false) +}) +test('Website: Ladezustand ist Text und verschwindet', mit(HAUPT, async (fx) => { + const w = neueWurzel(fx); const lauf = zeigeWebsite(w, { route: { bereich: 'website', query: '' }, me: me(), nav: 'tab' }) + assert.match(fx.text(w), /Wird geladen …/); await lauf; await fx.ruhe(); assert.doesNotMatch(fx.text(w), /Wird geladen …/) +})) +test('Website: Zeile je Projekt mit Frame, Status, Link über die Brücke; Name bleibt Text', mit(HAUPT, async (fx) => { + const w = await zeige(fx) + assert.equal(fx.finde(w, 'iframe').getAttribute('src'), 'https://baeckerei.project.webklar.com') + assert.match(fx.text(w), /Vorschau online/); assert.match(fx.text(w), /Bäckerei Roth<\/b>/); assert.equal(fx.finde(w, 'b'), null) + const a = fx.finde(w, '[data-projekt="p1"] a[data-view="projekt"]'); assert.deepEqual([a.textContent, a.getAttribute('href'), a.getAttribute('data-project')], ['Projektdetails', '#projekt?projekt=p1', 'p1']) +})) +test('Website: unsichere Vorschau-Adresse ergibt weder Frame noch Link', mit({ ...HAUPT, 'GET /api/projects': { projects: [projekt({ previewUrl: 'javascript:alert(1)' })] } }, async (fx) => { + const w = await zeige(fx) + assert.equal(fx.finde(w, 'iframe'), null); assert.ok(fx.alle(w, 'a').every((a) => !/^javascript/i.test(a.getAttribute('href') || ''))) +})) +test('Website: Aktivität fällt aus, die Projekte bleiben', mit({ ...HAUPT, 'GET /api/activity': () => ({ status: 500, body: { error: 'Server kaputt' } }) }, async (fx) => { + const w = await zeige(fx) + assert.match(fx.text(w), /Aktivität konnte nicht geladen werden: Server kaputt/); assert.ok(fx.finde(w, '[data-projekt="p1"]')) +})) +test('Website: leer', mit({ ...HAUPT, 'GET /api/projects': { projects: [] } }, async (fx) => { assert.match(fx.text(await zeige(fx)), /Noch keine Website-Projekte zugewiesen\./) })) +test('Website: offene Rechnung steht als erste Zeile', mit({ ...HAUPT, 'GET /api/invoices': { invoices: [rechnung({ number: 'RE-2026-0042', balance: 50 })] } }, async (fx) => { + const w = await zeige(fx); assert.match(fx.finde(w, '.kn-rechnung-offen').textContent, /^Rechnung offen · RE-2026-0042/) +})) +test('Website: route.scroll scrollt genau einmal zur Aktivität', mit(HAUPT, async (fx) => { + let n = 0; const create = fx.document.createElement.bind(fx.document) + fx.document.createElement = (t) => { const el = create(t); if (t === 'section') el.scrollIntoView = () => { n += 1 }; return el } + await zeige(fx, { bereich: 'website', scroll: 'aktivitaet', query: '' }); assert.equal(n, 1) +})) +test('Etage projekt: Titel, drei Abschnitte, nur das gewählte Projekt (projekt= und id=)', mit({ + ...HAUPT, 'GET /api/projects': { projects: [projekt({ id: 'p1', projectName: 'Eins' }), projekt({ id: 'p2', projectName: 'Zwei' })] }, + 'GET /api/projects/p1/git': { changes: [] }, 'GET /api/projects/p1/files': { totalFiles: 0 }, 'GET /api/projects/p1/work': { order: null, entries: [] }, + 'GET /api/projects/p2/git': { changes: [] }, 'GET /api/projects/p2/files': { totalFiles: 0 }, 'GET /api/projects/p2/work': { order: null, entries: [] }, +}, async (fx) => { + const titel = []; const w = neueWurzel(fx); w.addEventListener('kn:titel', (e) => titel.push(e.detail.text)) + await zeigeWebsite(w, { route: { bereich: 'website', etage: 'projekt', query: 'projekt=p2' }, me: me(), nav: 'push' }); await fx.ruhe() + assert.deepEqual(titel, ['Zwei']); for (const t of ['Letzte Änderungen', 'Arbeiten am Projekt', 'Dateien']) assert.match(fx.text(w), new RegExp(t)) + assert.ok(fx.fetchLog.some((a) => a.url === '/api/projects/p2/git')); assert.ok(!fx.fetchLog.some((a) => a.url === '/api/projects/p1/git')) + await zeigeWebsite(w, { route: { bereich: 'website', etage: 'projekt', query: 'id=p1' }, me: me(), nav: 'push' }); await fx.ruhe(); assert.deepEqual(titel, ['Zwei', 'Eins']) +})) +test('Website: Installationshinweis nur im Handy-Layout und noch nicht installiert', mit(HAUPT, async (fx) => { + fx.window.matchMedia = (q) => ({ matches: q.includes('max-width: 880px') }); fx.window.navigator = { userAgent: 'iPhone', maxTouchPoints: 5 } + assert.match(fx.text(await zeige(fx)), /WEBklar als App/) +})) +test('Website: ohne Handy-Layout kein Installationshinweis; späte Antwort eines früheren Aufrufs wird verworfen', mit(HAUPT, async (fx) => { + const w = await zeige(fx); assert.doesNotMatch(fx.text(w), /WEBklar als App/) + let frei; const gesperrt = new Promise((r) => { frei = r }) + fx.tabelle['GET /api/projects'] = async () => { await gesperrt; return { status: 200, body: { projects: [projekt({ id: 'alt', projectName: 'Alt' })] } } } + const w2 = neueWurzel(fx) + const erster = zeigeWebsite(w2, { route: { bereich: 'website', query: '' }, me: me(), nav: 'tab' }) + fx.tabelle['GET /api/projects'] = { projects: [projekt({ id: 'neu', projectName: 'Neu' })] } + await zeigeWebsite(w2, { route: { bereich: 'website', query: '' }, me: me(), nav: 'tab' }); frei(); await erster; await fx.ruhe() + assert.match(fx.text(w2), /Neu/); assert.doesNotMatch(fx.text(w2), /Alt/) +})) +test('Etage projekt ohne Projekte oder mit unbekannter ID: Text statt Fehler', mit({ ...HAUPT, 'GET /api/projects': { projects: [projekt({ id: 'p1', projectName: 'Eins' })] } }, async (fx) => { + const w = neueWurzel(fx) + await zeigeWebsite(w, { route: { bereich: 'website', etage: 'projekt', query: 'projekt=nope' }, me: me(), nav: 'push' }); await fx.ruhe() + assert.match(fx.text(w), /Dieses Projekt wurde nicht gefunden\./) +})) diff --git a/test/support/knDaten.js b/test/support/knDaten.js index bb65868..fd29452 100644 --- a/test/support/knDaten.js +++ b/test/support/knDaten.js @@ -109,8 +109,8 @@ export const projekt = (over = {}) => ({ subdomain: 'baeckerei', previewUrl: 'https://baeckerei.project.webklar.com', liveDomain: '', - status: 'in_progress', - provisioningStatus: 'ready', + status: 'ready', + provisioningStatus: '', updatedAt: '2026-10-14T09:00:00.000Z', ...over, })