diff --git a/public/analyse.js b/public/analyse.js index d7da4ae..f52c412 100644 --- a/public/analyse.js +++ b/public/analyse.js @@ -9,11 +9,18 @@ import { analyseOf, pickAnalyseProject, analyseSel, bannerColor, BANNER_PREVIEW_ import { headerHtml, tabsHtml, overviewHtml, rankCardHtml, skeletonHtml, errorHtml, settingsHtml, chartTipText } from './analyse-cards.js' import { HEAT_DEVICES, deviceHtml, heatCaption } from './geraete.js' import { ZU_VERTRAEGE } from './vertraege-link.js' +import { meldeReiter, reiterEintraege } from './fn-reiter.js' const PRESET_COLORS = ['#111111', '#1d4ed8', '#047857', '#b91c1c', '#7c3aed', '#c2410c'] const HEX_RE = /^#[0-9a-f]{6}$/i const nf = new Intl.NumberFormat('de-DE') const TABS = ['uebersicht', 'heatmap', 'einstellungen'] +// Abo-Modell: die Reiter zeichnet die Etage (kn:reiter, public/fn-reiter.js); Reihenfolge und Wortlaut wie tabsHtml +const ANALYSE_REITER = [{ key: 'uebersicht', label: 'Übersicht' }, { key: 'heatmap', label: 'Heatmap' }, { key: 'einstellungen', label: 'Einstellungen' }] +const reiterLabel = (key) => ANALYSE_REITER.find((r) => r.key === key)?.label || ANALYSE_REITER[0].label +// Abo-Modell, Texte A1 bis A3 +const ZIELGRUPPE = 'Meine Website richtet sich nicht gezielt an Kinder unter 16 Jahren.' +const KEIN_ABO_ANALYSE = 'Die Analyse ist im Hosting- und im Wartungs-Abo enthalten.' const RANGES = [7, 30, 90] const CLOCK = new Intl.DateTimeFormat('de-DE', { timeZone: 'Europe/Berlin', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }) @@ -21,10 +28,14 @@ const showError = (root) => (err) => { root.innerHTML = `
${esc( export async function initAnalyse(root, { projectId } = {}) { if (!root) return - if (projectId) analyseSel.projectId = projectId - const { projects = [] } = await request('/api/funktionen') + const wunsch = projectId || root.dataset?.wunschProjekt || '' // die Kachel in Funktionen legt ihr Projekt hier ab + if (root.dataset) delete root.dataset.wunschProjekt + if (wunsch) analyseSel.projectId = wunsch + const { projects = [], modell } = await request('/api/funktionen') + const abo = modell === 'abo' // strikt: nur dann Reiter in der Etage und die Texte A1 bis A3 const usable = projects.filter((p) => !analyseOf(p).comingSoon) if (!usable.length) { + if (abo) meldeReiter(root, { eintraege: [] }) root.innerHTML = '
Die Analyse ist für deine Websites noch nicht freigeschaltet.
Für diese Website ist die Analyse im Moment gesperrt. , dann klären wir das.
Die Daten konnten gerade nicht geladen werden.
${esc(a.reasonText || 'Für diese Website gerade nicht verfügbar.')}${a.reason === 'kein_abo' ? ` ` : ''}
${esc(abo && a.reason === 'kein_abo' ? KEIN_ABO_ANALYSE : a.reasonText || 'Für diese Website gerade nicht verfügbar.')}${a.reason === 'kein_abo' ? ` ` : ''}
Auf der Datenschutz-Zusatzseite Deiner Website erscheinen als Verantwortlicher:
${controllerLines(controller).map(esc).join(' · ')}
Stimmt etwas nicht? Schreib uns im Chat, bevor Du einschaltest.
Es gelten Leistungsbeschreibung und Auftragsverarbeitung (Fassung ${esc(a.avvVersion)}).
+ ` + : ``} ` @@ -104,7 +122,7 @@ export function renderAnalyseActivation(stage, project, reload) { paint() input.addEventListener('input', () => { color = input.value; paint() }) stage.querySelectorAll('.an-preset').forEach((b) => b.addEventListener('click', () => { color = b.dataset.color; input.value = color; paint() })) - const box = stage.querySelector('#an-avv') + const box = stage.querySelector(abo ? '#an-zielgruppe' : '#an-avv') const button = stage.querySelector('#an-on') const error = stage.querySelector('#an-error') box.addEventListener('change', () => { button.disabled = !box.checked || controller.ok !== true }) @@ -113,7 +131,7 @@ export function renderAnalyseActivation(stage, project, reload) { button.disabled = true error.classList.add('hidden') try { - await request('/api/funktionen/analyse/activate', { method: 'POST', body: { projectId: project.projectId, accentColor: color.toLowerCase(), consent: { accepted: true, avvVersion: a.avvVersion } } }) + await request('/api/funktionen/analyse/activate', { method: 'POST', body: { projectId: project.projectId, accentColor: color.toLowerCase(), consent: { accepted: true, avvVersion: a.avvVersion }, ...(abo ? { audienceConfirmed: true } : {}) } }) reload() } catch (err) { error.textContent = err.message @@ -171,7 +189,7 @@ function wireChart(stage) { svg.addEventListener('pointercancel', hideChartTip) } -function mountCockpit({ an, stage, project, usable, reload }) { +function mountCockpit({ an, stage, project, usable, reload, abo = false, root = null }) { const pid = project.projectId const state = { stats: null, range: 0, stamp: '', rank: { seen: {}, who: {} } } const tabNow = () => (TABS.includes(analyseSel.tab) ? analyseSel.tab : 'uebersicht') @@ -195,7 +213,12 @@ function mountCockpit({ an, stage, project, usable, reload }) { b.setAttribute('aria-selected', String(on)) b.tabIndex = on ? 0 : -1 }) - stage.setAttribute('aria-labelledby', `an-tab-${tab}`) + if (abo) stage.setAttribute('aria-label', reiterLabel(tab)) + else stage.setAttribute('aria-labelledby', `an-tab-${tab}`) + } + // Abo-Modell: den aktiven Reiter an die Etage melden (bei = pickTab); der Teaser der Uebersicht ruft ebenfalls pickTab + function melde() { + if (abo) meldeReiter(root, { eintraege: reiterEintraege('analyse', ANALYSE_REITER), aktiv: tabNow(), bei: pickTab }) } // Anfrage je Zeitraum: Uebersicht und Heatmap teilen sich eine laufende Anfrage. Sie schreibt nichts in state; uebernommen // (adopt) wird die Antwort erst nach current(mine), sonst ueberschriebe eine verspaetete den Stand des neueren Zeitraums. @@ -276,6 +299,7 @@ function mountCockpit({ an, stage, project, usable, reload }) { } function showTab() { paintTabs() + melde() paintHead() const tab = tabNow() if (tab === 'heatmap') showHeat() diff --git a/test/kundenansichtNeuModulAnalyse.test.js b/test/kundenansichtNeuModulAnalyse.test.js new file mode 100644 index 0000000..8259ef6 --- /dev/null +++ b/test/kundenansichtNeuModulAnalyse.test.js @@ -0,0 +1,135 @@ +import { test, after } from 'node:test' +import assert from 'node:assert/strict' +import { analyseSel } from '../public/analyse-view.js' +import { KEIN_PREIS, htmlWurzel } from './support/modulAbo.js' + +// Analyse im Abo-Modell (P4 Task 8): Linkzeile A1 und Zielgruppen-Häkchen A2 statt des langen Zustimmungssatzes, Text A3, audienceConfirmed im +// Körper, die Reiter Übersicht, Heatmap und Einstellungen in der Etage (kn:reiter). Ohne modell === 'abo' bleibt alles wie heute. + +const { initAnalyse } = await import('../public/analyse.js') +globalThis.document = { querySelectorAll: () => [] } // pickTab ruft hideChartTip +after(() => { delete globalThis.document }) + +const LEGAL = { avv: '/rechtliches/avv-website-analyse-2026-10-02.html', lb: '/rechtliches/leistungsbeschreibung-website-analyse-2026-10-02.html' } +const CTRL = { ok: true, company: 'Muster GmbH', address: 'Weg 1, 12345 Ort', email: 'info@muster.de' } +const offProject = (id = 'p1', analyse = {}) => ({ projectId: id, projectName: `Muster ${id}`, analyse: { state: 'off', eligible: true, accentColor: '#111111', avvVersion: '2026-10-02', legal: LEGAL, controller: CTRL, ...analyse } }) +const aktivProjekt = (id = 'p1') => offProject(id, { state: 'active' }) +const KEIN_ABO = { eligible: false, reason: 'kein_abo', reasonText: 'Alter Satz des Servers' } + +async function lauf({ modell, projects = [offProject()], dataset = {}, projectId = 'p1', tab = 'uebersicht' } = {}) { + const posts = [] + globalThis.fetch = async (url, opts) => { + if (opts?.method === 'POST') posts.push({ url: String(url), body: JSON.parse(opts.body) }) + if (String(url).startsWith('/api/funktionen/analyse/stats')) return { ok: false, status: 503, json: async () => ({}) } // das Cockpit zeigt die Fehlerkarte; mehr braucht der Reitertest nicht + return { ok: true, status: 200, json: async () => ({ ...(modell ? { modell } : {}), projects }) } + } + const { root, stage, kn } = htmlWurzel('#an-stage') + Object.assign(root.dataset, dataset) + analyseSel.tab = tab + await initAnalyse(root, { projectId }) + return { root, stage, posts, kn } +} + +test('Abo: Linkzeile A1 mit beiden Links, ein Häkchen mit A2, kein langer Zustimmungssatz, keine Preise', async () => { + const { stage } = await lauf({ modell: 'abo' }) + const html = stage.innerHTML + assert.match(html, /Es gelten ]*>Leistungsbeschreibung<\/a> und ]*>Auftragsverarbeitung<\/a> \(Fassung 2026-10-02\)\./) + assert.ok(html.includes(`href="${LEGAL.lb}"`) && html.includes(`href="${LEGAL.avv}"`)) + assert.match(html, /Meine Website richtet sich nicht gezielt an Kinder unter 16 Jahren\.<\/span>/) + assert.doesNotMatch(html, /Ich beauftrage WEBklar|id="an-avv"/) + assert.doesNotMatch(html, KEIN_PREIS) + assert.match(html, /Muster GmbH/, 'Verantwortlicher und Banner-Vorschau bleiben') + assert.match(html, /id="an-color"/) +}) + +test('Abo: Knopf gesperrt ohne Zielgruppen-Häkchen und bei unvollständigem Verantwortlichen; Körper mit audienceConfirmed', async () => { + const { stage, posts } = await lauf({ modell: 'abo' }) + const box = stage.els['#an-zielgruppe'] + const knopf = stage.els['#an-on'] + box.checked = true + box.handlers.change() + assert.equal(knopf.disabled, false) + box.checked = false + box.handlers.change() + assert.equal(knopf.disabled, true) + box.checked = true + box.handlers.change() + await knopf.handlers.click() + assert.deepEqual(posts, [{ url: '/api/funktionen/analyse/activate', body: { projectId: 'p1', accentColor: '#111111', consent: { accepted: true, avvVersion: '2026-10-02' }, audienceConfirmed: true } }]) + const ohne = await lauf({ modell: 'abo', projects: [offProject('p1', { controller: { ok: false, company: '', address: '', email: '', missing: ['firma'] } })] }) + ohne.stage.els['#an-zielgruppe'].checked = true + ohne.stage.els['#an-zielgruppe'].handlers.change() + assert.equal(ohne.stage.els['#an-on'].disabled, true) +}) + +test('Abo: kein_abo nennt Hosting- und Wartungs-Abo mit Link zu Verträge (A3), nicht den Satz des Servers', async () => { + const { stage } = await lauf({ modell: 'abo', projects: [offProject('p1', KEIN_ABO)] }) + assert.match(stage.innerHTML, /Die Analyse ist im Hosting- und im Wartungs-Abo enthalten\./) + assert.match(stage.innerHTML, /data-view="vertraege"/) + assert.doesNotMatch(stage.innerHTML, /Alter Satz des Servers/) +}) + +test('Abo: ein anderer Grund zeigt weiter den Satz des Servers, ohne Link', async () => { + const { stage } = await lauf({ modell: 'abo', projects: [offProject('p1', { eligible: false, reason: 'keine_website', reasonText: 'Es fehlt die Website.' })] }) + assert.match(stage.innerHTML, /Es fehlt die Website\./) + assert.doesNotMatch(stage.innerHTML, /data-view="vertraege"/) +}) + +test('Ohne modell (fehlt, „Abo“, „abo “): der lange Zustimmungssatz mit #an-avv, Körper ohne audienceConfirmed, bei kein_abo der Satz des Servers', async () => { + for (const modell of [undefined, 'Abo', 'abo ']) { + const a = await lauf({ modell }) + assert.match(a.stage.innerHTML, /id="an-avv"/) + assert.match(a.stage.innerHTML, /Ich beauftrage WEBklar/) + assert.doesNotMatch(a.stage.innerHTML, /an-zielgruppe/) + a.stage.els['#an-avv'].checked = true + a.stage.els['#an-avv'].handlers.change() + await a.stage.els['#an-on'].handlers.click() + assert.deepEqual(a.posts[0].body, { projectId: 'p1', accentColor: '#111111', consent: { accepted: true, avvVersion: '2026-10-02' } }) + const b = await lauf({ modell, projects: [offProject('p1', KEIN_ABO)] }) + assert.match(b.stage.innerHTML, /Alter Satz des Servers/) + } +}) + +test('wunschProjekt aus dem Behälter wählt das Projekt und wird entfernt', async () => { + const { root, stage } = await lauf({ modell: 'abo', projects: [offProject('p1'), offProject('p2')], dataset: { wunschProjekt: 'p2' }, projectId: '' }) + assert.match(stage.innerHTML, /Analyse für Muster p2 einschalten/) + assert.equal(root.dataset.wunschProjekt, undefined) +}) + +test('Abo, aktive Analyse: keine eigene Leiste, kn:reiter mit den drei Reitern, Panel mit aria-label; bei() wechselt und meldet den neuen aktiven Reiter', async () => { + assert.equal(typeof CustomEvent, 'function', 'meldeReiter braucht CustomEvent (Node 20: global)') + const { root, stage, kn } = await lauf({ modell: 'abo', projects: [aktivProjekt()] }) + assert.doesNotMatch(root.innerHTML, /role="tablist"|an-tabbtn|aria-labelledby/) + assert.match(root.innerHTML, /id="an-stage" role="tabpanel" aria-label="Übersicht"/) + const erst = kn.reiter.at(-1) + assert.deepEqual(erst.eintraege, [{ key: 'uebersicht', label: 'Übersicht' }, { key: 'heatmap', label: 'Heatmap' }, { key: 'einstellungen', label: 'Einstellungen' }]) + assert.equal(erst.aktiv, 'uebersicht') + erst.bei('heatmap') + await new Promise((r) => setImmediate(r)) + assert.equal(kn.reiter.at(-1).aktiv, 'heatmap') + assert.equal(stage.attribute['aria-label'], 'Heatmap') + assert.equal(analyseSel.tab, 'heatmap') +}) + +test('Abo, aktive Analyse mit gemerktem Reiter: das Panel nennt ihn, die Meldung nennt ihn als aktiv', async () => { + const { root, kn } = await lauf({ modell: 'abo', projects: [aktivProjekt()], tab: 'heatmap' }) + assert.match(root.innerHTML, /id="an-stage" role="tabpanel" aria-label="Heatmap"/) + assert.equal(kn.reiter.at(-1).aktiv, 'heatmap') +}) + +test('Abo ohne Cockpit (Einschalten, abgeschaltet, nicht verfügbar, keine Website): leere Reiterliste, „Zurück“ füllt die Zeile', async () => { + for (const projects of [[offProject()], [offProject('p1', { state: 'disabled' })], [offProject('p1', KEIN_ABO)], []]) { + const { kn } = await lauf({ modell: 'abo', projects }) + assert.deepEqual(kn.reiter.at(-1)?.eintraege, [], JSON.stringify(projects.map((p) => p.analyse.state))) + } +}) + +test('Ohne modell (fehlt, „Abo“): eigene Leiste mit denselben drei Labels, aria-labelledby, kein kn:reiter', async () => { + for (const modell of [undefined, 'Abo']) { + const { root, kn } = await lauf({ modell, projects: [aktivProjekt()] }) + assert.deepEqual([...root.innerHTML.matchAll(/role="tab"[^>]*>([^<]+) m[1]), ['Übersicht', 'Heatmap', 'Einstellungen']) + assert.match(root.innerHTML, /role="tablist"/) + assert.match(root.innerHTML, /id="an-stage" role="tabpanel" aria-labelledby="an-tab-uebersicht"/) + assert.equal(kn.reiter.length, 0) + } +})