Analyse: Portal-Texte, Verantwortlichen-Vorschau und Bestätigungen nach geprüftem Wortlaut

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-01 18:50:36 +00:00
1 parent ddb91a9950
commit 3bac4093af
8 files changed
+253 -25

No files matched your search

+9
View File
@@ -81,3 +81,12 @@ export function barRows(items, labelOf) {
const max = Math.max(1, ...list.map((i) => i.count))
return list.map((i) => ({ key: i.key, label: labelOf(i.key), count: i.count, pct: Math.round((i.count / max) * 100) }))
}
/** Banner-Text der Portal-Vorschau; muss mit BANNER_TEXT in analytics/client/core.js uebereinstimmen (Paritaetstest). */
export const BANNER_PREVIEW_TEXT = 'Um unsere Website zu verbessern, zählen wir Seitenaufrufe mit Land und Gerätetyp, ohne Kennung. Mit Deiner Zustimmung messen wir zusätzlich, von welcher Website Du kommst, wie lange Du bleibst, wie weit Du scrollst und wo Du klickst (Heatmap), mit einer täglich wechselnden Kennung. Die Messung betreibt für uns die WEBklar GbR in Deutschland, ohne Cookies und ohne Werbung. Nach „Ablehnen“ zählen wir nur noch, dass Du abgelehnt hast. Deine Zustimmung kannst Du jederzeit über „Datenschutz-Einstellungen“ mit Wirkung für die Zukunft widerrufen. Details: '
/** Zeilen der Verantwortlichen-Vorschau: Firma (sonst Name), Anschrift falls hinterlegt, E-Mail. Leere Angaben entfallen. */
export function controllerLines(controller) {
const c = controller || {}
return [c.company, c.address, c.email].map((v) => String(v || '').trim()).filter(Boolean)
}
+27 -17
View File
@@ -3,10 +3,9 @@
Buchungsseite (off), Einrichtungsstatus (ordered/setup) oder Editor (active). Styles: funktionen.css.
Kundentexte kommen nur ueber esc() bzw. textContent in die Seite. */
import { bannerColor, chartModel, barRows, countryName, flagEmoji, refLabel, DEVICE_LABELS, CONTACT_LABELS } from '/analyse-view.js'
import { bannerColor, BANNER_PREVIEW_TEXT, controllerLines, chartModel, barRows, countryName, flagEmoji, refLabel, DEVICE_LABELS, CONTACT_LABELS } from '/analyse-view.js'
const PRICE = '30 €'
const AVV_URL = '/rechtliches/avv-website-analyse.html'
const LEISTUNGSBESCHREIBUNG = '/vertragsunterlagen/funktionen-leistungsbeschreibung.html'
const PHOTO_ACCEPT = 'image/jpeg,image/png,image/webp' // iOS wandelt HEIC dann selbst in JPEG um
const PHOTO_MAX_BYTES = 10 * 1024 * 1024
@@ -592,23 +591,29 @@ function bannerPreview(color) {
const btn = `style="background:${c};border-color:${c};color:#fff"`
return `
<div class="an-bn" role="img" aria-label="Vorschau des Banners auf deiner Website">
<p class="an-bn-text">Wir möchten mit deiner Zustimmung anonym auswerten, wie unsere Website genutzt wird (keine Cookies, keine Weitergabe). Details: <span class="an-bn-link">Datenschutz</span></p>
<p class="an-bn-text">${esc(BANNER_PREVIEW_TEXT)}<span class="an-bn-link">Datenschutz</span></p>
<div class="an-bn-row"><span class="an-bn-btn" ${btn}>Ablehnen</span><span class="an-bn-btn" ${btn}>Annehmen</span></div>
</div>`
}
function renderAnalyseActivation(stage, project, reload) {
const MISSING_PRIVACY_HINT = 'Für die Zusatzseite fehlen in Deinen Kundendaten Firma oder Name und eine gültige E-Mail-Adresse. Schreib uns im Chat, wir ergänzen sie.'
// Vorschau der Verantwortlichen-Angaben; sind sie unvollstaendig, bleibt "Analyse einschalten" gesperrt
function controllerBlock(controller) {
if (controller.ok !== true) return `<p class="error an-missing" id="an-missing" role="alert">${esc(MISSING_PRIVACY_HINT)}</p>`
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) {
const a = analyseOf(project)
const legal = a.legal || {}
const controller = a.controller || { ok: false }
let color = HEX_RE.test(a.accentColor || '') ? a.accentColor.toLowerCase() : '#111111'
stage.innerHTML = `
<div class="kb-panel an-panel">
<h3>Analyse für ${esc(project.projectName)} einschalten</h3>
<p>Du siehst, wie oft deine Website aufgerufen wird, woher die Besucher kommen und was sie sich ansehen. Wir speichern <strong>keine IP-Adressen</strong> und setzen <strong>keine Cookies</strong>. Alles läuft auf einem eigenen Server in Deutschland.</p>
<ul class="kb-checks">
<li>Ohne Zustimmung zählen wir nur, wie oft eine Seite aufgerufen wird – anonym, ohne Wiedererkennung.</li>
<li>Mit Zustimmung kommen Besucher, Verweildauer, Klicks und Heatmaps dazu.</li>
<li>Auf deiner Website erscheint dafür ein Hinweis-Banner mit „Ablehnen“ und „Annehmen“.</li>
</ul>
<p>Du siehst, wie oft deine Website aufgerufen wird, woher die Besucher kommen und was sie sich ansehen.</p>
<p>Ohne Zustimmung der Besucher zählen wir nur Seitenaufrufe mit Land und Geräteklasse, ohne Kennung. Wer zustimmt, wird zusätzlich mit Herkunftsseite, Verweildauer, Scrolltiefe und Klicks gemessen. Wir speichern keine IP-Adressen und setzen keine Cookies. Die Messung läuft auf unserem Server bei Hetzner in Deutschland. Wer ablehnt, wird nicht weiter gemessen; gezählt wird nur, dass abgelehnt wurde.</p>
<h4 class="an-h4">Farbe des Banners</h4>
<div class="an-colors">
<input type="color" id="an-color" value="${esc(color)}" aria-label="Farbe wählen">
@@ -616,7 +621,8 @@ function renderAnalyseActivation(stage, project, reload) {
</div>
<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>
<label class="fn-consent"><input type="checkbox" id="an-avv"><span>Ich beauftrage WEBklar mit der Verarbeitung der Analysedaten gemäß Auftragsverarbeitungsvertrag (Fassung ${esc(a.avvVersion)}). <a href="${esc(AVV_URL)}" target="_blank" rel="noopener">AVV lesen</a></span></label>
${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>
<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>`
@@ -632,9 +638,9 @@ function renderAnalyseActivation(stage, project, reload) {
const box = stage.querySelector('#an-avv')
const button = stage.querySelector('#an-on')
const error = stage.querySelector('#an-error')
box.addEventListener('change', () => { button.disabled = !box.checked })
box.addEventListener('change', () => { button.disabled = !box.checked || controller.ok !== true })
button.addEventListener('click', async () => {
if (!box.checked) return
if (!box.checked || controller.ok !== true) return
button.disabled = true
error.classList.add('hidden')
try {
@@ -643,7 +649,7 @@ function renderAnalyseActivation(stage, project, reload) {
} catch (err) {
error.textContent = err.message
error.classList.remove('hidden')
button.disabled = !box.checked
button.disabled = !box.checked || controller.ok !== true
}
})
}
@@ -757,10 +763,10 @@ function renderAnalyseHeat(box, projectId, pages) {
}
// Ausschalten mit Bestaetigung in der Seite (zweiter Klick), ohne Browser-Dialog
function renderAnalyseOff(box, projectId, reload) {
export function renderAnalyseOff(box, projectId, reload) {
box.innerHTML = `
<h3>Analyse ausschalten</h3>
<p>Danach werden keine neuen Daten mehr gesammelt und der Banner verschwindet von deiner Website. Die bisherigen Daten bleiben noch 60 Tage erhalten.</p>
<p>Danach werden keine neuen Daten mehr gespeichert, und das Banner erscheint nicht mehr. Skript und Zusatzseite entfernen wir mit der nächsten Veröffentlichung Deiner Website, in der Regel sofort. Die bisherigen Daten löschen wir nach 60 Tagen; bis zum 46. Tag kannst Du eine Kopie anfordern.</p>
<p class="error hidden" id="an-off-error" role="alert"></p>
<div class="nw-confirm" id="an-off-actions"><button type="button" class="an-ghost" id="an-off-ask">Analyse ausschalten</button></div>`
const actions = box.querySelector('#an-off-actions')
@@ -772,7 +778,11 @@ function renderAnalyseOff(box, projectId, reload) {
ev.currentTarget.disabled = true
try {
await request('/api/funktionen/analyse/deactivate', { method: 'POST', body: { projectId } })
reload()
box.innerHTML = `
<h3>Analyse ausgeschaltet</h3>
<p id="an-off-done">Die Website-Analyse ist ausgeschaltet. Ab jetzt werden keine Daten mehr gespeichert.</p>
<div class="abo-actions"><button type="button" class="abo-btn" id="an-off-ok">OK</button></div>`
box.querySelector('#an-off-ok').addEventListener('click', () => reload())
} catch (err) {
renderAnalyseOff(box, projectId, reload)
const e2 = box.querySelector('#an-off-error')
+2
View File
@@ -1,4 +1,5 @@
import { Router } from 'express'
import { legalPaths } from '../services/analyseLegal.js'
import { config } from '../config.js'
import { getDocument } from '../services/appwriteAdmin.js'
import { getSessionCustomerId, requireSession } from '../middleware/session.js'
@@ -66,6 +67,7 @@ async function analyseView(customerId, projectId) {
reason: eligibility.reason || null,
reasonText: eligibility.reason ? ELIGIBILITY_MESSAGES[eligibility.reason] : null,
avvVersion: config.analyse.avvVersion,
legal: legalPaths(config.analyse.avvVersion),
controller,
}
}
+2 -1
View File
@@ -14,6 +14,7 @@ const aw = await startAppwriteMock({
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { legalPaths } = await import('../server/services/analyseLegal.js')
const { startTestApp, get, rawGet, post, sessionCookie } = await import('./support/testApp.js')
const { customerCookie, wartungDoc, seedFixtures } = await import('./support/fixtures.js')
const { default: funktionenRoutes } = await import('../server/routes/funktionen.js')
@@ -108,7 +109,7 @@ test('GET /api/funktionen: analyse-Zustand je Projekt, mit und ohne Schalter', a
const body = await get('/api/funktionen', { cookie })
const p = (id) => body.projects.find((x) => x.projectId === id)
assert.equal(p('p1').features.news.state, 'off')
assert.deepEqual(p('p1').analyse, { state: 'off', devMode: false, accentColor: '#111111', eligible: true, reason: null, reasonText: null, avvVersion: config.analyse.avvVersion, controller: { company: 'Kunde Eins', address: '', email: 'k@example.invalid', ok: true, missing: [] } })
assert.deepEqual(p('p1').analyse, { state: 'off', devMode: false, accentColor: '#111111', eligible: true, reason: null, reasonText: null, avvVersion: config.analyse.avvVersion, legal: legalPaths(config.analyse.avvVersion), controller: { company: 'Kunde Eins', address: '', email: 'k@example.invalid', ok: true, missing: [] } })
assert.equal(p('p2').analyse.eligible, false)
assert.equal(p('p2').analyse.reason, 'nicht_oeffentlich')
assert.equal(p('p2').analyse.reasonText, an.ELIGIBILITY_MESSAGES.nicht_oeffentlich)
+12 -2
View File
@@ -1,8 +1,8 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { contrastOk } from '../analytics/client/core.js'
import { bannerColor, chartModel, countryName, flagEmoji, niceMax, shortDay, barRows, refLabel } from '../public/analyse-view.js'
import { contrastOk, BANNER_TEXT } from '../analytics/client/core.js'
import { BANNER_PREVIEW_TEXT, controllerLines, bannerColor, chartModel, countryName, flagEmoji, niceMax, shortDay, barRows, refLabel } from '../public/analyse-view.js'
test('contrast und bannerColor stimmen mit core.js ueberein', () => {
for (const c of ['#000000', '#ffffff', '#1d4ed8', '#047857', '#b91c1c', '#7c3aed', '#c2410c', '#ffcc00', '#9d3dfe', 'x', '#12']) {
@@ -57,3 +57,13 @@ test('Portal: Analyse-Ansicht ist verdrahtet', () => {
const fn = readFileSync(new URL('../public/funktionen.js', import.meta.url), 'utf8')
assert.doesNotMatch(fn.slice(fn.indexOf('Analyse ---')), /\bconfirm\(/)
})
test('Portal-Vorschau: Banner-Text ist der des Clients', () => {
assert.equal(BANNER_PREVIEW_TEXT, BANNER_TEXT)
})
test('controllerLines: Firma, Anschrift falls vorhanden, E-Mail', () => {
assert.deepEqual(controllerLines({ company: 'Muster GmbH', address: 'Weg 1, 12345 Ort', email: 'info@muster.de' }), ['Muster GmbH', 'Weg 1, 12345 Ort', 'info@muster.de'])
assert.deepEqual(controllerLines({ company: 'Muster GmbH', address: '', email: 'info@muster.de' }), ['Muster GmbH', 'info@muster.de'])
assert.deepEqual(controllerLines(undefined), [])
})
+6 -4
View File
@@ -24,11 +24,13 @@ test('Entwurfsseite ohne Fassung ist nicht mehr als Datei vorhanden', () => {
assert.ok(!existsSync(root('public/rechtliches/leistungsbeschreibung-website-analyse.html')))
})
test('AVV-Link im Portal zeigt auf eine ausgelieferte Adresse (Task 7 stellt auf die Fassung um)', () => {
test('Portal: keine alten Formulierungen, AVV-Adresse kommt aus a.legal', () => {
const fn = readFileSync(root('public/funktionen.js'), 'utf8')
const url = fn.match(/const AVV_URL = '([^']+)'/)?.[1]
assert.equal(url, '/rechtliches/avv-website-analyse.html')
for (const f of FILES) assert.ok(existsSync(root(f)))
for (const old of ['anonym', 'Cookie-Einstellungen', 'Die bisherigen Daten bleiben noch 60 Tage erhalten', 'Verarbeitung der Analysedaten gemäß Auftragsverarbeitungsvertrag', "AVV_URL = '/rechtliches/avv-website-analyse.html'"]) {
assert.ok(!fn.includes(old), old)
}
assert.doesNotMatch(fn, /AVV_URL/)
assert.match(fn, /a\.legal/)
})
test('Portal: Dev-Modus-Hinweis an der Analyse-Kachel', () => {
+87 -1
View File
@@ -11,7 +11,7 @@ const viewUrl = pathToFileURL(new URL('../public/analyse-view.js', import.meta.u
const src = readFileSync(new URL('../public/funktionen.js', import.meta.url), 'utf8').replace("'/analyse-view.js'", `'${viewUrl}'`)
const file = join(dir, 'funktionen.mjs')
writeFileSync(file, src)
const { initFunktionen } = await import(pathToFileURL(file).href)
const { initFunktionen, initAnalyse, renderAnalyseOff } = await import(pathToFileURL(file).href)
const news = { state: 'off' }
const project = (id, analyse) => ({ projectId: id, projectName: `P ${id}`, liveUrl: '', features: { news }, analyse })
@@ -46,3 +46,89 @@ test('Kacheln rendern ohne Fehler: News, Analyse comingSoon/aus/aktiv/gesperrt',
assert.match(tile(disabled, 'analyse'), /Bitte melde dich bei uns/)
assert.doesNotMatch(disabled, /Dev-Modus/)
})
// ---- Analyse-Einschalten und Ausschalten (ohne DOM: Attrappen, die innerHTML und Klick-Handler festhalten)
function fakeEl() {
const handlers = {}
return { innerHTML: '', disabled: false, checked: false, value: '', dataset: {}, classList: { add() {}, remove() {}, toggle() {} }, handlers, addEventListener(ev, fn) { handlers[ev] = fn }, querySelector: () => null, querySelectorAll: () => [] }
}
function fakeStage() {
const els = {}
const get = (sel) => (els[sel] ||= fakeEl())
return { els, innerHTML: '', querySelector: get, querySelectorAll: () => [] }
}
const offProject = (analyse) => ({ projectId: 'p1', projectName: 'Muster', analyse: { state: 'off', eligible: true, accentColor: '#111111', avvVersion: '2026-10-02', ...analyse } })
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' }
async function renderActivation(analyse) {
globalThis.fetch = async () => ({ ok: true, status: 200, json: async () => ({ projects: [offProject(analyse)] }) })
const root = fakeEl()
const stage = fakeStage()
root.querySelector = (sel) => (sel === '#an-stage' ? stage : null)
await initAnalyse(root)
return stage
}
test('Einschalten: zwei Links aus legal, Fassung, Verantwortlicher, Kinder-Satz, Banner-Vorschau', async () => {
const stage = await renderActivation({ legal: LEGAL, controller: CTRL })
const html = stage.innerHTML
assert.ok(html.includes(`href="${LEGAL.avv}"`))
assert.ok(html.includes(`href="${LEGAL.lb}"`))
assert.match(html, /Fassung 2026-10-02/)
assert.match(html, /Muster GmbH/)
assert.match(html, /info@muster\.de/)
assert.match(html, /Stimmt etwas nicht\? Schreib uns im Chat, bevor Du einschaltest\./)
assert.match(html, /nicht gezielt an Kinder unter 16 Jahren/)
assert.match(html, /Ohne Zustimmung der Besucher zählen wir nur Seitenaufrufe mit Land und Geräteklasse/)
assert.doesNotMatch(html, /anonym/)
const preview = stage.els['#an-preview'].innerHTML
assert.match(preview, /Ablehnen/)
assert.match(preview, /Annehmen/)
assert.match(preview, /Datenschutz-Einstellungen/)
// Haken schaltet den Knopf frei
const box = stage.els['#an-avv']
box.checked = true
box.handlers.change()
assert.equal(stage.els['#an-on'].disabled, false)
})
test('Einschalten: Firma und Anschrift werden maskiert', async () => {
const stage = await renderActivation({ legal: LEGAL, controller: { ...CTRL, company: 'A<b>&Co' } })
assert.match(stage.innerHTML, /A&lt;b&gt;&amp;Co/)
assert.doesNotMatch(stage.innerHTML, /A<b>/)
})
test('Einschalten: ohne Verantwortlichen-Angaben gesperrt, Hinweis sichtbar, kein Schreib-uns-Hinweis der Vorschau', async () => {
const stage = await renderActivation({ legal: LEGAL, controller: { ok: false, company: '', address: '', email: '', missing: ['firma'] } })
assert.match(stage.innerHTML, /fehlen in Deinen Kundendaten Firma oder Name und eine gültige E-Mail-Adresse/)
assert.doesNotMatch(stage.innerHTML, /bevor Du einschaltest/)
const box = stage.els['#an-avv']
box.checked = true
box.handlers.change()
assert.equal(stage.els['#an-on'].disabled, true)
})
test('Ausschalten: Hinweis vorher, nach "Ja, ausschalten" Bestätigung, erst "OK" lädt neu', async () => {
const calls = []
globalThis.fetch = async (url) => { calls.push(url); return { ok: true, status: 200, json: async () => ({}) } }
const box = fakeEl()
const actions = fakeEl()
const yes = fakeEl()
const no = fakeEl()
const ok = fakeEl()
const ask = fakeEl()
box.querySelector = (sel) => ({ '#an-off-actions': actions, '#an-off-ask': ask, '#an-off-ok': ok, '#an-off-error': fakeEl() })[sel]
actions.querySelector = (sel) => ({ '#an-off-yes': yes, '#an-off-no': no })[sel]
let reloads = 0
renderAnalyseOff(box, 'p1', () => { reloads++ })
assert.match(box.innerHTML, /Skript und Zusatzseite entfernen wir mit der nächsten Veröffentlichung Deiner Website/)
assert.match(box.innerHTML, /bis zum 46\. Tag kannst Du eine Kopie anfordern/)
ask.handlers.click()
await yes.handlers.click({ currentTarget: yes })
assert.deepEqual(calls, ['/api/funktionen/analyse/deactivate'])
assert.match(box.innerHTML, /Die Website-Analyse ist ausgeschaltet\. Ab jetzt werden keine Daten mehr gespeichert\./)
assert.equal(reloads, 0)
ok.handlers.click()
assert.equal(reloads, 1)
})
+108
View File
@@ -0,0 +1,108 @@
// Playwright-Check Analyse-Panel (Einschalten-Dialog + aktives Panel): statischer Server fuer public/ + gemockte API.
// Nicht Teil von npm test. Aufruf: node test/ui/analyse.ui.mjs [Ausgabeordner]
import http from 'node:http'
import { readFile, mkdir } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { chromium } from '/root/.npm/_npx/db89d7302a373f10/node_modules/playwright-core/index.mjs'
const pub = path.join(path.dirname(fileURLToPath(import.meta.url)), '../../public')
const SHOTS = process.argv[2] || '/root/reports/website-analyse-2026-10-01'
await mkdir(SHOTS, { recursive: true })
const TYPES = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon', '.webmanifest': 'application/json' }
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, 'http://x')
try {
const file = path.join(pub, url.pathname === '/' ? 'dashboard.html' : url.pathname)
const data = await readFile(file)
res.writeHead(200, { 'content-type': TYPES[path.extname(file)] || 'application/octet-stream' })
res.end(data)
} catch { res.writeHead(404); res.end('nicht gefunden') }
})
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
const base = `http://127.0.0.1:${server.address().port}`
const legal = { avv: '/rechtliches/avv-website-analyse-2026-10-02.html', lb: '/rechtliches/leistungsbeschreibung-website-analyse-2026-10-02.html' }
const controller = { ok: true, company: 'Bäckerei Berg GmbH', address: 'Hauptstraße 1, 12345 Musterstadt', email: 'info@baeckerei-berg.example' }
const base1 = { projectId: 'p1', projectName: 'Bäckerei Berg', liveUrl: 'https://baeckerei.example', features: { news: { state: 'off' } } }
const SCENARIOS = {
off: { ...base1, analyse: { state: 'off', eligible: true, accentColor: '#1d4ed8', avvVersion: '2026-10-02', legal, controller } },
missing: { ...base1, analyse: { state: 'off', eligible: true, accentColor: '#1d4ed8', avvVersion: '2026-10-02', legal, controller: { ok: false, company: '', address: '', email: '', missing: ['firma'] } } },
active: { ...base1, analyse: { state: 'active', eligible: true, accentColor: '#1d4ed8', avvVersion: '2026-10-02', legal, controller } },
}
const days = Array.from({ length: 14 }, (_, i) => ({ day: `2026-09-${String(17 + i).padStart(2, '0')}`, pv: 20 + ((i * 7) % 13) * 4 }))
const stats = {
totals: { pv: 612, visitors: 214, contacts: 18, consentRate: 0.62 }, series: days,
pages: [{ key: '/', count: 300 }, { key: '/kontakt', count: 120 }], refs: [{ key: '', count: 200 }, { key: 'google.com', count: 90 }],
countries: [{ key: 'DE', count: 500 }], devices: [{ key: 'm', count: 380 }, { key: 'd', count: 230 }], contacts: [{ key: 'tel', count: 12 }],
banner: { shown: 300, accept: 186, reject: 114 },
}
let scenario = 'off'
const browser = await chromium.launch()
let failed = 0
const check = (name, ok) => { console.log(`${ok ? 'OK ' : 'FAIL'} ${name}`); if (!ok) failed += 1 }
try {
for (const [label, viewport] of [['desktop', { width: 1280, height: 900 }], ['phone', { width: 390, height: 844 }]]) {
const ctx = await browser.newContext({ viewport })
const page = await ctx.newPage()
const errors = []
page.on('pageerror', (e) => errors.push(e.message))
await page.route('**/api/**', async (route) => {
const p = new URL(route.request().url()).pathname
const json = (b) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(b) })
if (p === '/api/auth/me') return json({ authenticated: true, role: 'customer', customer: { id: 'c1', code: '1001', name: 'Berta Berg', email: 'b@example.test' } })
if (p === '/api/projects') return json({ projects: [{ id: 'p1', name: 'Bäckerei Berg', status: 'live' }] })
if (p === '/api/features') return json({ features: [] })
if (p === '/api/activity') return json({ tickets: [] })
if (p === '/api/funktionen') return json({ projects: [SCENARIOS[scenario]] })
if (p === '/api/funktionen/analyse/stats') return json(stats)
if (p === '/api/funktionen/analyse/deactivate') return json({ ok: true })
return json({})
})
const open = async (sc) => {
scenario = sc
await page.goto('about:blank')
await page.goto(`${base}/dashboard.html#analyse`)
await page.waitForSelector('#analyse-body .kb-panel')
await page.waitForTimeout(900)
}
const shot = async (name) => {
const height = await page.evaluate(() => Math.min(document.documentElement.scrollHeight, 3200))
await page.setViewportSize({ width: viewport.width, height: Math.max(viewport.height, height) })
await page.waitForTimeout(150)
await page.screenshot({ path: `${SHOTS}/task-7-${label}-${name}.png` })
await page.setViewportSize(viewport)
}
const noScroll = async (n) => check(`${label} kein_horizontaler_scroll ${n}`, await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth))
await open('off')
const text = await page.textContent('#analyse-body')
check(`${label} verantwortlicher`, text.includes('Bäckerei Berg GmbH') && text.includes('info@baeckerei-berg.example'))
check(`${label} links`, (await page.$$eval('#analyse-body a[href^="/rechtliches/"]', (a) => a.map((x) => x.getAttribute('href')))).join() === `${legal.avv},${legal.lb}`)
check(`${label} knopf_gesperrt_ohne_haken`, await page.isDisabled('#an-on'))
await page.check('#an-avv')
check(`${label} knopf_frei_mit_haken`, !(await page.isDisabled('#an-on')))
await noScroll('einschalten')
await shot('einschalten')
await open('missing')
await page.check('#an-avv')
check(`${label} gesperrt_ohne_angaben`, (await page.isDisabled('#an-on')) && Boolean(await page.$('#an-missing')))
await open('active')
await page.waitForSelector('.an-kpis')
await noScroll('aktiv')
await shot('aktiv')
await page.click('#an-off-ask')
await page.click('#an-off-yes')
await page.waitForSelector('#an-off-done')
check(`${label} ausschalten_bestaetigung`, (await page.textContent('#an-off-done')).includes('Ab jetzt werden keine Daten mehr gespeichert'))
await shot('ausgeschaltet')
check(`${label} keine_js_fehler`, errors.length === 0)
await ctx.close()
}
} finally {
await browser.close()
server.close()
}
process.exit(failed ? 1 : 0)