Analyse: im Abo-Modell Linkzeile und Zielgruppen-Häkchen, Hosting berechtigt, Reiter in der Etage

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-16 18:50:49 +00:00
1 parent c37faabcd3
commit 5534655671
2 files changed
+171 -12

No files matched your search

+36 -12
View File
@@ -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 = `<p class="muted">${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 = '<div class="an"><section class="an-card an-state"><p class="an-empty">Die Analyse ist für deine Websites noch nicht freigeschaltet.</p></section></div>'
return
}
@@ -33,7 +44,10 @@ export async function initAnalyse(root, { projectId } = {}) {
const a = analyseOf(project)
const active = a.state === 'active'
const tab = TABS.includes(analyseSel.tab) ? analyseSel.tab : 'uebersicht'
root.innerHTML = `<div class="an">${headHtml(project, usable, { showRange: active && tab === 'uebersicht' })}${active ? tabsHtml(tab) : ''}<div id="an-stage"${active ? ` role="tabpanel" aria-labelledby="an-tab-${tab}"` : ''}></div></div>`
if (abo && !active) meldeReiter(root, { eintraege: [] }) // ohne Cockpit keine Reiter; das Cockpit meldet seine gleich nach dem Zeichnen
const leiste = active && !abo ? tabsHtml(tab) : ''
const panel = !active ? '' : abo ? ` role="tabpanel" aria-label="${reiterLabel(tab)}"` : ` role="tabpanel" aria-labelledby="an-tab-${tab}"`
root.innerHTML = `<div class="an">${headHtml(project, usable, { showRange: active && tab === 'uebersicht' })}${leiste}<div id="an-stage"${panel}></div></div>`
const stage = root.querySelector('#an-stage')
const reload = () => initAnalyse(root).catch(showError(root))
// Website wechseln: der Reiter bleibt (analyseSel.tab)
@@ -42,11 +56,11 @@ export async function initAnalyse(root, { projectId } = {}) {
analyseSel.projectId = ev.target.value
reload()
})
if (a.state === 'active') mountCockpit({ an: root.querySelector('.an'), stage, project, usable, reload })
if (a.state === 'active') mountCockpit({ an: root.querySelector('.an'), stage, project, usable, reload, abo, root })
else if (a.state === 'disabled') stage.innerHTML = '<section class="an-card an-state"><h3>Analyse ist abgeschaltet</h3><p>Für diese Website ist die Analyse im Moment gesperrt. <button type="button" class="kb-link" data-view="chat">Schreib uns im Chat</button>, dann klären wir das.</p></section>'
else if (a.state === 'unknown') stage.innerHTML = '<section class="an-card an-state"><p>Die Daten konnten gerade nicht geladen werden.</p></section>'
else if (!a.eligible) stage.innerHTML = `<section class="an-card an-state"><h3>Analyse nicht verfügbar</h3><p>${esc(a.reasonText || 'Für diese Website gerade nicht verfügbar.')}${a.reason === 'kein_abo' ? ` <button type="button" class="${ZU_VERTRAEGE.klasse}" data-view="${ZU_VERTRAEGE.view}">${ZU_VERTRAEGE.text}</button>` : ''}</p></section>`
else renderAnalyseActivation(stage, project, reload)
else if (!a.eligible) stage.innerHTML = `<section class="an-card an-state"><h3>Analyse nicht verfügbar</h3><p>${esc(abo && a.reason === 'kein_abo' ? KEIN_ABO_ANALYSE : a.reasonText || 'Für diese Website gerade nicht verfügbar.')}${a.reason === 'kein_abo' ? ` <button type="button" class="${ZU_VERTRAEGE.klasse}" data-view="${ZU_VERTRAEGE.view}">${ZU_VERTRAEGE.text}</button>` : ''}</p></section>`
else renderAnalyseActivation(stage, project, reload, { modell })
}
// Kopfleiste (analyse-cards.js); Zeitraum nur in der Uebersicht, "Stand" = Uhrzeit des letzten Ladens, today = "Heute bisher" aus der Statistik
@@ -73,7 +87,8 @@ function controllerBlock(controller) {
return `<div class="an-ctl" id="an-ctl"><p>Auf der Datenschutz-Zusatzseite Deiner Website erscheinen als Verantwortlicher:</p><p><strong>${controllerLines(controller).map(esc).join(' · ')}</strong></p><p>Stimmt etwas nicht? Schreib uns im Chat, bevor Du einschaltest.</p></div>`
}
export function renderAnalyseActivation(stage, project, reload) {
export function renderAnalyseActivation(stage, project, reload, { modell } = {}) {
const abo = modell === 'abo' // Abo-Modell: Linkzeile statt des langen Zustimmungssatzes, Zielgruppen-Häkchen, audienceConfirmed
const a = analyseOf(project)
const legal = a.legal || {}
const controller = a.controller || { ok: false }
@@ -91,7 +106,10 @@ export function renderAnalyseActivation(stage, project, reload) {
<p class="an-hint hidden" id="an-hint">Diese Farbe ist für weiße Schrift zu hell. Der Banner nutzt dann Schwarz (#111111).</p>
<div id="an-preview"></div>
${controllerBlock(controller)}
<label class="fn-consent"><input type="checkbox" id="an-avv"><span>Ich beauftrage WEBklar mit der Website-Analyse und stimme dem <a href="${esc(legal.avv || '')}" target="_blank" rel="noopener">Auftragsverarbeitungsvertrag Website-Analyse</a> und der <a href="${esc(legal.lb || '')}" target="_blank" rel="noopener">Leistungsbeschreibung Website-Analyse</a> (Fassung ${esc(a.avvVersion)}) zu. Meine Website richtet sich nicht gezielt an Kinder unter 16 Jahren.</span></label>
${abo
? `<p class="an-hint" id="an-legal">Es gelten <a href="${esc(legal.lb || '')}" target="_blank" rel="noopener">Leistungsbeschreibung</a> und <a href="${esc(legal.avv || '')}" target="_blank" rel="noopener">Auftragsverarbeitung</a> (Fassung ${esc(a.avvVersion)}).</p>
<label class="fn-consent"><input type="checkbox" id="an-zielgruppe"><span>${ZIELGRUPPE}</span></label>`
: `<label class="fn-consent"><input type="checkbox" id="an-avv"><span>Ich beauftrage WEBklar mit der Website-Analyse und stimme dem <a href="${esc(legal.avv || '')}" target="_blank" rel="noopener">Auftragsverarbeitungsvertrag Website-Analyse</a> und der <a href="${esc(legal.lb || '')}" target="_blank" rel="noopener">Leistungsbeschreibung Website-Analyse</a> (Fassung ${esc(a.avvVersion)}) zu. Meine Website richtet sich nicht gezielt an Kinder unter 16 Jahren.</span></label>`}
<p class="error hidden" id="an-error" role="alert"></p>
<div class="abo-actions"><button type="button" class="abo-btn" id="an-on" disabled>Analyse einschalten</button></div>
</div>`
@@ -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()
+135
View File
@@ -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 <a href="[^"]*"[^>]*>Leistungsbeschreibung<\/a> und <a href="[^"]*"[^>]*>Auftragsverarbeitung<\/a> \(Fassung 2026-10-02\)\./)
assert.ok(html.includes(`href="${LEGAL.lb}"`) && html.includes(`href="${LEGAL.avv}"`))
assert.match(html, /<input type="checkbox" id="an-zielgruppe"><span>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"[^>]*>([^<]+)</g)].map((m) => 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)
}
})