Analyse: Portal-Kachel und Auswertung
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fc937b0d1e
commit
5a6fbc7328
6 files changed
+483
-4
No files matched your search
@@ -0,0 +1,83 @@
|
||||
/* Reine Hilfsfunktionen der Analyse-Ansicht (funktionen.js). Ohne DOM, damit Node-Tests sie direkt importieren koennen. */
|
||||
|
||||
// Gleiche Regel wie analytics/client/core.js (contrastOk): weisser Text auf der Farbe braucht >= 4,5:1
|
||||
export function contrastOk(hex) {
|
||||
if (typeof hex !== 'string' || !/^#[0-9a-f]{6}$/i.test(hex)) return false
|
||||
const lin = (i) => {
|
||||
const c = parseInt(hex.slice(1 + i * 2, 3 + i * 2), 16) / 255
|
||||
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
|
||||
}
|
||||
const L = 0.2126 * lin(0) + 0.7152 * lin(1) + 0.0722 * lin(2)
|
||||
return 1.05 / (L + 0.05) >= 4.5
|
||||
}
|
||||
|
||||
export const FALLBACK_COLOR = '#111111'
|
||||
/** Farbe der beiden Banner-Knoepfe: die Akzentfarbe, wenn sie genug Kontrast hat, sonst #111111. */
|
||||
export const bannerColor = (hex) => (contrastOk(hex) ? String(hex).toLowerCase() : FALLBACK_COLOR)
|
||||
|
||||
export const DEVICE_LABELS = { m: 'Handy', t: 'Tablet', d: 'Computer' }
|
||||
export const CONTACT_LABELS = { tel: 'Telefon', mail: 'E-Mail', wa: 'WhatsApp', out: 'Externer Link' }
|
||||
|
||||
/** Flaggen-Emoji aus ISO-3166-Alpha-2-Code ('' bei ungueltigem Code). */
|
||||
export function flagEmoji(code) {
|
||||
if (!/^[A-Za-z]{2}$/.test(code || '')) return ''
|
||||
return String.fromCodePoint(...[...code.toUpperCase()].map((c) => 0x1f1a5 + c.charCodeAt(0)))
|
||||
}
|
||||
|
||||
let regionNames
|
||||
/** "DE" => "Deutschland"; '' => "Unbekannt"; unbekannter Code => der Code selbst. */
|
||||
export function countryName(code) {
|
||||
if (!/^[A-Za-z]{2}$/.test(code || '')) return 'Unbekannt'
|
||||
try {
|
||||
regionNames ||= new Intl.DisplayNames('de', { type: 'region' })
|
||||
return regionNames.of(code.toUpperCase()) || code.toUpperCase()
|
||||
} catch {
|
||||
return code.toUpperCase()
|
||||
}
|
||||
}
|
||||
|
||||
/** Herkunft: '' => "Direkt". */
|
||||
export const refLabel = (key) => key || 'Direkt'
|
||||
|
||||
/** Hoehe der Skala: naechste "runde" Zahl (1, 2, 5 x 10^n) ueber dem Maximum. */
|
||||
export function niceMax(max) {
|
||||
if (!(max > 0)) return 1
|
||||
const pow = 10 ** Math.floor(Math.log10(max))
|
||||
for (const m of [1, 2, 5, 10]) if (m * pow >= max) return m * pow
|
||||
return 10 * pow
|
||||
}
|
||||
|
||||
/** "2026-10-01" => "01.10." */
|
||||
export function shortDay(day) {
|
||||
const m = /^\d{4}-(\d{2})-(\d{2})$/.exec(day || '')
|
||||
return m ? `${m[2]}.${m[1]}.` : String(day || '')
|
||||
}
|
||||
|
||||
/** Koordinaten fuer das Liniendiagramm. series: [{day, pv}] => Punkte, Achsenbeschriftung, Pfad. */
|
||||
export function chartModel(series, { width = 640, height = 240, pad = { l: 40, r: 12, t: 12, b: 28 }, maxLabels = 6 } = {}) {
|
||||
const data = Array.isArray(series) ? series : []
|
||||
const innerW = width - pad.l - pad.r
|
||||
const innerH = height - pad.t - pad.b
|
||||
const top = niceMax(Math.max(0, ...data.map((d) => Number(d.pv) || 0)))
|
||||
const x = (i) => pad.l + (data.length > 1 ? (innerW * i) / (data.length - 1) : innerW / 2)
|
||||
const y = (v) => pad.t + innerH - (innerH * v) / top
|
||||
const points = data.map((d, i) => ({ x: round(x(i)), y: round(y(Number(d.pv) || 0)), day: d.day, pv: Number(d.pv) || 0 }))
|
||||
const step = Math.max(1, Math.ceil(data.length / maxLabels))
|
||||
const xLabels = points.filter((_, i) => i % step === 0 || i === data.length - 1).filter((_, i, a) => i === a.length - 1 || a[a.length - 1].x - a[i].x > 36).map((p) => ({ x: p.x, label: shortDay(p.day) }))
|
||||
const yTicks = [0, 0.5, 1].map((f) => ({ y: round(y(top * f)), label: String(Math.round(top * f)) }))
|
||||
const line = points.map((p, i) => `${i ? 'L' : 'M'}${p.x} ${p.y}`).join(' ')
|
||||
const baseY = round(pad.t + innerH)
|
||||
const area = points.length ? `${line} L${points[points.length - 1].x} ${baseY} L${points[0].x} ${baseY} Z` : ''
|
||||
return { width, height, pad, top, points, xLabels, yTicks, line, area }
|
||||
}
|
||||
|
||||
function round(n) {
|
||||
return Math.round(n * 10) / 10
|
||||
}
|
||||
|
||||
/** Zeilen fuer eine Balkenliste: [{label, count, pct}] mit pct relativ zum groessten Wert (0-100). */
|
||||
export function barRows(items, labelOf) {
|
||||
const list = (Array.isArray(items) ? items : []).map((i) => ({ key: i.key ?? '', count: Number(i.count) || 0 }))
|
||||
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) }))
|
||||
}
|
||||
+3
-2
@@ -1961,13 +1961,14 @@ function initPasswordView() {
|
||||
// "projekt" (Projektdetails als eigene Seite) gibt es ebenfalls nur in der App-Ansicht.
|
||||
// "passwort" oeffnet das Konto-Menue (Profilbild). #fortschritt fuehrt zu Projekte und scrollt zum Abschnitt
|
||||
// "Was wir zuletzt erledigt haben" (#pj-activity); hinter dem Hash darf ein Query stehen (#news?bestellt=1, Rueckkehr von Stripe).
|
||||
const VIEW_HASHES = { projects: '', funktionen: 'funktionen', news: 'news', invoices: 'rechnungen', chat: 'chat', vertraege: 'vertraege', stunden: 'stunden', 'bestellen-hosting': 'bestellen-hosting', 'bestellen-wartung': 'bestellen-wartung', wechsel: 'wechsel', takeover: 'gutschrift', projekt: 'projekt', passwort: 'passwort' }
|
||||
const VIEW_HASHES = { projects: '', funktionen: 'funktionen', news: 'news', analyse: 'analyse', invoices: 'rechnungen', chat: 'chat', vertraege: 'vertraege', stunden: 'stunden', 'bestellen-hosting': 'bestellen-hosting', 'bestellen-wartung': 'bestellen-wartung', wechsel: 'wechsel', takeover: 'gutschrift', projekt: 'projekt', passwort: 'passwort' }
|
||||
const HASH_ALIASES = { wartung: 'vertraege', abo: 'vertraege' }
|
||||
const viewLoaders = { chat: initChat }
|
||||
// Funktionen und News laden bei jedem Oeffnen neu (Zustand aendert sich durch Bestellung und Freischaltung); Modul erst dann laden
|
||||
const viewRefreshers = {
|
||||
funktionen: () => import('/funktionen.js').then((m) => m.initFunktionen(document.getElementById('funktionen-body'))),
|
||||
news: () => import('/funktionen.js').then((m) => m.initNews(document.getElementById('news-body'))),
|
||||
analyse: () => import('/funktionen.js').then((m) => m.initAnalyse(document.getElementById('analyse-body'))),
|
||||
}
|
||||
const loadedViews = new Set()
|
||||
|
||||
@@ -2157,7 +2158,7 @@ const appLayout = window.matchMedia('(max-width: 880px), (pointer: coarse) and (
|
||||
const coarsePointer = window.matchMedia('(pointer: coarse)')
|
||||
// Unterseite -> Tab, zu dem sie gehoert (und Text neben dem Zurueck-Pfeil).
|
||||
// "passwort" setzt onAppViewShown: die Ansicht, aus der das Konto-Menue geoeffnet wurde.
|
||||
const APP_PARENT = { news: 'funktionen', stunden: 'vertraege', 'bestellen-hosting': 'vertraege', 'bestellen-wartung': 'vertraege', wechsel: 'vertraege', takeover: 'vertraege', projekt: 'projects' }
|
||||
const APP_PARENT = { news: 'funktionen', analyse: 'funktionen', stunden: 'vertraege', 'bestellen-hosting': 'vertraege', 'bestellen-wartung': 'vertraege', wechsel: 'vertraege', takeover: 'vertraege', projekt: 'projects' }
|
||||
const APP_PARENT_LABEL = { vertraege: 'Verträge', projects: 'Projekte', funktionen: 'Funktionen', invoices: 'Rechnungen', chat: 'Chat' }
|
||||
const appShell = { view: 'projects', scroll: {}, fromHistory: false, sideCards: [], installPrompt: null }
|
||||
const TEXT_INPUT = 'textarea, input:not([type=checkbox]):not([type=radio]):not([type=file]):not([type=date]):not([type=button]):not([type=submit]):not([type=range]):not([type=color])'
|
||||
|
||||
@@ -176,6 +176,15 @@
|
||||
<div id="news-body"><p class="kb-empty">Lade…</p></div>
|
||||
</section>
|
||||
|
||||
<!-- Analyse: Einschalten oder Auswertung (funktionen.js, initAnalyse) -->
|
||||
<section class="kb-view" id="view-analyse">
|
||||
<header class="kb-head">
|
||||
<h1>Analyse</h1>
|
||||
<p>Sieh, wie deine Website genutzt wird – ohne Cookies.</p>
|
||||
</header>
|
||||
<div id="analyse-body"><p class="kb-empty">Lade…</p></div>
|
||||
</section>
|
||||
|
||||
<!-- Rechnungen -->
|
||||
<section class="kb-view" id="view-invoices">
|
||||
<header class="kb-head">
|
||||
|
||||
@@ -137,3 +137,66 @@
|
||||
.fn-tiles { grid-template-columns: minmax(0, 1fr); }
|
||||
.fn-facts dt { min-width: 0; flex: 1 0 100%; }
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- Analyse --- */
|
||||
#view-analyse .kb-panel { max-width: none; }
|
||||
#view-analyse .an-panel { max-width: 720px; }
|
||||
#view-analyse .fn-project { max-width: 640px; margin-bottom: 16px; display: flex; align-items: center; gap: 12px; flex-wrap: wrap; padding: 14px 18px; }
|
||||
#view-analyse .kb-panel + .kb-panel { margin-top: 16px; }
|
||||
.an-panel p { margin: 0 0 12px; line-height: 1.55; }
|
||||
.an-h4 { margin: 18px 0 8px; font-size: 14px; }
|
||||
.an-colors { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; }
|
||||
.an-colors input[type=color] { width: 48px; height: 40px; padding: 2px; border-radius: 10px; border: 1px solid var(--kb-border-strong); background: transparent; cursor: pointer; }
|
||||
.an-presets { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.an-preset { width: 32px; height: 32px; border-radius: 50%; border: 2px solid var(--kb-border-strong); cursor: pointer; padding: 0; }
|
||||
.an-hint { margin: 10px 0 0; font-size: 13px; color: #fcd34d; }
|
||||
/* Banner-Vorschau: gleiche Optik wie der echte Banner (analytics/client/w.js), immer hell */
|
||||
.an-bn { margin: 16px 0; padding: 14px 20px; display: flex; align-items: center; gap: 16px; flex-wrap: wrap; background: #fff; color: #111; border: 1px solid #d0d0d0; border-radius: 12px; font: 15px/1.45 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; }
|
||||
.an-bn-text { flex: 1 1 260px; min-width: 0; margin: 0 !important; color: #111 !important; font-size: 15px !important; line-height: 1.45 !important; }
|
||||
.an-bn-link { text-decoration: underline; }
|
||||
.an-bn-row { display: flex; gap: 10px; flex: none; }
|
||||
.an-bn-btn { display: inline-flex; align-items: center; justify-content: center; box-sizing: border-box; min-width: 120px; height: 44px; padding: 0 20px; border: 2px solid; border-radius: 8px; font: 600 15px/1 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; }
|
||||
|
||||
.an-range { display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; }
|
||||
.an-tab { padding: 8px 16px; border-radius: 999px; border: 1px solid var(--kb-border-strong); background: transparent; color: inherit; font: inherit; font-size: 14px; font-weight: 700; cursor: pointer; }
|
||||
.an-tab.active { background: var(--kb-purple-soft); border-color: var(--kb-purple-line); }
|
||||
.an-kpis { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-bottom: 16px; }
|
||||
.an-kpi { padding: 14px 16px; border-radius: 14px; background: var(--kb-surface); border: 1px solid var(--kb-border); min-width: 0; }
|
||||
.an-kpi span { display: block; font-size: 13px; color: var(--kb-muted); }
|
||||
.an-kpi b { display: block; margin-top: 4px; font-size: 26px; font-variant-numeric: tabular-nums; }
|
||||
.an-chart-box { margin-bottom: 16px; }
|
||||
.an-chart { display: block; width: 100%; height: auto; }
|
||||
.an-grid { stroke: var(--kb-border-strong); stroke-width: 1; }
|
||||
.an-axis { fill: var(--kb-muted); font-size: 11px; }
|
||||
.an-line { fill: none; stroke: var(--kb-lilac); stroke-width: 2.5; stroke-linejoin: round; stroke-linecap: round; }
|
||||
.an-area { fill: var(--kb-purple-soft); stroke: none; }
|
||||
.an-dot { fill: var(--kb-lilac); }
|
||||
.an-grid-lists { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; align-items: start; }
|
||||
#view-analyse .an-grid-lists .kb-panel { margin-top: 0; }
|
||||
.an-rows { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }
|
||||
.an-row-top { display: flex; justify-content: space-between; gap: 12px; font-size: 14px; }
|
||||
.an-row-label { min-width: 0; overflow-wrap: anywhere; }
|
||||
.an-row-top b { font-variant-numeric: tabular-nums; }
|
||||
.an-bar { height: 6px; margin-top: 4px; border-radius: 3px; background: rgba(255, 255, 255, 0.07); overflow: hidden; }
|
||||
.an-bar i { display: block; height: 100%; border-radius: 3px; background: var(--kb-purple); }
|
||||
.an-note, .an-credit { margin: 16px 0 0; font-size: 12.5px; line-height: 1.5; color: var(--kb-muted); }
|
||||
.an-credit { font-size: 11.5px; color: var(--kb-faint); }
|
||||
.an-credit a { color: inherit; }
|
||||
.an-heat { margin-top: 16px; }
|
||||
.an-heat p, .an-off p { margin: 0 0 12px; line-height: 1.55; }
|
||||
.an-heat-row { display: flex; flex-wrap: wrap; gap: 12px; margin-bottom: 12px; }
|
||||
.an-heat-row label { display: grid; gap: 4px; font-size: 13px; font-weight: 600; flex: 1 1 200px; min-width: 0; }
|
||||
.an-heat-row select { padding: 10px 12px; border-radius: 12px; border: 1px solid var(--kb-border-strong); background: rgba(255, 255, 255, 0.05); color: inherit; font: inherit; min-width: 0; }
|
||||
.an-heat-row select option { color: #111; }
|
||||
.an-off { margin-top: 16px; }
|
||||
.an-ghost { padding: 9px 16px; border-radius: 999px; border: 1px solid var(--kb-border-strong); background: transparent; color: inherit; font: inherit; font-size: 13px; font-weight: 700; cursor: pointer; }
|
||||
.an-ghost:hover { background: rgba(255, 255, 255, 0.07); }
|
||||
.an-danger { color: #fca5a5; }
|
||||
|
||||
@media (max-width: 880px) {
|
||||
.an-kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.an-grid-lists { grid-template-columns: minmax(0, 1fr); }
|
||||
.an-bn { display: block; margin: 12px 0; padding: 16px; }
|
||||
.an-bn-row { flex-direction: column; margin-top: 12px; }
|
||||
.an-bn-btn { width: 100%; }
|
||||
}
|
||||
+266
-2
@@ -3,7 +3,10 @@
|
||||
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'
|
||||
|
||||
const PRICE = '30 €'
|
||||
const AVV_URL = '/rechtliches/avv-website-analyse' // Platzhalter: das Dokument liefert Task 13
|
||||
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
|
||||
@@ -95,6 +98,38 @@ function newsTile(projects) {
|
||||
</article>`
|
||||
}
|
||||
|
||||
/* Analyse: Kachel je nach Zustand der Websites (comingSoon = Schalter aus) */
|
||||
const analyseOf = (project) => project.analyse || { comingSoon: true }
|
||||
const analyseState = (a) => (a.comingSoon ? 'soon' : a.state === 'active' ? 'active' : a.eligible ? 'off' : 'blocked')
|
||||
const ANALYSE_TEXT = 'Sieh, wie viele Besucher deine Website hat und was sie sich ansehen.'
|
||||
|
||||
function pickAnalyseProject(projects) {
|
||||
return projects.find((p) => analyseState(analyseOf(p)) === 'active') || projects.find((p) => analyseState(analyseOf(p)) === 'off') || projects[0]
|
||||
}
|
||||
|
||||
function analyseTile(projects) {
|
||||
const target = pickAnalyseProject(projects)
|
||||
const a = target && analyseOf(target)
|
||||
if (!target || a.comingSoon) return soonTile('chart', 'Analyse', ANALYSE_TEXT)
|
||||
const state = analyseState(a)
|
||||
const active = projects.filter((p) => analyseState(analyseOf(p)) === 'active').length
|
||||
const pill = state === 'active' ? ['Aktiv', 'on'] : ['Aus', 'off']
|
||||
let action
|
||||
if (state === 'active') action = `<button type="button" class="kb-btn-primary fn-tile-cta" data-view="analyse" data-project="${esc(target.projectId)}">Auswertung ansehen</button>`
|
||||
else if (state === 'off') action = `<button type="button" class="kb-btn-primary fn-tile-cta" data-view="analyse" data-project="${esc(target.projectId)}">Einrichten</button>`
|
||||
else if (a.reason === 'kein_abo') action = '<p class="fn-count">Im Wartungs-Abo enthalten. <button type="button" class="kb-link" data-view="vertraege">Zu Verträge</button></p>'
|
||||
else action = `<p class="fn-count">${esc(a.reasonText || 'Für diese Website gerade nicht verfügbar.')}</p>`
|
||||
const count = projects.length > 1 && active ? `<p class="fn-count">aktiv für ${active} von ${projects.length} Websites</p>` : ''
|
||||
return `
|
||||
<article class="fn-tile" data-fn-tile="analyse">
|
||||
<div class="fn-tile-head">${svg('chart')}<span class="fn-pill fn-pill-${pill[1]}">${pill[0]}</span></div>
|
||||
<h3>Analyse</h3>
|
||||
<p>${ANALYSE_TEXT} Ohne Cookies, auf einem Server in Deutschland.</p>
|
||||
${count}
|
||||
${action}
|
||||
</article>`
|
||||
}
|
||||
|
||||
const soonTile = (icon, title, text) => `
|
||||
<article class="fn-tile fn-tile-soon">
|
||||
<div class="fn-tile-head">${svg(icon)}<span class="fn-pill fn-pill-soon">Kommt bald</span></div>
|
||||
@@ -109,9 +144,10 @@ export async function initFunktionen(root) {
|
||||
<div class="fn-tiles">
|
||||
${newsTile(projects)}
|
||||
${soonTile('calendar', 'Terminplaner', 'Deine Kundschaft bucht Termine direkt über deine Website.')}
|
||||
${soonTile('chart', 'Analyse', 'Sieh, wie viele Besucher deine Website hat und was sie sich ansehen.')}
|
||||
${analyseTile(projects)}
|
||||
</div>`
|
||||
root.querySelector('.fn-tile-cta')?.addEventListener('click', (ev) => { selection.projectId = ev.currentTarget.dataset.project })
|
||||
root.querySelector('[data-fn-tile="news"] .fn-tile-cta')?.addEventListener('click', (ev) => { selection.projectId = ev.currentTarget.dataset.project })
|
||||
root.querySelector('[data-fn-tile="analyse"] .fn-tile-cta')?.addEventListener('click', (ev) => { analyseSel.projectId = ev.currentTarget.dataset.project })
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- News --- */
|
||||
@@ -505,3 +541,231 @@ function renderEditor(stage, project) {
|
||||
sync()
|
||||
return loadList()
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- Analyse --- */
|
||||
const analyseSel = { projectId: '', range: 30 }
|
||||
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 KPI_NOTE = '* Besucher, Verweildauer und Klicks zählen nur Besucher, die zugestimmt haben. Seitenaufrufe sind vollständig.'
|
||||
|
||||
export async function initAnalyse(root, { projectId } = {}) {
|
||||
if (!root) return
|
||||
if (projectId) analyseSel.projectId = projectId
|
||||
const { projects = [] } = await request('/api/funktionen')
|
||||
const usable = projects.filter((p) => !analyseOf(p).comingSoon)
|
||||
if (!usable.length) {
|
||||
root.innerHTML = '<div class="kb-panel kb-empty">Die Analyse ist für deine Websites noch nicht freigeschaltet.</div>'
|
||||
return
|
||||
}
|
||||
const project = usable.find((p) => p.projectId === analyseSel.projectId) || pickAnalyseProject(usable)
|
||||
analyseSel.projectId = project.projectId
|
||||
root.innerHTML = `
|
||||
${usable.length > 1 ? `
|
||||
<div class="fn-project kb-panel">
|
||||
<label for="an-project">Website</label>
|
||||
<select id="an-project">${usable.map((p) => `<option value="${esc(p.projectId)}"${p.projectId === project.projectId ? ' selected' : ''}>${esc(p.projectName)}</option>`).join('')}</select>
|
||||
</div>` : ''}
|
||||
<div id="an-stage"></div>`
|
||||
root.querySelector('#an-project')?.addEventListener('change', (ev) => {
|
||||
analyseSel.projectId = ev.target.value
|
||||
initAnalyse(root).catch((err) => { root.innerHTML = `<p class="muted">${esc(err.message)}</p>` })
|
||||
})
|
||||
const stage = root.querySelector('#an-stage')
|
||||
const a = analyseOf(project)
|
||||
const reload = () => initAnalyse(root).catch((err) => { root.innerHTML = `<p class="muted">${esc(err.message)}</p>` })
|
||||
if (a.state === 'active') renderAnalyseStats(stage, project, reload)
|
||||
else if (a.state === 'disabled') stage.innerHTML = '<div class="kb-panel"><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></div>'
|
||||
else if (!a.eligible) stage.innerHTML = `<div class="kb-panel"><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="kb-link" data-view="vertraege">Zu Verträge</button>' : ''}</p></div>`
|
||||
else renderAnalyseActivation(stage, project, reload)
|
||||
}
|
||||
|
||||
function bannerPreview(color) {
|
||||
const c = bannerColor(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>
|
||||
<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 a = analyseOf(project)
|
||||
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>
|
||||
<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">
|
||||
<div class="an-presets" role="group" aria-label="Vorgaben">${PRESET_COLORS.map((c) => `<button type="button" class="an-preset" data-color="${c}" style="background:${c}" aria-label="Farbe ${c}"></button>`).join('')}</div>
|
||||
</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>
|
||||
<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>`
|
||||
const input = stage.querySelector('#an-color')
|
||||
const hint = stage.querySelector('#an-hint')
|
||||
const paint = () => {
|
||||
stage.querySelector('#an-preview').innerHTML = bannerPreview(color)
|
||||
hint.classList.toggle('hidden', bannerColor(color) === color.toLowerCase())
|
||||
}
|
||||
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 button = stage.querySelector('#an-on')
|
||||
const error = stage.querySelector('#an-error')
|
||||
box.addEventListener('change', () => { button.disabled = !box.checked })
|
||||
button.addEventListener('click', async () => {
|
||||
if (!box.checked) return
|
||||
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 } } })
|
||||
reload()
|
||||
} catch (err) {
|
||||
error.textContent = err.message
|
||||
error.classList.remove('hidden')
|
||||
button.disabled = !box.checked
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function lineChart(series) {
|
||||
const m = chartModel(series)
|
||||
if (!m.points.length) return '<p class="nw-empty">Noch keine Daten.</p>'
|
||||
const total = m.points.reduce((sum, p) => sum + p.pv, 0)
|
||||
const first = m.points[0].day
|
||||
const last = m.points[m.points.length - 1].day
|
||||
return `
|
||||
<svg class="an-chart" viewBox="0 0 ${m.width} ${m.height}" role="img" aria-labelledby="an-chart-title">
|
||||
<title id="an-chart-title">Seitenaufrufe pro Tag vom ${esc(first)} bis ${esc(last)}, insgesamt ${esc(nf.format(total))}</title>
|
||||
${m.yTicks.map((t) => `<line class="an-grid" x1="${m.pad.l}" x2="${m.width - m.pad.r}" y1="${t.y}" y2="${t.y}"/><text class="an-axis" x="${m.pad.l - 6}" y="${t.y + 4}" text-anchor="end">${esc(t.label)}</text>`).join('')}
|
||||
${m.xLabels.map((l) => `<text class="an-axis" x="${l.x}" y="${m.height - 8}" text-anchor="middle">${esc(l.label)}</text>`).join('')}
|
||||
<path class="an-area" d="${m.area}"/>
|
||||
<path class="an-line" d="${m.line}"/>
|
||||
${m.points.length <= 31 ? m.points.map((p) => `<circle class="an-dot" cx="${p.x}" cy="${p.y}" r="3"><title>${esc(p.day)}: ${esc(nf.format(p.pv))}</title></circle>`).join('') : ''}
|
||||
</svg>`
|
||||
}
|
||||
|
||||
function barList(title, items, labelOf, { html = false } = {}) {
|
||||
const rows = barRows(items, labelOf)
|
||||
return `
|
||||
<section class="kb-panel an-list">
|
||||
<h3>${esc(title)}</h3>
|
||||
${rows.length ? `<ul class="an-rows">${rows.map((r) => `
|
||||
<li><div class="an-row-top"><span class="an-row-label">${html ? r.label : esc(r.label)}</span><b>${esc(nf.format(r.count))}</b></div><div class="an-bar"><i style="width:${r.pct}%"></i></div></li>`).join('')}</ul>` : '<p class="nw-empty">Noch keine Daten.</p>'}
|
||||
</section>`
|
||||
}
|
||||
|
||||
const COUNTRY_ROW = (key) => `${flagEmoji(key) ? `<span aria-hidden="true">${flagEmoji(key)}</span> ` : ''}${esc(countryName(key))}`
|
||||
|
||||
async function renderAnalyseStats(stage, project, reload) {
|
||||
const range = [7, 30, 90].includes(analyseSel.range) ? analyseSel.range : 30
|
||||
const pid = project.projectId
|
||||
stage.innerHTML = `
|
||||
<div class="an-range" role="tablist" aria-label="Zeitraum">${[7, 30, 90].map((r) => `<button type="button" role="tab" class="an-tab${r === range ? ' active' : ''}" aria-selected="${r === range}" data-range="${r}">${r} Tage</button>`).join('')}</div>
|
||||
<div id="an-data"><p class="kb-empty">Lade…</p></div>
|
||||
<section class="kb-panel an-heat" id="an-heat"></section>
|
||||
<section class="kb-panel an-off" id="an-off"></section>
|
||||
<p class="an-credit">Länderdaten: IP-Geolocation by <a href="https://db-ip.com" target="_blank" rel="noopener">DB-IP</a></p>`
|
||||
stage.querySelectorAll('.an-tab').forEach((b) => b.addEventListener('click', () => { analyseSel.range = Number(b.dataset.range); renderAnalyseStats(stage, project, reload) }))
|
||||
renderAnalyseOff(stage.querySelector('#an-off'), pid, reload)
|
||||
|
||||
const data = stage.querySelector('#an-data')
|
||||
let stats
|
||||
try {
|
||||
stats = await request(`/api/funktionen/analyse/stats?projectId=${encodeURIComponent(pid)}&range=${range}`)
|
||||
} catch (err) {
|
||||
if (!stage.isConnected) return
|
||||
data.innerHTML = `<p class="error" role="alert">${esc(err.status === 503 ? 'Auswertung gerade nicht erreichbar' : err.message)}</p>`
|
||||
return
|
||||
}
|
||||
if (!stage.isConnected) return
|
||||
const t = stats.totals || {}
|
||||
const rate = Math.round((Number(t.consentRate) || 0) * 100)
|
||||
const kpi = (label, value) => `<div class="an-kpi"><span>${label}</span><b>${esc(value)}</b></div>`
|
||||
const b = stats.banner || {}
|
||||
data.innerHTML = `
|
||||
<div class="an-kpis">${kpi('Seitenaufrufe', nf.format(t.pv || 0))}${kpi('Besucher*', nf.format(t.visitors || 0))}${kpi('Kontakt-Klicks*', nf.format(t.contacts || 0))}${kpi('Zustimmung', `${rate} %`)}</div>
|
||||
<section class="kb-panel an-chart-box"><h3>Seitenaufrufe pro Tag</h3>${lineChart(stats.series)}</section>
|
||||
<div class="an-grid-lists">
|
||||
${barList('Meistbesuchte Seiten', stats.pages, (k) => k || '/')}
|
||||
${barList('Herkunft', stats.refs, refLabel)}
|
||||
${barList('Länder', stats.countries, COUNTRY_ROW, { html: true })}
|
||||
${barList('Geräte', stats.devices, (k) => DEVICE_LABELS[k] || 'Unbekannt')}
|
||||
${barList('Kontakt-Klicks', stats.contacts, (k) => CONTACT_LABELS[k] || k)}
|
||||
${barList('Banner', [{ key: 'shown', count: b.shown }, { key: 'accept', count: b.accept }, { key: 'reject', count: b.reject }], (k) => ({ shown: 'Angezeigt', accept: 'Angenommen', reject: 'Abgelehnt' }[k]))}
|
||||
</div>
|
||||
<p class="an-note">${esc(KPI_NOTE)}</p>`
|
||||
renderAnalyseHeat(stage.querySelector('#an-heat'), pid, stats.pages || [])
|
||||
}
|
||||
|
||||
function renderAnalyseHeat(box, projectId, pages) {
|
||||
if (!box) return
|
||||
if (!pages.length) {
|
||||
box.innerHTML = '<h3>Heatmap</h3><p class="nw-empty">Sobald Besucher zugestimmt haben und Seiten aufgerufen wurden, kannst du hier sehen, wo geklickt und gescrollt wird.</p>'
|
||||
return
|
||||
}
|
||||
box.innerHTML = `
|
||||
<h3>Heatmap</h3>
|
||||
<p>Sieh auf deiner Website, wo Besucher klicken und wie weit sie scrollen.</p>
|
||||
<div class="an-heat-row">
|
||||
<label>Seite<select id="an-heat-page">${pages.map((p) => `<option value="${esc(p.key)}">${esc(p.key || '/')}</option>`).join('')}</select></label>
|
||||
<label>Gerät<select id="an-heat-vp">${Object.entries(DEVICE_LABELS).map(([k, v]) => `<option value="${k}"${k === 'm' ? ' selected' : ''}>${v}</option>`).join('')}</select></label>
|
||||
</div>
|
||||
<p class="error hidden" id="an-heat-error" role="alert"></p>
|
||||
<div class="abo-actions"><button type="button" class="abo-btn" id="an-heat-go">Auf meiner Website ansehen</button></div>`
|
||||
const go = box.querySelector('#an-heat-go')
|
||||
const error = box.querySelector('#an-heat-error')
|
||||
go.addEventListener('click', async () => {
|
||||
go.disabled = true
|
||||
error.classList.add('hidden')
|
||||
try {
|
||||
const { url } = await request('/api/funktionen/analyse/heatmap-link', { method: 'POST', body: { projectId, path: box.querySelector('#an-heat-page').value || '/', vp: box.querySelector('#an-heat-vp').value } })
|
||||
if (safeUrl(url)) window.open(url, '_blank', 'noopener')
|
||||
} catch (err) {
|
||||
error.textContent = err.status === 503 ? 'Auswertung gerade nicht erreichbar' : err.message
|
||||
error.classList.remove('hidden')
|
||||
} finally {
|
||||
go.disabled = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Ausschalten mit Bestaetigung in der Seite (zweiter Klick), ohne Browser-Dialog
|
||||
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 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')
|
||||
const error = box.querySelector('#an-off-error')
|
||||
box.querySelector('#an-off-ask').addEventListener('click', () => {
|
||||
actions.innerHTML = '<span>Wirklich ausschalten?</span> <button type="button" class="an-ghost an-danger" id="an-off-yes">Ja, ausschalten</button> <button type="button" class="an-ghost" id="an-off-no">Abbrechen</button>'
|
||||
actions.querySelector('#an-off-no').addEventListener('click', () => renderAnalyseOff(box, projectId, reload))
|
||||
actions.querySelector('#an-off-yes').addEventListener('click', async (ev) => {
|
||||
ev.currentTarget.disabled = true
|
||||
try {
|
||||
await request('/api/funktionen/analyse/deactivate', { method: 'POST', body: { projectId } })
|
||||
reload()
|
||||
} catch (err) {
|
||||
renderAnalyseOff(box, projectId, reload)
|
||||
const e2 = box.querySelector('#an-off-error')
|
||||
e2.textContent = err.message
|
||||
e2.classList.remove('hidden')
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
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'
|
||||
|
||||
test('contrast und bannerColor stimmen mit core.js ueberein', () => {
|
||||
for (const c of ['#000000', '#ffffff', '#1d4ed8', '#047857', '#b91c1c', '#7c3aed', '#c2410c', '#ffcc00', '#9d3dfe', 'x', '#12']) {
|
||||
const { contrastOk: portal } = { contrastOk: bannerColor(c) !== '#111111' || c.toLowerCase() === '#111111' }
|
||||
assert.equal(portal, contrastOk(c) || c.toLowerCase() === '#111111', c)
|
||||
}
|
||||
assert.equal(bannerColor('#ffcc00'), '#111111')
|
||||
assert.equal(bannerColor('#1D4ED8'), '#1d4ed8')
|
||||
assert.equal(bannerColor('nonsense'), '#111111')
|
||||
})
|
||||
|
||||
test('Land und Flagge', () => {
|
||||
assert.equal(countryName('DE'), 'Deutschland')
|
||||
assert.equal(countryName(''), 'Unbekannt')
|
||||
assert.equal(flagEmoji('de'), '🇩🇪')
|
||||
assert.equal(flagEmoji(''), '')
|
||||
assert.equal(flagEmoji('<b>'), '')
|
||||
assert.equal(refLabel(''), 'Direkt')
|
||||
})
|
||||
|
||||
test('Diagramm-Skalierung', () => {
|
||||
assert.equal(niceMax(0), 1)
|
||||
assert.equal(niceMax(7), 10)
|
||||
assert.equal(niceMax(130), 200)
|
||||
assert.equal(shortDay('2026-10-01'), '01.10.')
|
||||
const series = Array.from({ length: 30 }, (_, i) => ({ day: `2026-09-${String(i + 1).padStart(2, '0')}`, pv: i }))
|
||||
const m = chartModel(series)
|
||||
assert.equal(m.points.length, 30)
|
||||
assert.equal(m.top, 50)
|
||||
assert.ok(m.points[0].x < m.points[29].x)
|
||||
assert.ok(m.points.every((p) => p.x >= m.pad.l && p.x <= m.width - m.pad.r && p.y >= m.pad.t && p.y <= m.height - m.pad.b))
|
||||
assert.ok(m.xLabels.length <= 7 && m.xLabels.length >= 2)
|
||||
assert.equal(chartModel([]).points.length, 0)
|
||||
assert.equal(chartModel([{ day: '2026-10-01', pv: 3 }]).points.length, 1)
|
||||
})
|
||||
|
||||
test('Balkenzeilen', () => {
|
||||
assert.deepEqual(barRows([{ key: 'a', count: 4 }, { key: 'b', count: 2 }], (k) => k.toUpperCase()), [
|
||||
{ key: 'a', label: 'A', count: 4, pct: 100 },
|
||||
{ key: 'b', label: 'B', count: 2, pct: 50 },
|
||||
])
|
||||
assert.deepEqual(barRows(undefined, String), [])
|
||||
})
|
||||
|
||||
test('Portal: Analyse-Ansicht ist verdrahtet', () => {
|
||||
const app = readFileSync(new URL('../public/app.js', import.meta.url), 'utf8')
|
||||
const html = readFileSync(new URL('../public/dashboard.html', import.meta.url), 'utf8')
|
||||
assert.match(app, /analyse: 'analyse'/)
|
||||
assert.match(app, /APP_PARENT = \{ news: 'funktionen', analyse: 'funktionen'/)
|
||||
assert.match(html, /id="view-analyse"/)
|
||||
assert.match(html, /id="analyse-body"/)
|
||||
const fn = readFileSync(new URL('../public/funktionen.js', import.meta.url), 'utf8')
|
||||
assert.doesNotMatch(fn.slice(fn.indexOf('Analyse ---')), /\bconfirm\(/)
|
||||
})
|
||||
Reference in new issue
Block a user