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()