Merge origin/main in feature/terminplaner-tp2 (Analyse-Cockpit, Subdomain-Sperre, Projekte)
Konflikte: funktionen.js folgt der Aufteilung aus main (fn-util.js, analyse.js); der Link
'Zu Verträge' der Analyse-Seite kommt jetzt auch in analyse.js aus vertraege-link.js.
Checkout und Team-Ansicht übernehmen assertOwnProject(..., { forNewOrder: true }) und
includeArchived aus main. Test-Pins der absoluten Importe nachgezogen.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
af63b69ec1
56 files changed
+4583
-717
No files matched your search
@@ -0,0 +1,297 @@
|
||||
/* HTML-Bausteine der Analyse-Uebersicht ("Cockpit"). Reine String-Builder ohne DOM und ohne Netz; analyse.js setzt sie ein, analyse.css stylt sie.
|
||||
Jeder Wert aus Daten (Pfade, Hosts, Projektnamen, Tage, Links) laeuft durch esc(). Texte: Spec 2.1-2.4.
|
||||
|
||||
Vertrag fuer analyse.js und analyse.css (Praefix an-, Zustaende als Klasse "on" oder Attribut):
|
||||
Kopf .an-top > .an-site[data-state=on|warn|off] (.an-status-dot, .an-site-name | select#an-project.an-pick, .an-status "Analyse aktiv · Heute bisher: N Aufrufe · Stand: HH:MM Uhr") + .an-seg[role=group] button[data-range=7|30|90][aria-pressed] (.on)
|
||||
Reiter .an-tabs[role=tablist] > button.an-tabbtn[role=tab][data-tab=uebersicht|heatmap|einstellungen][aria-selected] (.on), aria-controls="an-stage"
|
||||
Wurzel .an-ov (Raster, Abstand 14 px) enthaelt: .an-sum, .an-kpis, .an-row2, 2x .an-pair, .an-credit
|
||||
Wichtigste .an-sum > h3.an-eyebrow, p.an-lead (darin <b>), ul.an-points > li > span.an-point-ico (svg.an-ico) + span; Leerzustand: .an-sum.an-sum-empty
|
||||
Kacheln .an-kpis > 4x .an-card.an-kpi[data-kpi=pv|visitors|contacts|time] > span.an-k (span.an-star), b.an-v, small.an-sub, .an-cmp (span.an-delta.up|down|flat[role=img][aria-label], span.an-vs), svg.an-spark (path.an-spark-area, path.an-spark-line)
|
||||
Diagramm .an-card.an-chartbox > .an-head (h3, .an-legend > span.an-leg > i.an-leg-line[.an-leg-prev]) + .an-chartwrap > svg.an-chart (line.an-grid, text.an-axis, path.an-area, path.an-prev nur mit Vergleich, path.an-line,
|
||||
circle.an-dot[data-i][data-day][data-pv][data-prev] fuer jeden Tag, per CSS erst bei Fokus sichtbar) + div.an-tip[hidden] (Blase; Text aus chartTipText)
|
||||
Kontakte .an-card.an-contacts > .an-head (h3, b.an-sum-n) + ul.an-crows > li.an-crow[data-kind=tel|mail|wa|out] (span.an-cico, span.an-cname, b, span.an-delta) + p.an-quote; Leertext: p.an-empty
|
||||
Ranglisten section.an-card.an-rank[data-rank=seen|who][data-rank-active=<Reiter>] > h3, .an-ltabs[role=group] > button.an-ltab[data-rank-tab][aria-pressed] (.on), ul.an-lrows > li.an-lr
|
||||
(span.an-lab > i.an-fill[style=width:N%] + span.an-name[title], b, em), button.an-more[data-rank-more] ("Alle anzeigen", nur bei mehr als 6 Zeilen und nicht aufgeklappt)
|
||||
Datenbasis .an-card.an-basis > svg.an-ring (circle.an-ring-bg, circle.an-ring-fg, text.an-ring-n) + p.an-basis-text > span.an-basis-main
|
||||
Heatmap .an-card.an-teaser > .an-teaser-row > .an-mini-heat + p + button.an-btn[data-tab=heatmap] (data-tab auch auf dem Reiterknopf: Handler auf .an-tabs bzw. .an-teaser einschraenken)
|
||||
Einstell. .an-ov.an-settings > .an-card.an-status-card (dl.an-dl, span.an-swatch) + .an-card.an-docs (ul > li > a) + section.an-card#an-off (leer, fuellt renderAnalyseOff)
|
||||
Laden .an-ov.an-skeleton[aria-busy] mit .an-sk-Platzhaltern (.an-sk > i); .an-sr = nur fuer Screenreader sichtbar (muss in analyse.css stehen)
|
||||
Fehler section.an-card.an-error[role=alert] > p + button.an-btn#an-retry */
|
||||
|
||||
import { esc } from './fn-util.js'
|
||||
import { fmtNum, fmtDuration, periodLabel, shortDay, dayLabel, compareFrom, delta, contactRate, refName, summarize, chartModel, barRows, countryName, flagEmoji, DEVICE_LABELS, bannerColor } from './analyse-view.js'
|
||||
|
||||
const RANGES = [7, 30, 90]
|
||||
const isNum = (v) => typeof v === 'number' && Number.isFinite(v)
|
||||
const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0)
|
||||
const round = (n) => Math.round(n * 10) / 10
|
||||
const list = (v) => (Array.isArray(v) ? v : [])
|
||||
|
||||
// Symbole wie ICONS in funktionen.js: 24er Raster, nur Striche
|
||||
const PHONE = '<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.96.36 1.9.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.91.34 1.85.57 2.81.7A2 2 0 0 1 22 16.92z"/>'
|
||||
const ICONS = {
|
||||
contact: PHONE,
|
||||
tel: PHONE,
|
||||
mail: '<rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>',
|
||||
wa: '<path d="M7.9 20A9 9 0 1 0 4 16.1L2 22z"/>',
|
||||
out: '<path d="M7 7h10v10M7 17 17 7"/>',
|
||||
day: '<rect x="3" y="5" width="18" height="16" rx="3"/><path d="M8 3v4M16 3v4M3 10h18"/>',
|
||||
source: '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
|
||||
device: '<rect x="6" y="2" width="12" height="20" rx="3"/><path d="M11 18h2"/>',
|
||||
page: '<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5M9 13h6M9 17h6"/>',
|
||||
}
|
||||
const ico = (name) => `<svg class="an-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${ICONS[name] || ''}</svg>`
|
||||
|
||||
// Veraenderungs-Chip aus delta(); ohne Wert nichts
|
||||
const chip = (d) => (d ? `<span class="an-delta ${d.dir}" role="img" aria-label="${esc(d.aria)}">${esc(d.text)}</span>` : '')
|
||||
const rateDe = (n) => n.toLocaleString('de-DE', { minimumFractionDigits: 1, maximumFractionDigits: 1 })
|
||||
|
||||
/* --- Kopfleiste und Reiter, Spec 2.1 --- */
|
||||
|
||||
const STATE_TEXT = { disabled: 'Analyse ist abgeschaltet', unknown: 'Status gerade nicht abrufbar' }
|
||||
|
||||
// Statuszeile: aktiv (mit "Heute bisher" aus stats.today und Stand), Dev-Modus (gelb) oder, bei Websites ohne Messung, der tatsaechliche Zustand
|
||||
function statusOf(project, devMode, stamp, today) {
|
||||
if (devMode) return { s: 'warn', text: 'Im Dev-Modus wird nicht gemessen.' }
|
||||
const a = project?.analyse
|
||||
const state = typeof a?.state === 'string' ? a.state : 'active'
|
||||
if (state === 'active') {
|
||||
const pv = isNum(today?.pv) ? today.pv : null // alter Collector ohne today: kein "Heute bisher"
|
||||
// jeder Teil bricht nur als Ganzes in die naechste Zeile (kein "Stand:" allein am Zeilenende)
|
||||
const parts = ['Analyse aktiv', pv === null ? '' : `Heute bisher: ${fmtNum(pv)} ${pv === 1 ? 'Aufruf' : 'Aufrufe'}`, stamp ? `Stand: ${stamp} Uhr` : '']
|
||||
return { s: 'on', text: parts.filter(Boolean).map((t) => `<span class="an-st">${t}</span>`).join(' · ') }
|
||||
}
|
||||
return { s: 'off', text: Object.hasOwn(STATE_TEXT, state) ? STATE_TEXT[state] : a.eligible === false ? 'Analyse nicht verfügbar' : 'Analyse ist noch ausgeschaltet' }
|
||||
}
|
||||
|
||||
/** Kopfleiste. usable: Websites mit Analyse (Auswahlfeld erst ab 2), stamp: Uhrzeit "19:42" des Ladens, today: stats.today (heute bisher). */
|
||||
export function headerHtml({ project, usable, range, showRange, devMode, stamp, today } = {}) {
|
||||
const sites = list(usable)
|
||||
const st = statusOf(project, devMode, stamp ? esc(stamp) : '', today)
|
||||
const current = RANGES.includes(Number(range)) ? Number(range) : 30
|
||||
const name = sites.length > 1
|
||||
? `<select id="an-project" class="an-pick" aria-label="Website wählen">${sites.map((p) => `<option value="${esc(p.projectId)}"${p.projectId === project?.projectId ? ' selected' : ''}>${esc(p.projectName)}</option>`).join('')}</select>`
|
||||
: `<b class="an-site-name">${esc(project?.projectName)}</b>`
|
||||
const seg = showRange
|
||||
? `<div class="an-seg" role="group" aria-label="Zeitraum">${RANGES.map((r) => `<button type="button"${r === current ? ' class="on"' : ''} data-range="${r}" aria-pressed="${r === current}">${r} Tage</button>`).join('')}</div>`
|
||||
: ''
|
||||
return `<div class="an-top"><div class="an-site" data-state="${st.s}"><span class="an-status-dot" aria-hidden="true"></span><div>${name}<small class="an-status">${st.text}</small></div></div>${seg}</div>`
|
||||
}
|
||||
|
||||
const TABS = [['uebersicht', 'Übersicht'], ['heatmap', 'Heatmap'], ['einstellungen', 'Einstellungen']]
|
||||
|
||||
export function tabsHtml(tab) {
|
||||
const active = TABS.some(([k]) => k === tab) ? tab : 'uebersicht'
|
||||
return `<div class="an-tabs" role="tablist" aria-label="Bereiche der Analyse">${TABS.map(([k, label]) => `<button type="button" role="tab" class="an-tabbtn${k === active ? ' on' : ''}" id="an-tab-${k}" data-tab="${k}" aria-selected="${k === active}" aria-controls="an-stage">${label}</button>`).join('')}</div>`
|
||||
}
|
||||
|
||||
/* --- Das Wichtigste und Kennzahlen, Spec 2.1, 2.3, 2.4 --- */
|
||||
|
||||
export function summaryHtml(stats) {
|
||||
const s = summarize(stats)
|
||||
const period = periodLabel(stats?.from, stats?.to)
|
||||
const head = `<h3 class="an-eyebrow">Das Wichtigste${period ? ` · ${esc(period)}` : ''}</h3>`
|
||||
if (!s) return `<section class="an-sum an-sum-empty">${head}<p class="an-lead">Sobald ein paar mehr Besuche da sind, steht hier das Wichtigste über deine Website in zwei, drei Sätzen.</p></section>`
|
||||
// lead und items[].html liefert summarize bereits maskiert
|
||||
const points = s.items.length ? `<ul class="an-points">${s.items.map((i) => `<li><span class="an-point-ico">${ico(i.icon)}</span><span>${i.html}</span></li>`).join('')}</ul>` : ''
|
||||
return `<section class="an-sum">${head}<p class="an-lead">${s.lead}</p>${points}</section>`
|
||||
}
|
||||
|
||||
/** Mini-Verlauf (Flaeche + Linie, ohne Achsen) aus Zahlen; null/undefined ist eine Luecke (Tag ohne Messung): die Linie bricht dort ab, es wird nie als 0 gezeichnet. Unter 2 Werten nichts. */
|
||||
export function sparkHtml(values) {
|
||||
const v = list(values).map((x) => (x === null || x === undefined ? null : num(x)))
|
||||
if (v.length < 2 || v.filter((x) => x !== null).length < 2) return ''
|
||||
const max = Math.max(1, ...v.filter((x) => x !== null)) * 1.1
|
||||
const x = (i) => round((200 * i) / (v.length - 1))
|
||||
const pt = (y, i) => `${x(i)} ${round(36 - (32 * y) / max)}`
|
||||
// zusammenhaengende Abschnitte ohne Luecke: [[wert, index], ...]
|
||||
const runs = []
|
||||
v.forEach((y, i) => {
|
||||
if (y === null) runs.push(null)
|
||||
else if (runs.length && runs[runs.length - 1]) runs[runs.length - 1].push([y, i])
|
||||
else runs.push([[y, i]])
|
||||
})
|
||||
const parts = runs.filter(Boolean)
|
||||
const seg = (r) => r.map(([y, i], k) => `${k ? 'L' : 'M'}${pt(y, i)}`).join(' ')
|
||||
// ein einzelner Tag zwischen Luecken wird ein Punkt (Strich der Laenge 0, runde Linienenden)
|
||||
const line = parts.map((r) => (r.length === 1 ? `${seg(r)} L${pt(...r[0])}` : seg(r))).join(' ')
|
||||
const area = parts.filter((r) => r.length > 1).map((r) => `${seg(r)} L${x(r[r.length - 1][1])} 38 L${x(r[0][1])} 38 Z`).join(' ')
|
||||
return `<svg class="an-spark" viewBox="0 0 200 38" preserveAspectRatio="none" aria-hidden="true" focusable="false"><path class="an-spark-area" d="${area}"/><path class="an-spark-line" d="${line}" fill="none" vector-effect="non-scaling-stroke"/></svg>`
|
||||
}
|
||||
|
||||
// Tageswerte einer Reihe: fehlende Werte 0; Reihe ohne das Feld (alter Collector) => null.
|
||||
// gaps: Tage ohne Wert bleiben null (Luecke im Verlauf), z.B. die Zeit pro Seite an Tagen ohne Zustimmung
|
||||
const trend = (series, key, gaps = false) => {
|
||||
const rows = list(series)
|
||||
return rows.some((d) => Object.hasOwn(d ?? {}, key)) ? rows.map((d) => (gaps && !isNum(d?.[key]) ? null : num(d?.[key]))) : null
|
||||
}
|
||||
|
||||
export function kpisHtml(stats) {
|
||||
const t = stats?.totals || {}
|
||||
const cmp = compareFrom(stats)
|
||||
const p = cmp.ok ? stats.previous.totals || {} : null
|
||||
const tiles = [
|
||||
{ key: 'pv', label: 'Seitenaufrufe', value: fmtNum(t.pv), d: p && delta(t.pv, p.pv), vs: p && fmtNum(p.pv) },
|
||||
{ key: 'visitors', label: 'Besucher', star: true, value: fmtNum(t.visitors), d: p && delta(t.visitors, p.visitors), vs: p && fmtNum(p.visitors) },
|
||||
{ key: 'contacts', label: 'Kontakt-Aktionen', star: true, value: fmtNum(t.contacts), d: p && delta(t.contacts, p.contacts), vs: p && fmtNum(p.contacts) },
|
||||
{ key: 'time', label: 'Ø Zeit pro Seite', star: true, value: fmtDuration(t.avgSec), sub: isNum(t.avgScroll) ? `Ø ${fmtNum(t.avgScroll)} % gescrollt` : '', d: p && delta(t.avgSec, p.avgSec, { kind: 'sec' }), vs: p && fmtDuration(p.avgSec) },
|
||||
]
|
||||
const field = { pv: 'pv', visitors: 'visitors', contacts: 'contacts', time: 'avgSec' }
|
||||
const tile = (k) => {
|
||||
const foot = k.d ? `${chip(k.d)}<span class="an-vs">vs. ${esc(k.vs)}</span>` : !cmp.ok && cmp.from ? `<span class="an-vs">Vergleich ab ${esc(cmp.from)}</span>` : ''
|
||||
return `<div class="an-card an-kpi" data-kpi="${k.key}"><span class="an-k">${k.label}${k.star ? '<span class="an-star">*</span>' : ''}</span><b class="an-v">${esc(k.value)}</b>${k.sub ? `<small class="an-sub">${esc(k.sub)}</small>` : ''}<div class="an-cmp">${foot}</div>${sparkHtml(trend(stats?.series, field[k.key], k.key === 'time'))}</div>`
|
||||
}
|
||||
return `<div class="an-kpis">${tiles.map(tile).join('')}</div>`
|
||||
}
|
||||
|
||||
/* --- Diagramm und Kontakt-Aktionen, Spec 2.1 --- */
|
||||
|
||||
/** Text der Blase ueber einem Tag: "Sa., 19.09.: 371 Aufrufe (davor 210)"; prev leer/null => ohne Klammer. */
|
||||
export function chartTipText(day, pv, prev) {
|
||||
const n = num(pv)
|
||||
const before = prev === null || prev === undefined || prev === '' ? '' : ` (davor ${fmtNum(prev)})`
|
||||
return `${dayLabel(day)}: ${fmtNum(n)} ${n === 1 ? 'Aufruf' : 'Aufrufe'}${before}`
|
||||
}
|
||||
|
||||
export function chartHtml(stats) {
|
||||
const series = list(stats?.series)
|
||||
const cmp = compareFrom(stats)
|
||||
const head = (legend) => `<div class="an-head"><h3>Seitenaufrufe pro Tag</h3>${legend}</div>`
|
||||
if (!series.length) return `<section class="an-card an-chartbox">${head('')}<p class="an-empty">Noch keine Daten.</p></section>`
|
||||
// Schrift der Achsen in viewBox-Einheiten: bis ca. 24 auf dem schmalsten Handy (analyse.css). Der linke Rand haelt die breiteste
|
||||
// y-Beschriftung (Ziffern ca. 0,6 x 24 breit), die x-Beschriftungen halten 100 Einheiten Abstand (eine Datumsbeschriftung ist bei Schrift 28 ca. 92 breit)
|
||||
const opts = { height: 300, maxLabels: 5, labelGap: 100, compare: cmp.ok ? stats.previous.series : undefined }
|
||||
const padL = Math.max(40, Math.round(6 + String(chartModel(series, opts).top).length * 14.4))
|
||||
const m = chartModel(series, { ...opts, pad: { l: padL, r: 24, t: 12, b: 40 } })
|
||||
const hasPrev = Boolean(m.prevLine)
|
||||
const days = Number(stats?.range) || series.length
|
||||
const total = m.points.reduce((sum, p) => sum + p.pv, 0)
|
||||
const title = `Seitenaufrufe pro Tag vom ${shortDay(m.points[0].day)} bis ${shortDay(m.points[m.points.length - 1].day)}, insgesamt ${fmtNum(total)}`
|
||||
const legend = `<div class="an-legend"><span class="an-leg"><i class="an-leg-line"></i>${days} Tage</span>${hasPrev ? '<span class="an-leg"><i class="an-leg-line an-leg-prev"></i>Zeitraum davor</span>' : ''}</div>`
|
||||
const dots = m.points.map((p, i) => `<circle class="an-dot" data-i="${i}" cx="${p.x}" cy="${p.y}" r="3" data-day="${esc(p.day)}" data-pv="${p.pv}"${p.prev === null ? '' : ` data-prev="${p.prev}"`}/>`).join('')
|
||||
return `<section class="an-card an-chartbox">${head(legend)}<div class="an-chartwrap"><svg class="an-chart" viewBox="0 0 ${m.width} ${m.height}" role="img" aria-labelledby="an-chart-title"><title id="an-chart-title">${esc(title)}</title>`
|
||||
+ m.yTicks.map((k) => `<line class="an-grid" x1="${m.pad.l}" x2="${m.width - m.pad.r}" y1="${k.y}" y2="${k.y}"/><text class="an-axis" x="${m.pad.l - 6}" y="${k.y}" dy="0.35em" text-anchor="end">${esc(k.label)}</text>`).join('')
|
||||
+ m.xLabels.map((l) => `<text class="an-axis" x="${l.x}" y="${m.height - 9}" text-anchor="middle">${esc(l.label)}</text>`).join('')
|
||||
+ `<path class="an-area" d="${m.area}"/>${hasPrev ? `<path class="an-prev" d="${m.prevLine}"/>` : ''}<path class="an-line" d="${m.line}"/>${dots}</svg><div class="an-tip" hidden></div></div></section>`
|
||||
}
|
||||
|
||||
const CONTACT_KINDS = [['tel', 'Anrufe'], ['mail', 'E-Mails'], ['wa', 'WhatsApp'], ['out', 'Links nach außen']]
|
||||
|
||||
export function contactsHtml(stats) {
|
||||
const t = stats?.totals || {}
|
||||
const total = num(t.contacts)
|
||||
const cmp = compareFrom(stats)
|
||||
const before = cmp.ok ? stats.previous : null
|
||||
const head = `<div class="an-head"><h3>Kontakt-Aktionen</h3><b class="an-sum-n">${fmtNum(total)}</b></div>`
|
||||
if (total < 1) return `<section class="an-card an-contacts">${head}<p class="an-empty">Noch keine Kontakt-Aktionen. Sie zählen, wenn jemand auf deiner Website auf Telefonnummer, E-Mail-Adresse oder WhatsApp tippt.</p></section>`
|
||||
const countOf = (rows, key) => num(list(rows).find((c) => c?.key === key)?.count)
|
||||
const rows = CONTACT_KINDS.map(([key, label]) => {
|
||||
const cur = countOf(stats.contacts, key)
|
||||
const was = before ? countOf(before.contacts, key) : 0
|
||||
if (!cur && !was) return ''
|
||||
return `<li class="an-crow" data-kind="${key}"><span class="an-cico">${ico(key)}</span><span class="an-cname">${label}</span><b>${fmtNum(cur)}</b>${chip(before && delta(cur, was))}</li>`
|
||||
}).join('')
|
||||
// Kontakt-Aktionen sind Klicks, keine Personen: "pro 100 Besucher"; ab 30 Besuchern
|
||||
const rate = contactRate(total, num(t.visitors))
|
||||
const wasRate = before ? contactRate(num(before.totals?.contacts), num(before.totals?.visitors)) : null
|
||||
const quote = rate === null ? '' : `<p class="an-quote">Auf 100 Besucher kommen ${rateDe(rate)} Kontakt-Aktionen${wasRate === null ? '' : ` (davor ${rateDe(wasRate)})`}.</p>`
|
||||
return `<section class="an-card an-contacts">${head}${rows ? `<ul class="an-crows">${rows}</ul>` : ''}${quote}</section>`
|
||||
}
|
||||
|
||||
/* --- Ranglisten, Spec 2.1 --- */
|
||||
|
||||
const CARDS = {
|
||||
seen: { title: 'Was angesehen wird', tabs: [['pages', 'Seiten'], ['refs', 'Herkunft']] },
|
||||
who: { title: 'Wer zu Besuch ist', tabs: [['devices', 'Geräte'], ['countries', 'Länder']] },
|
||||
}
|
||||
const NAMERS = {
|
||||
pages: (key) => (key === '' || key === '/' ? 'Startseite' : key),
|
||||
refs: refName,
|
||||
devices: (key) => (Object.hasOwn(DEVICE_LABELS, key) ? DEVICE_LABELS[key] : 'Unbekannt'),
|
||||
countries: countryName,
|
||||
}
|
||||
const SHORT = 6
|
||||
const LONG = 20
|
||||
|
||||
/** Rangliste "seen" (Seiten | Herkunft) oder "who" (Geraete | Laender); tab waehlt den Reiter, expanded zeigt bis 20 statt 6 Zeilen. */
|
||||
export function rankCardHtml(card, stats, { tab, expanded } = {}) {
|
||||
if (!Object.hasOwn(CARDS, card)) return ''
|
||||
const def = CARDS[card]
|
||||
const active = def.tabs.some(([k]) => k === tab) ? tab : def.tabs[0][0]
|
||||
const items = list(stats?.[active])
|
||||
const listSum = items.reduce((a, r) => a + num(r?.count), 0)
|
||||
// Anteil an allen Seitenaufrufen (die Liste zeigt hoechstens 20 Eintraege); Herkunft kennt nur die Summe der Liste (Besucher ohne Zustimmung haben keine)
|
||||
const sum = active === 'refs' ? listSum : Math.max(listSum, num(stats?.totals?.pv))
|
||||
const share = (c) => {
|
||||
const pct = sum > 0 ? (c / sum) * 100 : 0
|
||||
return pct > 0 && pct < 0.5 ? '< 1 %' : `${Math.round(pct)} %`
|
||||
}
|
||||
const all = barRows(items, NAMERS[active])
|
||||
const row = (r) => {
|
||||
const flag = active === 'countries' && flagEmoji(r.key) ? `<span class="an-flag" aria-hidden="true">${flagEmoji(r.key)}</span> ` : ''
|
||||
return `<li class="an-lr"><span class="an-lab"><i class="an-fill" style="width:${r.pct}%" aria-hidden="true"></i><span class="an-name" title="${esc(r.label)}">${flag}${esc(r.label)}</span></span><b>${fmtNum(r.count)}</b><em>${share(r.count)}</em></li>`
|
||||
}
|
||||
const tabs = def.tabs.map(([k, label]) => `<button type="button" class="an-ltab${k === active ? ' on' : ''}" data-rank-tab="${k}" aria-pressed="${k === active}">${label}</button>`).join('')
|
||||
const rows = all.length ? `<ul class="an-lrows">${all.slice(0, expanded ? LONG : SHORT).map(row).join('')}</ul>` : '<p class="an-empty">Noch keine Daten.</p>'
|
||||
const more = all.length > SHORT && !expanded ? '<button type="button" class="an-more" data-rank-more>Alle anzeigen</button>' : ''
|
||||
return `<section class="an-card an-rank" data-rank="${card}" data-rank-active="${active}"><h3>${def.title}</h3><div class="an-ltabs" role="group" aria-label="Ansicht wählen">${tabs}</div>${rows}${more}</section>`
|
||||
}
|
||||
|
||||
/* --- Datenbasis, Heatmap-Karte, Laden, Fehler --- */
|
||||
|
||||
export function basisHtml(stats) {
|
||||
const b = stats?.banner || {}
|
||||
const decided = num(b.accept) + num(b.reject)
|
||||
const rate = decided > 0 ? Math.min(1, Math.max(0, isNum(stats?.totals?.consentRate) ? stats.totals.consentRate : num(b.accept) / decided)) : 0
|
||||
const pct = Math.round(rate * 100)
|
||||
const main = decided > 0
|
||||
? `${fmtNum(pct)} % haben dem Banner zugestimmt (${fmtNum(b.accept)} von ${fmtNum(decided)} Entscheidung${decided === 1 ? '' : 'en'}).`
|
||||
: 'Noch hat niemand über das Banner entschieden.'
|
||||
const full = 2 * Math.PI * 30
|
||||
const ring = `<svg class="an-ring" viewBox="0 0 74 74" aria-hidden="true"><circle class="an-ring-bg" cx="37" cy="37" r="30" fill="none" stroke-width="9"/>${rate > 0 ? `<circle class="an-ring-fg" cx="37" cy="37" r="30" fill="none" stroke-width="9" stroke-linecap="round" stroke-dasharray="${round(rate * full)} ${round(full)}" transform="rotate(-90 37 37)"/>` : ''}<text class="an-ring-n" x="37" y="42" text-anchor="middle">${decided > 0 ? `${fmtNum(pct)} %` : '–'}</text></svg>`
|
||||
return `<section class="an-card an-basis"><h3>Datenbasis</h3><div class="an-basis-row">${ring}<p class="an-basis-text"><span class="an-basis-main">${main}</span> Seitenaufrufe zählen wir vollständig; Besucher, Kontakt-Aktionen und Zeit pro Seite (*) nur bei Zustimmung.</p></div></section>`
|
||||
}
|
||||
|
||||
export function heatTeaserHtml() {
|
||||
return '<section class="an-card an-teaser"><h3>Heatmap</h3><div class="an-teaser-row"><div class="an-mini-heat" aria-hidden="true"></div><p>Sieh auf deiner echten Website, wo geklickt wird – am Computer, Tablet und Handy.</p><button type="button" class="an-btn" data-tab="heatmap">Öffnen</button></div></section>'
|
||||
}
|
||||
|
||||
/** Platzhalter in der Form der echten Kacheln, solange die Auswertung laedt. */
|
||||
export function skeletonHtml() {
|
||||
const card = (cls) => `<div class="an-card ${cls} an-sk" aria-hidden="true"><i></i><i></i><i></i></div>`
|
||||
return `<div class="an-ov an-skeleton" aria-busy="true"><span class="an-sr" role="status">Auswertung wird geladen</span><div class="an-sum an-sk" aria-hidden="true"><i></i><i></i></div><div class="an-kpis">${card('an-kpi').repeat(4)}</div><div class="an-row2">${card('an-chartbox')}${card('an-contacts')}</div><div class="an-pair">${card('an-rank')}${card('an-rank')}</div></div>`
|
||||
}
|
||||
|
||||
export function errorHtml(message) {
|
||||
return `<section class="an-card an-error" role="alert"><p>${esc(message)}</p><button type="button" class="an-btn" id="an-retry">Neu laden</button></section>`
|
||||
}
|
||||
|
||||
/* --- Reiter Einstellungen, Spec 2.2 --- */
|
||||
|
||||
const BERLIN_DATE = new Intl.DateTimeFormat('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
// Links nur auf eigene Pfade oder http(s)
|
||||
const hrefOk = (url) => typeof url === 'string' && /^(\/(?!\/)|https?:\/\/)/i.test(url)
|
||||
|
||||
export function settingsHtml(project) {
|
||||
const a = project?.analyse || {}
|
||||
const color = bannerColor(a.accentColor) // wie auf der Website: zu helle Farbe => #111111
|
||||
const since = a.activatedAt ? new Date(a.activatedAt) : null
|
||||
const status = `Analyse aktiv${since && !Number.isNaN(since.getTime()) ? ` seit ${BERLIN_DATE.format(since)}` : ''}`
|
||||
const rows = [
|
||||
['Status', esc(status)],
|
||||
['Farbe des Banners', `<span class="an-swatch" style="background:${color}" aria-hidden="true"></span> ${color}`],
|
||||
a.avvVersion ? ['Vereinbarung', `Fassung ${esc(a.avvVersion)}`] : null,
|
||||
].filter(Boolean)
|
||||
const links = [[a.legal?.avv, 'Auftragsverarbeitungsvertrag Website-Analyse'], [a.legal?.lb, 'Leistungsbeschreibung Website-Analyse']]
|
||||
.filter(([url]) => hrefOk(url)).map(([url, label]) => `<li><a href="${esc(url)}" target="_blank" rel="noopener">${label}</a></li>`)
|
||||
return `<div class="an-ov an-settings"><section class="an-card an-status-card"><h3>Status</h3><dl class="an-dl">${rows.map(([k, v]) => `<div><dt>${k}</dt><dd>${v}</dd></div>`).join('')}</dl></section>`
|
||||
+ `${links.length ? `<section class="an-card an-docs"><h3>Unterlagen</h3><ul>${links.join('')}</ul></section>` : ''}<section class="an-card" id="an-off"></section></div>`
|
||||
}
|
||||
|
||||
/* --- Zusammenbau der Uebersicht --- */
|
||||
|
||||
export function overviewHtml(stats) {
|
||||
return `<div class="an-ov">${summaryHtml(stats)}${kpisHtml(stats)}<div class="an-row2">${chartHtml(stats)}${contactsHtml(stats)}</div>`
|
||||
+ `<div class="an-pair">${rankCardHtml('seen', stats)}${rankCardHtml('who', stats)}</div><div class="an-pair">${basisHtml(stats)}${heatTeaserHtml()}</div>`
|
||||
+ '<p class="an-credit">Länderdaten: IP-Geolocation by <a href="https://db-ip.com" target="_blank" rel="noopener">DB-IP</a></p></div>'
|
||||
}
|
||||
+268
-7
@@ -1,4 +1,6 @@
|
||||
/* Reine Hilfsfunktionen der Analyse-Ansicht (funktionen.js). Ohne DOM, damit Node-Tests sie direkt importieren koennen. */
|
||||
/* Reine Hilfsfunktionen und gemeinsamer Zustand der Analyse-Ansicht (analyse.js, Kachel in funktionen.js). Ohne DOM, damit Node-Tests sie direkt importieren koennen. */
|
||||
|
||||
import { esc } from './fn-util.js'
|
||||
|
||||
// Gleiche Regel wie analytics/client/core.js (contrastOk): weisser Text auf der Farbe braucht >= 4,5:1
|
||||
export function contrastOk(hex) {
|
||||
@@ -53,22 +55,33 @@ export function shortDay(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 } = {}) {
|
||||
/** Koordinaten fuer das Liniendiagramm. series: [{day, pv}] => Punkte, Achsenbeschriftung, Pfad. Mit compare ([{pv}], Zeitraum davor) gemeinsame Skala und gestrichelte Vergleichslinie (nach Tagesindex ausgerichtet). labelGap: Mindestabstand (Einheiten) zwischen der letzten und der vorletzten Beschriftung der x-Achse. */
|
||||
export function chartModel(series, { width = 640, height = 240, pad = { l: 40, r: 12, t: 12, b: 28 }, maxLabels = 6, labelGap = 36, compare } = {}) {
|
||||
const data = Array.isArray(series) ? series : []
|
||||
const before = Array.isArray(compare) ? compare : []
|
||||
const prevOf = (i) => {
|
||||
const v = before[i]?.pv
|
||||
return v == null || !Number.isFinite(Number(v)) ? null : Number(v)
|
||||
}
|
||||
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 top = niceMax(Math.max(0, ...data.map((d) => Number(d.pv) || 0), ...data.map((_, i) => prevOf(i) ?? 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 points = data.map((d, i) => ({ x: round(x(i)), y: round(y(Number(d.pv) || 0)), day: d.day, pv: Number(d.pv) || 0, prev: prevOf(i) }))
|
||||
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 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 > labelGap).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 }
|
||||
// Vergleichslinie: Luecken (fehlende Tage) beginnen einen neuen Abschnitt
|
||||
const prevLine = points.reduce((path, p, i) => {
|
||||
if (p.prev === null) return path
|
||||
const move = i > 0 && points[i - 1].prev !== null ? 'L' : 'M'
|
||||
return `${path}${path ? ' ' : ''}${move}${p.x} ${round(y(p.prev))}`
|
||||
}, '')
|
||||
return { width, height, pad, top, points, xLabels, yTicks, line, area, prevLine }
|
||||
}
|
||||
|
||||
function round(n) {
|
||||
@@ -90,3 +103,251 @@ export function controllerLines(controller) {
|
||||
const c = controller || {}
|
||||
return [c.company, c.address, c.email].map((v) => String(v || '').trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
/* Analyse-Zustand je Website (comingSoon = Schalter aus) und Auswahl */
|
||||
export const analyseOf = (project) => project.analyse || { comingSoon: true }
|
||||
export const analyseState = (a) => (a.comingSoon ? 'soon' : a.state === 'unknown' ? 'unknown' : a.state === 'disabled' ? 'disabled' : a.state === 'active' ? 'active' : a.eligible ? 'off' : 'blocked')
|
||||
|
||||
export function pickAnalyseProject(projects) {
|
||||
return projects.find((p) => analyseState(analyseOf(p)) === 'active') || projects.find((p) => analyseState(analyseOf(p)) === 'off') || projects[0]
|
||||
}
|
||||
|
||||
// Gemeinsam fuer die Kachel (funktionen.js) und die Analyse-Ansicht (analyse.js)
|
||||
export const analyseSel = { projectId: '', range: 30, tab: 'uebersicht' }
|
||||
|
||||
/* --- Vergleich zum Vorzeitraum und Klartext ("Das Wichtigste"), Spec 2.3 und 2.4 --- */
|
||||
|
||||
const MONTHS = ['Jan.', 'Feb.', 'März', 'Apr.', 'Mai', 'Juni', 'Juli', 'Aug.', 'Sep.', 'Okt.', 'Nov.', 'Dez.']
|
||||
const WEEKDAYS = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'] // wie Date#getUTCDay()
|
||||
const WEEKDAYS_SHORT = ['So.', 'Mo.', 'Di.', 'Mi.', 'Do.', 'Fr.', 'Sa.']
|
||||
const AGAINST = 'gegenüber dem Zeitraum davor'
|
||||
|
||||
const NUMBER_DE = new Intl.NumberFormat('de-DE', { maximumFractionDigits: 0 })
|
||||
const isNum = (v) => typeof v === 'number' && Number.isFinite(v)
|
||||
|
||||
/** Ganzzahl auf Deutsch: 6930 => "6.930". */
|
||||
export const fmtNum = (n) => NUMBER_DE.format(Number(n) || 0)
|
||||
|
||||
/** Sekunden als m:ss: 108 => "1:48", 7 => "0:07"; null oder ungueltig => "–". */
|
||||
export function fmtDuration(sec) {
|
||||
if (!isNum(sec) || sec < 0) return '–'
|
||||
const s = Math.round(sec)
|
||||
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// "YYYY-MM-DD" => { y, m, d } oder null; Wochentage und Tagesrechnung laufen nur ueber Date.UTC (keine Ortszeit)
|
||||
function parseDay(day) {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(day || '')
|
||||
if (!m) return null
|
||||
const [y, mo, d] = [+m[1], +m[2], +m[3]]
|
||||
const t = new Date(Date.UTC(y, mo - 1, d))
|
||||
return t.getUTCMonth() === mo - 1 && t.getUTCDate() === d ? { y, m: mo, d } : null
|
||||
}
|
||||
const weekdayOf = (day) => {
|
||||
const p = parseDay(day)
|
||||
return p ? new Date(Date.UTC(p.y, p.m - 1, p.d)).getUTCDay() : -1
|
||||
}
|
||||
function addDays(day, n) {
|
||||
const p = parseDay(day)
|
||||
return p ? new Date(Date.UTC(p.y, p.m - 1, p.d + n)).toISOString().slice(0, 10) : ''
|
||||
}
|
||||
|
||||
/** "2026-09-19" => "Sa., 19.09." */
|
||||
export function dayLabel(day) {
|
||||
const w = weekdayOf(day)
|
||||
return w < 0 ? String(day || '') : `${WEEKDAYS_SHORT[w]}, ${shortDay(day)}`
|
||||
}
|
||||
|
||||
/** ("2026-09-05", "2026-10-04") => "5. Sep. – 4. Okt."; leere oder ungueltige Eingabe => "". */
|
||||
export function periodLabel(from, to) {
|
||||
const a = parseDay(from)
|
||||
const b = parseDay(to)
|
||||
return a && b ? `${a.d}. ${MONTHS[a.m - 1]} – ${b.d}. ${MONTHS[b.m - 1]}` : ''
|
||||
}
|
||||
|
||||
/** Vergleich moeglich, wenn der Vorzeitraum vollstaendig ist und mindestens 20 Aufrufe hat. Sonst from = "TT.MM." ab dem der Vergleich geht (nur bei Vorzeitraum, der noch nicht voll ist).
|
||||
Der Zeitraum endet gestern: der Vorzeitraum beginnt heute - 2 x range, also ist er ab firstDay + 2 x range voll. */
|
||||
export function compareFrom(stats) {
|
||||
const prev = stats?.previous
|
||||
if (prev?.complete && Number(prev.totals?.pv) >= 20) return { ok: true, from: '' }
|
||||
const range = Number(stats?.range)
|
||||
if (prev && !prev.complete && stats.firstDay && range > 0) {
|
||||
const from = addDays(stats.firstDay, 2 * range)
|
||||
return { ok: false, from: from ? shortDay(from) : '' }
|
||||
}
|
||||
return { ok: false, from: '' }
|
||||
}
|
||||
|
||||
// Sekunden in Worten fuer Screenreader: 65 => "1 Minute 5 Sekunden"
|
||||
function secWords(sec) {
|
||||
const m = Math.floor(sec / 60)
|
||||
const s = sec % 60
|
||||
return [m ? `${m} ${m === 1 ? 'Minute' : 'Minuten'}` : '', s || !m ? `${s} ${s === 1 ? 'Sekunde' : 'Sekunden'}` : ''].filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
/** Veraenderung gegenueber dem Vorwert fuer den Chip: { dir, text, aria } oder null, wenn ein Wert keine Zahl ist. kind 'count': Prozent ab 100/100, sonst absolut; kind 'sec': absolut in Sekunden. */
|
||||
export function delta(cur, prev, { kind = 'count' } = {}) {
|
||||
if (!isNum(cur) || !isNum(prev)) return null
|
||||
const flat = (text) => ({ dir: 'flat', text, aria: `unverändert ${AGAINST}` })
|
||||
const diff = cur - prev
|
||||
if (diff === 0) return flat('± 0')
|
||||
const dir = diff > 0 ? 'up' : 'down'
|
||||
const arrow = diff > 0 ? '▲' : '▼'
|
||||
const sign = diff > 0 ? 'plus' : 'minus'
|
||||
if (kind === 'sec') {
|
||||
const n = Math.round(Math.abs(diff))
|
||||
if (n === 0) return flat('± 0')
|
||||
return { dir, text: `${arrow} ${n < 60 ? `${n} s` : fmtDuration(n)}`, aria: `${sign} ${secWords(n)} ${AGAINST}` }
|
||||
}
|
||||
if (cur >= 100 && prev >= 100) {
|
||||
const pct = (Math.abs(diff) / prev) * 100
|
||||
if (pct < 1) return flat('± 0 %')
|
||||
const n = Math.round(pct)
|
||||
return { dir, text: `${arrow} ${n} %`, aria: `${sign} ${n} Prozent ${AGAINST}` }
|
||||
}
|
||||
const n = fmtNum(Math.abs(diff))
|
||||
return { dir, text: `${arrow} ${n}`, aria: `${sign} ${n} ${AGAINST}` }
|
||||
}
|
||||
|
||||
/** Kontakt-Aktionen pro 100 Besucher, eine Nachkommastelle; null unter 30 Besuchern (zu unsicher). */
|
||||
export function contactRate(contacts, visitors) {
|
||||
if (!isNum(contacts) || !isNum(visitors) || visitors < 30) return null
|
||||
return Math.round((contacts / visitors) * 1000) / 10
|
||||
}
|
||||
|
||||
// Bekannte Herkunfts-Hosts (Teilstring am Label-Rand: "l.facebook.com" ja, "notgoogle.com" nein)
|
||||
const REF_NAMES = [
|
||||
[/(^|\.)google\.(com?\.)?[a-z]{2,3}$/, 'Google'],
|
||||
[/(^|\.)bing\.com$/, 'Bing'],
|
||||
[/(^|\.)instagram\.com$/, 'Instagram'],
|
||||
[/(^|\.)facebook\.com$/, 'Facebook'],
|
||||
[/(^|\.)duckduckgo\.com$/, 'DuckDuckGo'],
|
||||
[/(^|\.)ecosia\.org$/, 'Ecosia'],
|
||||
]
|
||||
|
||||
/** Lesbarer Name einer Herkunft; unbekannter Host bleibt unveraendert (beim Einsetzen in HTML maskieren). */
|
||||
export function refName(host) {
|
||||
const h = String(host ?? '').trim()
|
||||
if (!h) return 'Direkt oder unbekannt'
|
||||
const hit = REF_NAMES.find(([re]) => re.test(h.toLowerCase()))
|
||||
return hit ? hit[1] : h
|
||||
}
|
||||
|
||||
/** Staerkster Wochentag und Spitzentag ueber die (abgeschlossenen) Tage der Reihe ab firstDay ('' = alle). null bei weniger als 7 Tagen. */
|
||||
export function weekdayStats(series, firstDay) {
|
||||
const rows = (Array.isArray(series) ? series : [])
|
||||
.filter((d) => parseDay(d?.day) && (!firstDay || d.day >= firstDay))
|
||||
.map((d) => ({ day: d.day, pv: Number(d.pv) || 0 }))
|
||||
if (rows.length < 7) return null
|
||||
const avg = rows.reduce((a, r) => a + r.pv, 0) / rows.length
|
||||
const perWeekday = Array.from({ length: 7 }, () => ({ sum: 0, n: 0 }))
|
||||
for (const r of rows) {
|
||||
const w = perWeekday[weekdayOf(r.day)]
|
||||
w.sum += r.pv
|
||||
w.n += 1
|
||||
}
|
||||
let best = null
|
||||
perWeekday.forEach((w, weekday) => {
|
||||
if (w.n && (!best || w.sum / w.n > best.avg)) best = { weekday, name: WEEKDAYS[weekday], avg: w.sum / w.n }
|
||||
})
|
||||
if (!best || !(avg > 0) || best.avg < 1.2 * avg) best = null
|
||||
const max = rows.reduce((m, r) => (r.pv > m.pv ? r : m), rows[0])
|
||||
const peak = avg > 0 && max.pv >= 1.8 * avg ? { day: max.day, pv: max.pv } : null
|
||||
return { best, avg, peak }
|
||||
}
|
||||
|
||||
// Direkte Kontaktwege; Klicks auf Links nach außen ('out') sind kein Kontakt zu dir und zählen im Satz nicht mit
|
||||
const CONTACT_WAYS = { tel: 'per Telefon', mail: 'per E-Mail', wa: 'per WhatsApp' }
|
||||
const DEVICE_WORDS = { m: 'vom Handy', d: 'vom Computer' }
|
||||
|
||||
// Satz ueber dem Hinweis-Block; nur hier steht fett (<b>), alle Zahlen sind fmtNum
|
||||
function leadOf(stats, cmp) {
|
||||
const days = Number(stats.range) || (Array.isArray(stats.series) ? stats.series.length : 0)
|
||||
const pv = Number(stats.totals.pv)
|
||||
const count = `<b>${fmtNum(pv)}-mal</b>`
|
||||
if (!cmp.ok) {
|
||||
return stats.firstDay && stats.from && stats.firstDay >= stats.from
|
||||
? `Seit dem Start der Analyse am ${esc(shortDay(stats.firstDay))} wurde deine Website ${count} aufgerufen.`
|
||||
: `Deine Website wurde in den letzten ${days} Tagen ${count} aufgerufen.`
|
||||
}
|
||||
const before = Number(stats.previous.totals.pv)
|
||||
const diff = pv - before
|
||||
const start = `Deine Website wurde in den letzten ${days} Tagen ${count} aufgerufen – `
|
||||
if (Math.abs(diff) / before < 0.05) return `${start}etwa so oft wie in den ${days} Tagen davor.`
|
||||
const more = diff > 0
|
||||
const size = pv >= 100 && before >= 100
|
||||
? `${fmtNum(Math.round((Math.abs(diff) / before) * 100))} %`
|
||||
: `${fmtNum(Math.abs(diff))} ${Math.abs(diff) === 1 ? 'Aufruf' : 'Aufrufe'}`
|
||||
return `${start}<b>${size} ${more ? 'mehr' : 'weniger'}</b> als in den ${days} Tagen davor.`
|
||||
}
|
||||
|
||||
function contactItem(stats, cmp) {
|
||||
if (!Array.isArray(stats.contacts)) return null // Collector ohne Kontaktliste: nichts behaupten
|
||||
const direct = (rows) => (Array.isArray(rows) ? rows : []).filter((c) => Object.hasOwn(CONTACT_WAYS, c?.key) && c.count > 0)
|
||||
const sum = (rows) => rows.reduce((a, c) => a + c.count, 0)
|
||||
const cur = direct(stats.contacts)
|
||||
const total = sum(cur)
|
||||
if (total < 1) {
|
||||
return Number(stats.totals.visitors) >= 30
|
||||
? 'Von den Besuchern mit Zustimmung hat noch niemand über die Website angerufen oder geschrieben.'
|
||||
: null
|
||||
}
|
||||
const main = cur.reduce((m, c) => (!m || c.count > m.count ? c : m), null)
|
||||
let text = `${fmtNum(total)}-mal hat dich jemand direkt kontaktiert, meist ${CONTACT_WAYS[main.key]} (${fmtNum(main.count)}).`
|
||||
const before = cmp.ok ? sum(direct(stats.previous.contacts)) : 0
|
||||
if (before >= 1) {
|
||||
const diff = total - before
|
||||
text += diff === 0 ? ' Genauso oft wie zuvor.' : ` Das sind ${fmtNum(Math.abs(diff))} ${diff > 0 ? 'mehr' : 'weniger'} als zuvor.`
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
function dayItem(stats) {
|
||||
if (!(Number(stats.totals.pv) >= 70)) return null
|
||||
const w = weekdayStats(stats.series, stats.firstDay || '')
|
||||
if (!w) return null
|
||||
const parts = []
|
||||
if (w.best) parts.push(`${w.best.name} ist im Schnitt dein stärkster Tag.`)
|
||||
if (w.peak) parts.push(`Spitzentag war ${dayLabel(w.peak.day)} mit ${fmtNum(w.peak.pv)} Aufrufen.`)
|
||||
return parts.length ? parts.join(' ') : null
|
||||
}
|
||||
|
||||
function sourceItem(stats) {
|
||||
const refs = Array.isArray(stats.refs) ? stats.refs : []
|
||||
const sum = refs.reduce((a, r) => a + (Number(r.count) || 0), 0)
|
||||
const first = refs.find((r) => r.key)
|
||||
if (!first || !(sum > 0) || first.count / sum < 0.2) return null
|
||||
return `Die meisten, die über einen Link kamen, kommen von ${esc(refName(first.key))} (${fmtNum(first.count)}).`
|
||||
}
|
||||
|
||||
function deviceItem(stats) {
|
||||
const devices = Array.isArray(stats.devices) ? stats.devices : []
|
||||
const sum = devices.reduce((a, d) => a + (Number(d.count) || 0), 0)
|
||||
const main = devices.find((d) => DEVICE_WORDS[d.key] && sum > 0 && d.count / sum >= 0.55)
|
||||
return main ? `${fmtNum(Math.round((main.count / sum) * 100))} % der Aufrufe kommen ${DEVICE_WORDS[main.key]}.` : null
|
||||
}
|
||||
|
||||
function pageItem(stats) {
|
||||
const [first, second] = Array.isArray(stats.pages) ? stats.pages : []
|
||||
if (!first?.key) return null
|
||||
if (first.key === '/') {
|
||||
return second?.key
|
||||
? `Am meisten angesehen: die Startseite, danach ${esc(second.key)} (${fmtNum(second.count)}).`
|
||||
: `Am meisten angesehen: die Startseite (${fmtNum(first.count)} Aufrufe).`
|
||||
}
|
||||
return `Am meisten angesehen: ${esc(first.key)} (${fmtNum(first.count)} Aufrufe).`
|
||||
}
|
||||
|
||||
/** Klartext fuer "Das Wichtigste": { lead, items: [{ icon, html }] } als maskiertes HTML; null unter 30 Seitenaufrufen. Feste Regeln, keine KI. */
|
||||
export function summarize(stats) {
|
||||
if (!stats?.totals || !(Number(stats.totals.pv) >= 30)) return null
|
||||
const cmp = compareFrom(stats)
|
||||
const items = [
|
||||
['contact', contactItem(stats, cmp)],
|
||||
['day', dayItem(stats)],
|
||||
['source', sourceItem(stats)],
|
||||
['device', deviceItem(stats)],
|
||||
['page', pageItem(stats)],
|
||||
].filter(([, html]) => html).slice(0, 4).map(([icon, html]) => ({ icon, html }))
|
||||
return { lead: leadOf(stats, cmp), items }
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
/* Analyse "Cockpit" (analyse.js, analyse-cards.js): alles unter dem Wurzelelement .an mit eigenen Variablen und eigenem tuerkisen Akzent;
|
||||
ausserhalb von #view-analyse aendert sich nichts. Klassen und Zustaende: Vertrag im Kopf von analyse-cards.js. */
|
||||
|
||||
.an {
|
||||
--an-fg: #fff;
|
||||
--an-fg2: rgba(255, 255, 255, 0.78);
|
||||
--an-muted: rgba(255, 255, 255, 0.58);
|
||||
--an-faint: rgba(255, 255, 255, 0.55); /* >= 4,5:1 auf der Karte (gemessen ca. 6,2:1) */
|
||||
--an-line: rgba(255, 255, 255, 0.09);
|
||||
--an-line2: rgba(255, 255, 255, 0.18);
|
||||
--an-card: rgba(14, 16, 20, 0.92);
|
||||
--an-acc: #2dd4bf;
|
||||
--an-acc-strong: #5eead4;
|
||||
--an-acc-soft: rgba(45, 212, 191, 0.1);
|
||||
--an-on-acc: #04201c;
|
||||
--an-up-bg: rgba(45, 212, 191, 0.14);
|
||||
--an-down: #fda4af;
|
||||
--an-down-bg: rgba(244, 63, 94, 0.14);
|
||||
--an-prev: rgba(255, 255, 255, 0.35);
|
||||
--an-warn: #fcd34d;
|
||||
--an-bar-fill: linear-gradient(90deg, rgba(45, 212, 191, 0.32), rgba(45, 212, 191, 0.12));
|
||||
--an-r: 16px;
|
||||
--an-gap: 14px;
|
||||
min-width: 0;
|
||||
color: var(--an-fg);
|
||||
color-scheme: dark;
|
||||
}
|
||||
.an, .an *, .an *::before, .an *::after { box-sizing: border-box; }
|
||||
.an a, .an .kb-link { color: var(--an-acc-strong); }
|
||||
.an :is(button, select, input, a):focus-visible { outline: 2px solid var(--an-acc-strong); outline-offset: 2px; }
|
||||
|
||||
/* ------------------------------------------------------------ Kopfleiste --- */
|
||||
.an-top { display: flex; align-items: center; justify-content: space-between; gap: 12px 16px; flex-wrap: wrap; padding: 14px 16px; border-radius: var(--an-r); background: var(--an-card); border: 1px solid var(--an-line); }
|
||||
.an-site { --an-dot: #2dd4bf; --an-dot-soft: rgba(45, 212, 191, 0.18); display: flex; align-items: center; gap: 12px; flex: 1 1 260px; min-width: 0; }
|
||||
.an-site[data-state="warn"] { --an-dot: #fcd34d; --an-dot-soft: rgba(252, 211, 77, 0.18); }
|
||||
.an-site[data-state="off"] { --an-dot: rgba(255, 255, 255, 0.4); --an-dot-soft: rgba(255, 255, 255, 0.1); }
|
||||
.an-site > div { min-width: 0; }
|
||||
.an-status-dot { flex: none; width: 9px; height: 9px; border-radius: 50%; background: var(--an-dot); box-shadow: 0 0 0 4px var(--an-dot-soft); }
|
||||
.an-site-name { display: block; font-size: 16px; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.an-status { display: block; margin-top: 2px; font-size: 12px; color: var(--an-muted); }
|
||||
.an-st { white-space: nowrap; }
|
||||
.an-pick {
|
||||
display: block; max-width: 100%; padding: 2px 24px 2px 0; border: 0; border-radius: 6px; cursor: pointer; text-overflow: ellipsis;
|
||||
-webkit-appearance: none; appearance: none; color: var(--an-fg); font-family: inherit; font-size: 16px; font-weight: 700; line-height: 1.3;
|
||||
background: transparent url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'><path d='M2 4.5l4 4 4-4' fill='none' stroke='white' stroke-opacity='.7' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/></svg>") no-repeat right 2px center / 12px;
|
||||
}
|
||||
.an-pick option { background: #14161b; color: #fff; }
|
||||
|
||||
.an-seg { display: inline-flex; flex: none; gap: 2px; padding: 3px; border-radius: 11px; background: rgba(255, 255, 255, 0.04); border: 1px solid var(--an-line); }
|
||||
.an-seg button { padding: 7px 13px; border: 0; border-radius: 8px; background: transparent; color: var(--an-muted); font: inherit; font-size: 13px; font-weight: 600; cursor: pointer; transition: background-color 0.15s, color 0.15s; }
|
||||
.an-seg button:hover { color: var(--an-fg); }
|
||||
.an-seg button.on { background: var(--an-acc); color: var(--an-on-acc); }
|
||||
|
||||
/* ----------------------------------------------------------------- Reiter --- */
|
||||
.an-tabs { display: flex; gap: 22px; margin: 18px 4px; border-bottom: 1px solid var(--an-line); overflow-x: auto; scrollbar-width: none; }
|
||||
.an-tabs::-webkit-scrollbar { display: none; }
|
||||
.an-tabbtn { flex: none; padding: 0 0 12px; border: 0; background: transparent; color: var(--an-muted); font: inherit; font-size: 14px; font-weight: 600; white-space: nowrap; cursor: pointer; transition: color 0.15s; }
|
||||
.an-tabbtn:hover { color: var(--an-fg); }
|
||||
.an-tabbtn.on { color: var(--an-fg); box-shadow: inset 0 -2px 0 var(--an-acc); }
|
||||
.an .an-tabbtn:focus-visible { outline-offset: -2px; border-radius: 4px; }
|
||||
.an-top + #an-stage { margin-top: var(--an-gap); }
|
||||
|
||||
/* ------------------------------------------------------- Karten und Raster --- */
|
||||
.an-ov { display: grid; gap: var(--an-gap); min-width: 0; }
|
||||
.an-ov > * { min-width: 0; }
|
||||
.an-card { min-width: 0; padding: 20px 22px; border-radius: var(--an-r); background: var(--an-card); border: 1px solid var(--an-line); }
|
||||
.an-card h3 { margin: 0; font-size: 13px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: var(--an-muted); }
|
||||
.an-card > h3 { margin-bottom: 14px; }
|
||||
.an-head { display: flex; align-items: center; justify-content: space-between; gap: 8px 16px; flex-wrap: wrap; margin-bottom: 14px; }
|
||||
.an-empty { margin: 0; color: var(--an-muted); font-size: 14px; line-height: 1.5; }
|
||||
.an-credit { margin: 2px 4px 0; font-size: 11.5px; line-height: 1.5; color: var(--an-faint); }
|
||||
.an .an-credit a { color: inherit; }
|
||||
|
||||
/* Das Wichtigste */
|
||||
.an-sum { padding: 22px 24px; border-radius: var(--an-r); background: linear-gradient(135deg, rgba(45, 212, 191, 0.13), rgba(139, 92, 246, 0.1) 60%, rgba(14, 16, 20, 0.9)); border: 1px solid rgba(94, 234, 212, 0.35); }
|
||||
.an-eyebrow { margin: 0 0 10px; font-size: 12px; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; color: var(--an-acc); }
|
||||
.an-lead { margin: 0 0 14px; font-size: 21px; line-height: 1.4; font-weight: 600; }
|
||||
.an-sum-empty .an-lead { margin: 0; font-size: 17px; font-weight: 500; color: var(--an-fg2); }
|
||||
.an-lead b { color: var(--an-acc-strong); white-space: nowrap; }
|
||||
.an-points { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px 22px; margin: 0; padding: 0; list-style: none; }
|
||||
.an-points li { display: flex; align-items: flex-start; gap: 10px; color: var(--an-fg2); line-height: 1.45; }
|
||||
.an-points li > span:last-child { min-width: 0; overflow-wrap: anywhere; }
|
||||
.an-points b { color: var(--an-fg); }
|
||||
.an-point-ico { flex: none; display: grid; place-items: center; width: 26px; height: 26px; border-radius: 8px; background: var(--an-acc-soft); color: var(--an-acc-strong); }
|
||||
.an-ico { width: 15px; height: 15px; }
|
||||
|
||||
/* Kennzahlen */
|
||||
.an-kpis { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--an-gap); }
|
||||
.an-kpi { display: flex; flex-direction: column; padding: 16px 18px 12px; }
|
||||
.an-k { display: flex; align-items: center; gap: 4px; font-size: 13px; font-weight: 500; color: var(--an-muted); }
|
||||
.an-star { font-size: 11px; color: var(--an-faint); }
|
||||
.an-v { display: block; margin: 6px 0 4px; font-size: 30px; font-weight: 700; line-height: 1.15; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; }
|
||||
.an-sub { display: block; margin: -2px 0 4px; font-size: 12px; color: var(--an-muted); }
|
||||
.an-cmp { display: flex; align-items: center; flex-wrap: wrap; gap: 4px 6px; min-height: 24px; margin-bottom: 10px; }
|
||||
.an-vs { font-size: 12px; color: var(--an-faint); }
|
||||
.an-delta { display: inline-flex; align-items: center; gap: 4px; padding: 3px 8px; border-radius: 999px; font-size: 12px; font-weight: 700; white-space: nowrap; }
|
||||
.an-delta.up { background: var(--an-up-bg); color: var(--an-acc-strong); }
|
||||
.an-delta.down { background: var(--an-down-bg); color: var(--an-down); }
|
||||
.an-delta.flat { background: var(--an-acc-soft); color: var(--an-muted); }
|
||||
.an-spark { display: block; width: 100%; height: 38px; margin-top: auto; }
|
||||
.an-spark-area { fill: var(--an-acc); opacity: 0.14; stroke: none; }
|
||||
.an-spark-line { stroke: var(--an-acc); stroke-width: 1.6px; stroke-linejoin: round; stroke-linecap: round; }
|
||||
|
||||
/* Diagramm und Kontakt-Aktionen: gleich hoch, das Diagramm fuellt die Karte */
|
||||
.an-row2 { display: grid; grid-template-columns: minmax(0, 1.65fr) minmax(0, 1fr); gap: var(--an-gap); align-items: stretch; }
|
||||
.an-chartbox, .an-contacts { display: flex; flex-direction: column; }
|
||||
.an-legend { display: flex; flex-wrap: wrap; gap: 4px 16px; font-size: 12px; font-weight: 500; color: var(--an-muted); }
|
||||
.an-leg { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
|
||||
.an-leg-line { display: inline-block; width: 18px; height: 0; border-top: 3px solid var(--an-acc); border-radius: 2px; }
|
||||
.an-leg-prev { border-top: 2px dashed var(--an-prev); }
|
||||
.an-chartwrap { position: relative; flex: 1 1 auto; display: flex; align-items: center; min-height: 0; container-type: inline-size; }
|
||||
.an-chart { display: block; width: 100%; height: auto; max-height: 100%; overflow: visible; touch-action: pan-y; }
|
||||
.an-grid { stroke: var(--an-line); stroke-width: 1px; vector-effect: non-scaling-stroke; }
|
||||
/* Achsenschrift in viewBox-Einheiten (viewBox 640 breit, die Zeichnung skaliert mit der Kartenbreite). Je nach Breite der Zeichnung
|
||||
so gewaehlt, dass sie immer ca. 11-12 px gross erscheint (gemessen: 320 px Handy bis 1920 px Desktop); Grundwert fuer Browser ohne Container-Abfragen. */
|
||||
.an-axis { fill: var(--an-muted); font-size: 13px; }
|
||||
@container (max-width: 279px) { .an-axis { font-size: 28px; } }
|
||||
@container (min-width: 280px) and (max-width: 329px) { .an-axis { font-size: 24px; } }
|
||||
@container (min-width: 330px) and (max-width: 379px) { .an-axis { font-size: 21px; } }
|
||||
@container (min-width: 380px) and (max-width: 439px) { .an-axis { font-size: 18px; } }
|
||||
@container (min-width: 440px) and (max-width: 499px) { .an-axis { font-size: 16px; } }
|
||||
@container (min-width: 500px) and (max-width: 559px) { .an-axis { font-size: 14px; } }
|
||||
@container (min-width: 560px) and (max-width: 629px) { .an-axis { font-size: 12.5px; } }
|
||||
@container (min-width: 630px) and (max-width: 699px) { .an-axis { font-size: 11px; } }
|
||||
@container (min-width: 700px) { .an-axis { font-size: 10px; } }
|
||||
.an-area { fill: var(--an-acc); opacity: 0.12; stroke: none; }
|
||||
.an-prev { fill: none; stroke: var(--an-prev); stroke-width: 2px; stroke-dasharray: 5 5; stroke-linejoin: round; vector-effect: non-scaling-stroke; }
|
||||
.an-line { fill: none; stroke: var(--an-acc); stroke-width: 2.4px; stroke-linejoin: round; stroke-linecap: round; vector-effect: non-scaling-stroke; }
|
||||
/* Punkte erst beim Zeigen oder Antippen (analyse.js setzt "on"); der Rand ist ein weicher Ring */
|
||||
.an-dot { fill: var(--an-acc-strong); stroke: rgba(45, 212, 191, 0.3); stroke-width: 9px; vector-effect: non-scaling-stroke; opacity: 0; pointer-events: none; }
|
||||
.an-dot.on { opacity: 1; }
|
||||
.an-tip { position: absolute; z-index: 3; padding: 6px 10px; border-radius: 8px; background: #0b0d10; border: 1px solid var(--an-line2); box-shadow: 0 6px 18px rgba(0, 0, 0, 0.5); color: var(--an-fg); font-size: 12.5px; font-weight: 600; white-space: nowrap; pointer-events: none; transform: translate(-50%, calc(-100% - 12px)); }
|
||||
.an-tip.below { transform: translate(-50%, 12px); }
|
||||
.an-tip[hidden] { display: none; }
|
||||
|
||||
.an-sum-n { font-size: 15px; font-weight: 700; color: var(--an-fg2); font-variant-numeric: tabular-nums; }
|
||||
.an-crows { margin: 0 0 12px; padding: 0; list-style: none; }
|
||||
.an-crow { display: grid; grid-template-columns: 34px minmax(0, 1fr) auto auto; align-items: center; gap: 10px; padding: 8px 0; border-top: 1px solid var(--an-line); }
|
||||
.an-cico { display: grid; place-items: center; width: 34px; height: 34px; border-radius: 10px; background: var(--an-acc-soft); color: var(--an-acc-strong); }
|
||||
.an-cico .an-ico { width: 17px; height: 17px; }
|
||||
.an-cname { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.an-crow b { font-size: 16px; font-variant-numeric: tabular-nums; text-align: right; }
|
||||
.an-quote { margin: auto 0 0; padding: 10px 12px; border-radius: 10px; background: var(--an-acc-soft); color: var(--an-fg2); font-size: 13px; line-height: 1.45; }
|
||||
|
||||
/* Ranglisten */
|
||||
.an-pair { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--an-gap); }
|
||||
.an-ltabs { display: flex; flex-wrap: wrap; gap: 6px; margin: -4px 0 12px; }
|
||||
.an-ltab { padding: 6px 11px; border: 1px solid var(--an-line); border-radius: 8px; background: transparent; color: var(--an-muted); font: inherit; font-size: 12.5px; font-weight: 600; cursor: pointer; transition: background-color 0.15s, color 0.15s; }
|
||||
.an-ltab:hover { color: var(--an-fg); }
|
||||
.an-ltab.on { background: var(--an-acc-soft); border-color: var(--an-line2); color: var(--an-fg); }
|
||||
.an-lrows { margin: 0; padding: 0; list-style: none; }
|
||||
.an-lr { display: grid; grid-template-columns: minmax(0, 1fr) auto 52px; align-items: center; gap: 12px; padding: 7px 0; }
|
||||
.an-lab { position: relative; display: block; min-width: 0; padding: 5px 8px; border-radius: 7px; overflow: hidden; }
|
||||
.an-fill { position: absolute; inset: 0 auto 0 0; border-radius: 7px; background: var(--an-bar-fill); }
|
||||
.an-name { position: relative; display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.an-lr b { font-size: 14px; font-variant-numeric: tabular-nums; }
|
||||
.an-lr em { font-size: 12px; font-style: normal; text-align: right; color: var(--an-muted); font-variant-numeric: tabular-nums; }
|
||||
.an-more { margin-top: 8px; padding: 7px 12px; border: 1px solid var(--an-line); border-radius: 8px; background: transparent; color: var(--an-fg2); font: inherit; font-size: 12.5px; font-weight: 600; cursor: pointer; }
|
||||
.an-more:hover { color: var(--an-fg); border-color: var(--an-line2); }
|
||||
|
||||
/* Datenbasis und Heatmap-Karte */
|
||||
.an-basis-row { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 18px; }
|
||||
.an-ring { width: 74px; height: 74px; }
|
||||
.an-ring-bg { stroke: rgba(255, 255, 255, 0.1); }
|
||||
.an-ring-fg { stroke: var(--an-acc); }
|
||||
.an-ring-n { fill: var(--an-fg); font-size: 15px; font-weight: 700; }
|
||||
.an-basis-text { margin: 0; font-size: 13.5px; line-height: 1.5; color: var(--an-fg2); }
|
||||
.an-basis-main { display: block; margin-bottom: 2px; font-weight: 600; color: var(--an-fg); }
|
||||
.an-teaser-row { display: flex; align-items: center; flex-wrap: wrap; gap: 14px 18px; }
|
||||
.an-teaser-row p { flex: 1 1 170px; min-width: 0; margin: 0; line-height: 1.5; color: var(--an-fg2); }
|
||||
.an-mini-heat {
|
||||
flex: none; width: 110px; height: 74px; border-radius: 10px; border: 1px solid var(--an-line);
|
||||
background:
|
||||
radial-gradient(circle at 22% 40%, rgba(255, 60, 40, 0.9), rgba(255, 200, 0, 0.5) 12%, transparent 24%),
|
||||
radial-gradient(circle at 70% 62%, rgba(255, 60, 40, 0.85), rgba(255, 200, 0, 0.5) 10%, transparent 22%),
|
||||
radial-gradient(circle at 48% 25%, rgba(80, 220, 120, 0.7), transparent 14%), #1b1d22;
|
||||
}
|
||||
.an-btn { flex: none; padding: 10px 16px; border: 0; border-radius: 10px; background: var(--an-acc); color: var(--an-on-acc); font: inherit; font-size: 13px; font-weight: 700; white-space: nowrap; cursor: pointer; transition: background-color 0.15s; }
|
||||
.an-btn:hover { background: var(--an-acc-strong); }
|
||||
|
||||
/* Laden und Fehler */
|
||||
.an-sr { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
|
||||
.an-sk { position: relative; overflow: hidden; }
|
||||
.an-sk > i { display: block; height: 14px; margin-top: 12px; border-radius: 7px; background: rgba(255, 255, 255, 0.07); }
|
||||
.an-sk > i:first-child { width: 38%; margin-top: 0; }
|
||||
.an-sk > i:nth-child(2) { width: 62%; height: 28px; }
|
||||
.an-sk > i:nth-child(3) { width: 85%; }
|
||||
.an-sk::after { content: ''; position: absolute; inset: 0; background: linear-gradient(100deg, transparent 30%, rgba(255, 255, 255, 0.05) 50%, transparent 70%) 150% 0 / 200% 100%; animation: an-shimmer 1.4s linear infinite; }
|
||||
@keyframes an-shimmer { to { background-position: -50% 0; } }
|
||||
.an-skeleton .an-sum { min-height: 150px; }
|
||||
.an-skeleton .an-kpi { min-height: 128px; }
|
||||
.an-skeleton .an-chartbox, .an-skeleton .an-contacts { min-height: 300px; }
|
||||
.an-skeleton .an-rank { min-height: 230px; }
|
||||
.an-error { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 14px 16px; }
|
||||
.an-error p { margin: 0; }
|
||||
|
||||
/* ---------------------------------------------------------- Einstellungen --- */
|
||||
.an-dl { margin: 0; }
|
||||
.an-dl > div { display: flex; justify-content: space-between; align-items: baseline; gap: 16px; padding: 10px 0; border-top: 1px solid var(--an-line); }
|
||||
.an-dl dt { color: var(--an-muted); }
|
||||
.an-dl dd { margin: 0; text-align: right; overflow-wrap: anywhere; }
|
||||
.an-swatch { display: inline-block; width: 14px; height: 14px; margin-right: 4px; border-radius: 4px; border: 1px solid var(--an-line2); vertical-align: -2px; }
|
||||
.an-docs ul { margin: 0; padding: 0; list-style: none; }
|
||||
.an-docs li { padding: 8px 0; border-top: 1px solid var(--an-line); }
|
||||
#an-off p { margin: 0 0 12px; line-height: 1.55; color: var(--an-fg2); }
|
||||
.an-confirm { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; font-size: 13px; }
|
||||
.an-ghost { padding: 9px 16px; border: 1px solid var(--an-line2); border-radius: 10px; 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; }
|
||||
.an .error { margin: 0 0 12px; text-align: left; }
|
||||
#an-off p.error { color: #fca5a5; }
|
||||
|
||||
/* ------------------------------------------- Einschalten und andere Zustaende --- */
|
||||
.an-state p, .an-panel p { margin: 0 0 12px; line-height: 1.55; color: var(--an-fg2); }
|
||||
.an-state p:last-child { margin-bottom: 0; }
|
||||
.an-panel > p, .an-state p { max-width: 75ch; }
|
||||
.an-panel > h3, .an-state > h3 { margin: 0 0 10px; font-size: 20px; font-weight: 700; letter-spacing: 0; text-transform: none; color: var(--an-fg); }
|
||||
.an-h4 { margin: 20px 0 8px; font-size: 14px; font-weight: 700; }
|
||||
.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(--an-line2); background: transparent; cursor: pointer; }
|
||||
.an-presets { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.an-preset { width: 32px; height: 32px; padding: 0; border-radius: 50%; border: 2px solid var(--an-line2); cursor: pointer; }
|
||||
.an-hint { margin: 10px 0 0 !important; font-size: 13px; color: var(--an-warn) !important; }
|
||||
.an-ctl { margin: 14px 0; padding: 12px 14px; border-radius: 10px; background: var(--an-acc-soft); }
|
||||
.an-ctl p { margin: 0 0 6px; }
|
||||
.an-ctl p:last-child { margin: 0; }
|
||||
.an .fn-consent { max-width: 75ch; color: var(--an-fg2); }
|
||||
.an .fn-consent input { accent-color: var(--an-acc); }
|
||||
.an .abo-btn { background: var(--an-acc); color: var(--an-on-acc); border-radius: 10px; }
|
||||
.an .abo-btn:hover { background: var(--an-acc-strong); }
|
||||
/* 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 .an-bn-text { flex: 1 1 260px; min-width: 0; margin: 0 !important; max-width: none; 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; width: 150px; height: 44px; padding: 0 20px; border: 2px solid; border-radius: 8px; font: 600 15px/1 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; }
|
||||
|
||||
/* ---------------------------------------------------------------- Heatmap --- */
|
||||
/* Buehne: grosses Geraet (Computer oder Tablet) und Handy nebeneinander, unten buendig (Rahmen: geraete.css) */
|
||||
.an-heat > p { margin: 0 0 14px; line-height: 1.55; color: var(--an-fg2); }
|
||||
.an-heat .hs-head { margin-bottom: 10px; }
|
||||
.an-heat-bar { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
|
||||
.an-heat-bar label { display: grid; gap: 4px; flex: 1 1 220px; min-width: 0; max-width: 420px; font-size: 13px; font-weight: 600; }
|
||||
.an-heat-bar select { width: 100%; min-width: 0; padding: 10px 12px; border-radius: 10px; border: 1px solid var(--an-line2); background: rgba(255, 255, 255, 0.05); color: inherit; font: inherit; text-overflow: ellipsis; }
|
||||
.an-heat-bar select option { background: #14161b; color: #fff; }
|
||||
.hs-stage { display: grid; grid-template-columns: minmax(0, 3.7fr) minmax(0, 1fr); align-items: end; gap: clamp(16px, 3vw, 40px); max-width: 980px; margin: 24px auto 4px; }
|
||||
.hs-stage[data-big="t"] { grid-template-columns: minmax(0, 2.1fr) minmax(0, 1fr); max-width: 620px; }
|
||||
.hs-dev { margin: 0; min-width: 0; display: flex; flex-direction: column; align-items: center; gap: 14px; }
|
||||
.hs-frame { width: 100%; }
|
||||
.hs-cap { min-height: 1.5em; font-size: 13px; line-height: 1.5; color: var(--an-muted); text-align: center; }
|
||||
.hs-dev .dm-canvas { background: #fff; }
|
||||
.hs-site { position: absolute; left: 0; background: #fff; }
|
||||
.hs-site iframe { display: block; width: 100%; height: 100%; border: 0; background: #fff; }
|
||||
@media (max-width: 640px) {
|
||||
.hs-stage, .hs-stage[data-big="t"] { grid-template-columns: minmax(0, 1fr); gap: 28px; }
|
||||
.hs-dev--phone .hs-frame { width: 62%; }
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ Mittlere Breite --- */
|
||||
@media (max-width: 1100px) {
|
||||
.an-kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.an-row2 { grid-template-columns: minmax(0, 1fr); }
|
||||
}
|
||||
/* Heatmap-Karte: Vorschau, Text und Knopf in einer Zeile, solange die Karte breit genug ist */
|
||||
@media (min-width: 1101px) {
|
||||
.an-teaser-row { flex-wrap: nowrap; }
|
||||
.an-teaser-row p { flex-basis: 0; }
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------- Handy --- */
|
||||
@media (max-width: 880px) {
|
||||
.an-top { padding: 14px; }
|
||||
.an-seg { display: flex; width: 100%; }
|
||||
.an-seg button { flex: 1 1 0; padding: 9px 8px; min-height: 40px; }
|
||||
.an-tabs { gap: 20px; margin: 14px 2px; }
|
||||
/* Tippflaechen mindestens 40 px hoch */
|
||||
.an-tabbtn { min-height: 40px; padding-top: 8px; }
|
||||
.an-ltab, .an-more, .an-btn { min-height: 40px; }
|
||||
.an-ltab { display: inline-flex; align-items: center; padding: 6px 14px; }
|
||||
.an-card { padding: 16px; }
|
||||
.an-kpi { padding: 14px 14px 10px; }
|
||||
.an-v { font-size: 26px; }
|
||||
.an-row2, .an-pair, .an-points { grid-template-columns: minmax(0, 1fr); }
|
||||
.an-sum { padding: 18px 16px; }
|
||||
.an-lead { font-size: 19px; }
|
||||
.an-sum-empty .an-lead { font-size: 16px; }
|
||||
.an-heat-bar label { max-width: none; }
|
||||
.an-bn { display: block; margin: 12px 0; padding: 16px; }
|
||||
.an-bn-row { flex-direction: column; margin-top: 12px; }
|
||||
.an-bn-btn { width: 100%; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.an *, .an *::before, .an *::after { transition: none !important; animation: none !important; }
|
||||
}
|
||||
@@ -0,0 +1,518 @@
|
||||
/* Analyse-Ansicht im Kundenbereich (ES-Modul, wird von app.js erst beim Oeffnen geladen).
|
||||
initAnalyse(rootEl, { projectId }): Rahmen "Cockpit" (.an) mit Kopfleiste; je nach Zustand der Analyse Einschalten (off),
|
||||
Hinweis (disabled/blocked/unknown) oder, bei aktiver Analyse, die Reiter Uebersicht | Heatmap | Einstellungen.
|
||||
HTML-Bausteine: analyse-cards.js, reine Hilfsfunktionen und der gemeinsame Zustand: analyse-view.js. Styles: analyse.css.
|
||||
Kundentexte kommen nur ueber esc() bzw. textContent in die Seite. */
|
||||
|
||||
import { esc, safeUrl, request } from './fn-util.js'
|
||||
import { analyseOf, pickAnalyseProject, analyseSel, bannerColor, BANNER_PREVIEW_TEXT, controllerLines } from './analyse-view.js'
|
||||
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'
|
||||
|
||||
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']
|
||||
const RANGES = [7, 30, 90]
|
||||
const CLOCK = new Intl.DateTimeFormat('de-DE', { timeZone: 'Europe/Berlin', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' })
|
||||
|
||||
const showError = (root) => (err) => { root.innerHTML = `<p class="muted">${esc(err.message)}</p>` }
|
||||
|
||||
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="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
|
||||
}
|
||||
const project = usable.find((p) => p.projectId === analyseSel.projectId) || pickAnalyseProject(usable)
|
||||
analyseSel.projectId = project.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>`
|
||||
const stage = root.querySelector('#an-stage')
|
||||
const reload = () => initAnalyse(root).catch(showError(root))
|
||||
// Website wechseln: der Reiter bleibt (analyseSel.tab)
|
||||
root.querySelector('.an')?.addEventListener('change', (ev) => {
|
||||
if (ev.target?.id !== 'an-project') return
|
||||
analyseSel.projectId = ev.target.value
|
||||
reload()
|
||||
})
|
||||
if (a.state === 'active') mountCockpit({ an: root.querySelector('.an'), stage, project, usable, reload })
|
||||
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)
|
||||
}
|
||||
|
||||
// Kopfleiste (analyse-cards.js); Zeitraum nur in der Uebersicht, "Stand" = Uhrzeit des letzten Ladens, today = "Heute bisher" aus der Statistik
|
||||
function headHtml(project, usable, { showRange, stamp = '', today } = {}) {
|
||||
const a = analyseOf(project)
|
||||
return headerHtml({ project, usable, range: RANGES.includes(analyseSel.range) ? analyseSel.range : 30, showRange, devMode: a.state === 'active' && Boolean(a.devMode), stamp, today })
|
||||
}
|
||||
|
||||
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">${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>`
|
||||
}
|
||||
|
||||
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="an-card 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.</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">
|
||||
<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>
|
||||
${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>`
|
||||
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 || controller.ok !== true })
|
||||
button.addEventListener('click', async () => {
|
||||
if (!box.checked || controller.ok !== true) 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 || controller.ok !== true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/* --- Aktiver Zustand: Reiter Uebersicht | Heatmap | Einstellungen ---
|
||||
Zustand je Aufruf von initAnalyse in der Closure von mountCockpit; Ereignisse am Wurzelelement .an (Kopfleiste und Reiter werden
|
||||
neu gezeichnet, ohne dass Handler neu gebunden werden muessen). statsRequestId verwirft veraltete Antworten, sobald etwas Neueres
|
||||
angefordert oder der Reiter gewechselt wurde. */
|
||||
let statsRequestId = 0
|
||||
|
||||
function hideChartTip() {
|
||||
document.querySelectorAll('.an-dot.on').forEach((d) => d.classList.remove('on'))
|
||||
document.querySelectorAll('.an-tip').forEach((t) => { t.hidden = true })
|
||||
}
|
||||
let tipListening = false
|
||||
|
||||
// Diagramm: Zeigen oder Tippen waehlt den naechsten Tag, die Blase zeigt "Sa., 19.09.: 371 Aufrufe (davor 210)"
|
||||
function wireChart(stage) {
|
||||
const svg = stage.querySelector('.an-chart')
|
||||
const tip = stage.querySelector('.an-tip')
|
||||
const wrap = svg?.parentElement
|
||||
const dots = svg ? [...svg.querySelectorAll('.an-dot')] : []
|
||||
if (!tip || !wrap || !dots.length) return
|
||||
if (!tipListening) {
|
||||
// Antippen ausserhalb schliesst die Blase (auf Touch bleibt sie sonst nach dem Loslassen stehen)
|
||||
document.addEventListener('pointerdown', (ev) => { if (!ev.target?.closest?.('.an-chart')) hideChartTip() })
|
||||
tipListening = true
|
||||
}
|
||||
const xs = dots.map((d) => Number(d.getAttribute('cx')))
|
||||
const show = (ev) => {
|
||||
const ctm = svg.getScreenCTM()
|
||||
if (!ctm) return
|
||||
const x = new DOMPoint(ev.clientX, ev.clientY).matrixTransform(ctm.inverse()).x
|
||||
let best = 0
|
||||
for (let i = 1; i < xs.length; i++) if (Math.abs(xs[i] - x) < Math.abs(xs[best] - x)) best = i
|
||||
const dot = dots[best]
|
||||
dots.forEach((d) => d.classList.toggle('on', d === dot))
|
||||
tip.textContent = chartTipText(dot.dataset.day, dot.dataset.pv, dot.dataset.prev)
|
||||
tip.hidden = false
|
||||
const box = wrap.getBoundingClientRect()
|
||||
const at = dot.getBoundingClientRect()
|
||||
const half = tip.offsetWidth / 2
|
||||
tip.style.left = `${Math.min(Math.max(at.left + at.width / 2 - box.left, half), box.width - half)}px`
|
||||
tip.style.top = `${at.top + at.height / 2 - box.top}px`
|
||||
tip.classList.toggle('below', at.top - box.top < tip.offsetHeight + 16)
|
||||
}
|
||||
svg.addEventListener('pointermove', show)
|
||||
svg.addEventListener('pointerdown', show)
|
||||
svg.addEventListener('pointerleave', (ev) => { if (ev.pointerType === 'mouse') hideChartTip() })
|
||||
svg.addEventListener('pointercancel', hideChartTip)
|
||||
}
|
||||
|
||||
function mountCockpit({ an, stage, project, usable, reload }) {
|
||||
const pid = project.projectId
|
||||
const state = { stats: null, range: 0, stamp: '', rank: { seen: {}, who: {} } }
|
||||
const tabNow = () => (TABS.includes(analyseSel.tab) ? analyseSel.tab : 'uebersicht')
|
||||
const rangeNow = () => (RANGES.includes(analyseSel.range) ? analyseSel.range : 30)
|
||||
const current = (mine) => stage.isConnected && mine === statsRequestId
|
||||
|
||||
// Kopfleiste neu zeichnen (Zeitraum nur in der Uebersicht, Stand); Fokus auf einem Zeitraum-Knopf bleibt
|
||||
function paintHead() {
|
||||
const bar = an.querySelector('.an-top')
|
||||
if (!bar) return
|
||||
const focused = bar.contains(document.activeElement) ? document.activeElement.dataset.range : ''
|
||||
bar.outerHTML = headHtml(project, usable, { showRange: tabNow() === 'uebersicht', stamp: state.stamp, today: state.stats?.today })
|
||||
if (focused) an.querySelector(`.an-seg [data-range="${focused}"]`)?.focus()
|
||||
}
|
||||
// Reiterknoepfe in place umschalten (Fokus bleibt), nur der gewaehlte ist per Tab erreichbar, die anderen per Pfeiltaste
|
||||
function paintTabs() {
|
||||
const tab = tabNow()
|
||||
an.querySelectorAll('.an-tabbtn').forEach((b) => {
|
||||
const on = b.dataset.tab === tab
|
||||
b.classList.toggle('on', on)
|
||||
b.setAttribute('aria-selected', String(on))
|
||||
b.tabIndex = on ? 0 : -1
|
||||
})
|
||||
stage.setAttribute('aria-labelledby', `an-tab-${tab}`)
|
||||
}
|
||||
// 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.
|
||||
const pending = {}
|
||||
function fetchStats(range) {
|
||||
if (!pending[range]) {
|
||||
const p = request(`/api/funktionen/analyse/stats?projectId=${encodeURIComponent(pid)}&range=${range}`)
|
||||
const done = () => { if (pending[range] === p) delete pending[range] }
|
||||
p.then(done, done)
|
||||
pending[range] = p
|
||||
}
|
||||
return pending[range]
|
||||
}
|
||||
function adopt(stats, range) {
|
||||
state.stats = stats
|
||||
state.range = range
|
||||
state.stamp = CLOCK.format(new Date())
|
||||
}
|
||||
|
||||
function paintOverview() {
|
||||
stage.innerHTML = overviewHtml(state.stats)
|
||||
for (const card of Object.keys(state.rank)) if (state.rank[card].tab) paintRank(card)
|
||||
wireChart(stage)
|
||||
}
|
||||
// Rangliste einer Karte neu zeichnen (nur diese Karte); der Fokus kehrt auf den gewaehlten Umschalter zurueck
|
||||
function paintRank(card, { focus = false } = {}) {
|
||||
const old = stage.querySelector(`[data-rank="${card}"]`)
|
||||
if (!old) return
|
||||
old.outerHTML = rankCardHtml(card, state.stats, state.rank[card])
|
||||
if (focus) stage.querySelector(`[data-rank="${card}"] .an-ltab.on`)?.focus()
|
||||
}
|
||||
async function showOverview() {
|
||||
const mine = ++statsRequestId
|
||||
const range = rangeNow()
|
||||
if (state.stats && state.range === range) {
|
||||
paintOverview()
|
||||
return
|
||||
}
|
||||
stage.innerHTML = skeletonHtml()
|
||||
let stats
|
||||
try {
|
||||
stats = await fetchStats(range)
|
||||
} catch (err) {
|
||||
if (!current(mine)) return
|
||||
stage.innerHTML = errorHtml(err.status === 503 ? 'Auswertung gerade nicht erreichbar' : err.message)
|
||||
return
|
||||
}
|
||||
if (!current(mine)) return
|
||||
adopt(stats, range)
|
||||
paintHead()
|
||||
paintOverview()
|
||||
}
|
||||
// Heatmap: Seitenliste aus der zuletzt geladenen Statistik, sonst einmal 30 Tage laden; iframes erst jetzt
|
||||
async function showHeat() {
|
||||
const mine = ++statsRequestId
|
||||
stage.innerHTML = '<div class="an-ov"><section class="an-card an-heat" id="an-heat"></section></div>'
|
||||
const box = stage.querySelector('#an-heat')
|
||||
if (!state.stats) {
|
||||
box.innerHTML = '<h3>Heatmap</h3><p class="an-empty">Lade…</p>'
|
||||
let stats
|
||||
try {
|
||||
stats = await fetchStats(30)
|
||||
} catch (err) {
|
||||
if (!current(mine)) return
|
||||
stage.innerHTML = errorHtml(err.status === 503 ? 'Auswertung gerade nicht erreichbar' : err.message)
|
||||
return
|
||||
}
|
||||
if (!current(mine)) return
|
||||
adopt(stats, 30)
|
||||
paintHead()
|
||||
}
|
||||
renderAnalyseHeat(box, pid, state.stats.pages || [])
|
||||
}
|
||||
function showSettings() {
|
||||
statsRequestId += 1
|
||||
stage.innerHTML = settingsHtml(project)
|
||||
renderAnalyseOff(stage.querySelector('#an-off'), pid, reload)
|
||||
}
|
||||
function showTab() {
|
||||
paintTabs()
|
||||
paintHead()
|
||||
const tab = tabNow()
|
||||
if (tab === 'heatmap') showHeat()
|
||||
else if (tab === 'einstellungen') showSettings()
|
||||
else showOverview()
|
||||
}
|
||||
function pickTab(tab) {
|
||||
if (!TABS.includes(tab) || tab === tabNow()) return
|
||||
hideChartTip()
|
||||
analyseSel.tab = tab
|
||||
showTab()
|
||||
}
|
||||
|
||||
an.addEventListener('click', (ev) => {
|
||||
const btn = ev.target?.closest?.('button')
|
||||
if (!btn || !an.contains(btn)) return
|
||||
if (btn.dataset.range && btn.closest('.an-seg')) {
|
||||
// Zeitraum: nur die Uebersicht laedt neu
|
||||
const range = Number(btn.dataset.range)
|
||||
if (!RANGES.includes(range) || range === rangeNow()) return
|
||||
analyseSel.range = range
|
||||
paintHead()
|
||||
showOverview()
|
||||
} else if (btn.dataset.tab && (btn.closest('.an-tabs') || btn.closest('.an-teaser'))) {
|
||||
pickTab(btn.dataset.tab)
|
||||
} else if (btn.id === 'an-retry') {
|
||||
showTab()
|
||||
} else if (btn.closest('.an-rank')) {
|
||||
const card = btn.closest('.an-rank').dataset.rank
|
||||
const rank = state.rank[card]
|
||||
if (!rank) return
|
||||
if (btn.dataset.rankTab) Object.assign(rank, { tab: btn.dataset.rankTab, expanded: false })
|
||||
else if (btn.hasAttribute('data-rank-more')) rank.expanded = true
|
||||
else return
|
||||
paintRank(card, { focus: true })
|
||||
}
|
||||
})
|
||||
// Pfeiltasten zwischen den Reitern (Reiter wechselt mit dem Fokus)
|
||||
an.addEventListener('keydown', (ev) => {
|
||||
if (!ev.target?.closest?.('.an-tabbtn')) return
|
||||
const tabs = [...an.querySelectorAll('.an-tabbtn')]
|
||||
const at = tabs.indexOf(ev.target.closest('.an-tabbtn'))
|
||||
const next = { ArrowRight: (at + 1) % tabs.length, ArrowLeft: (at + tabs.length - 1) % tabs.length, Home: 0, End: tabs.length - 1 }[ev.key]
|
||||
if (next === undefined) return
|
||||
ev.preventDefault()
|
||||
tabs[next].focus()
|
||||
pickTab(tabs[next].dataset.tab)
|
||||
})
|
||||
showTab()
|
||||
}
|
||||
|
||||
/* Heatmap: die echte Website in Geraeterahmen (gross: Computer oder Tablet, daneben das Handy), darueber die Klicks.
|
||||
Die Website laeuft im iframe in echter Breite; h.js zeichnet dort das Overlay, sperrt Links und meldet die Klickzahl
|
||||
per postMessage. Geraeterahmen: geraete.js + geraete.css (wird erst hier geladen). */
|
||||
const heatSel = { big: 'd' }
|
||||
const HEAT_TIMEOUT_MS = 20000
|
||||
|
||||
let geraeteCss = null
|
||||
function loadGeraeteCss() {
|
||||
if (!geraeteCss) {
|
||||
geraeteCss = new Promise((resolve) => {
|
||||
const link = document.createElement('link')
|
||||
link.rel = 'stylesheet'
|
||||
link.href = '/geraete.css'
|
||||
link.onload = link.onerror = () => resolve()
|
||||
document.head.appendChild(link)
|
||||
})
|
||||
}
|
||||
return geraeteCss
|
||||
}
|
||||
|
||||
// Nachricht von h.js aus einem der Rahmen: Klickzahl oder Fehler
|
||||
let heatListening = false
|
||||
function onHeatMessage(ev) {
|
||||
const d = ev.data
|
||||
if (!d || d.wk !== 'heat') return
|
||||
for (const fig of document.querySelectorAll('.hs-dev')) {
|
||||
const frame = fig.querySelector('iframe')
|
||||
if (!frame || frame.contentWindow !== ev.source || ev.origin !== fig.dataset.origin) continue
|
||||
clearTimeout(fig.heatTimer)
|
||||
fig.dataset.state = d.state === 'ready' ? 'ready' : 'error'
|
||||
setHeatCaption(fig, heatCaption(fig.dataset.state, Number(d.count) || 0, (n) => nf.format(n)))
|
||||
}
|
||||
}
|
||||
|
||||
function setHeatCaption(fig, text) {
|
||||
const cap = fig.querySelector('.hs-cap')
|
||||
if (cap) cap.textContent = text
|
||||
}
|
||||
|
||||
// Bildschirmflaeche (dm-canvas) in Designgroesse per scale() einpassen, wie device-frames.js der Website
|
||||
function fitHeatDevice(fig) {
|
||||
fig.heatResize?.disconnect()
|
||||
const canvas = fig.querySelector('.dm-canvas')
|
||||
const host = canvas?.parentElement
|
||||
if (!host) return
|
||||
const fit = (width) => {
|
||||
if (width > 0) canvas.style.setProperty('--dm-k', String(Math.round((width / Number(canvas.dataset.designWidth)) * 1e6) / 1e6))
|
||||
}
|
||||
fit(parseFloat(getComputedStyle(host).width) || host.clientWidth)
|
||||
if (typeof ResizeObserver === 'function') {
|
||||
fig.heatResize = new ResizeObserver((entries) => fit(entries[entries.length - 1].contentRect.width))
|
||||
fig.heatResize.observe(host)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHeatDevice(fig, projectId, vp, path) {
|
||||
const run = (fig.heatRun = (fig.heatRun || 0) + 1)
|
||||
clearTimeout(fig.heatTimer)
|
||||
fig.heatResize?.disconnect()
|
||||
fig.dataset.state = 'loading'
|
||||
fig.innerHTML = `<div class="hs-frame"></div><figcaption class="hs-cap" aria-live="polite">${esc(heatCaption('loading'))}</figcaption>`
|
||||
let url
|
||||
try {
|
||||
;({ url } = await request('/api/funktionen/analyse/heatmap-link', { method: 'POST', body: { projectId, path, vp, embed: true } }))
|
||||
} catch (err) {
|
||||
if (run === fig.heatRun) setHeatCaption(fig, err.status === 503 ? 'Auswertung gerade nicht erreichbar' : err.message)
|
||||
return
|
||||
}
|
||||
await loadGeraeteCss()
|
||||
if (run !== fig.heatRun || !fig.isConnected) return
|
||||
if (!safeUrl(url)) return setHeatCaption(fig, heatCaption('error'))
|
||||
fig.dataset.origin = new URL(url).origin
|
||||
fig.querySelector('.hs-frame').innerHTML = deviceHtml(vp, { src: url, title: `Heatmap ${HEAT_DEVICES[vp].label}, Seite ${path}` })
|
||||
fitHeatDevice(fig)
|
||||
// Seitenwechsel im Rahmen (Links sperrt h.js, Skripte der Seite koennten trotzdem wechseln): Heatmap neu laden
|
||||
let loads = 0
|
||||
fig.querySelector('iframe').addEventListener('load', () => {
|
||||
if (++loads > 1 && run === fig.heatRun && (fig.heatReloads = (fig.heatReloads || 0) + 1) <= 3) loadHeatDevice(fig, projectId, vp, path)
|
||||
})
|
||||
fig.heatTimer = setTimeout(() => {
|
||||
if (run === fig.heatRun && fig.isConnected && fig.dataset.state === 'loading') showHeatFallback(fig, projectId, vp, path)
|
||||
}, HEAT_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
// Website laesst sich nicht einbetten (oder das Analyse-Skript fehlt): Heatmap im eigenen Tab wie bisher
|
||||
async function showHeatFallback(fig, projectId, vp, path) {
|
||||
fig.dataset.state = 'timeout'
|
||||
const cap = fig.querySelector('.hs-cap')
|
||||
if (!cap) return
|
||||
cap.textContent = heatCaption('timeout')
|
||||
try {
|
||||
const { url } = await request('/api/funktionen/analyse/heatmap-link', { method: 'POST', body: { projectId, path, vp } })
|
||||
// Kein window.open nach await (Safari/iOS blockiert das): sichtbarer Link, den der Kunde selbst antippt
|
||||
if (safeUrl(url) && cap.isConnected) cap.insertAdjacentHTML('beforeend', ` <a class="kb-link" href="${esc(url)}" target="_blank" rel="noopener">In neuem Tab öffnen</a>`)
|
||||
} catch {
|
||||
/* Text reicht */
|
||||
}
|
||||
}
|
||||
|
||||
function renderAnalyseHeat(box, projectId, pages) {
|
||||
if (!box) return
|
||||
if (!pages.length) {
|
||||
box.innerHTML = '<h3>Heatmap</h3><p class="an-empty">Sobald Besucher zugestimmt haben und Seiten aufgerufen wurden, kannst du hier sehen, wo geklickt und gescrollt wird.</p>'
|
||||
return
|
||||
}
|
||||
if (!heatListening) {
|
||||
window.addEventListener('message', onHeatMessage)
|
||||
heatListening = true
|
||||
}
|
||||
const big = heatSel.big === 't' ? 't' : 'd'
|
||||
box.innerHTML = `
|
||||
<div class="hs-head"><h3>Heatmap</h3></div>
|
||||
<p>So sieht deine Website auf den Geräten aus, mit den Klicks der letzten 30 Tage. Scrolle in den Geräten durch die Seite; Links sind hier gesperrt.</p>
|
||||
<div class="an-heat-bar">
|
||||
<label>Seite<select id="an-heat-page">${pages.map((p) => `<option value="${esc(p.key)}">${esc(p.key || '/')}</option>`).join('')}</select></label>
|
||||
<div class="an-seg hs-switch" role="group" aria-label="Großes Gerät">${['d', 't'].map((vp) => `<button type="button"${vp === big ? ' class="on"' : ''} data-vp="${vp}" aria-pressed="${vp === big}">${HEAT_DEVICES[vp].label}</button>`).join('')}</div>
|
||||
</div>
|
||||
<div class="hs-stage" data-big="${big}">
|
||||
<figure class="hs-dev hs-dev--big"></figure>
|
||||
<figure class="hs-dev hs-dev--phone"></figure>
|
||||
</div>`
|
||||
const stage = box.querySelector('.hs-stage')
|
||||
const bigFig = box.querySelector('.hs-dev--big')
|
||||
const phoneFig = box.querySelector('.hs-dev--phone')
|
||||
const page = () => box.querySelector('#an-heat-page').value || '/'
|
||||
const show = (fig, vp) => {
|
||||
fig.heatReloads = 0
|
||||
loadHeatDevice(fig, projectId, vp, page())
|
||||
}
|
||||
box.querySelector('#an-heat-page').addEventListener('change', () => {
|
||||
show(bigFig, heatSel.big)
|
||||
show(phoneFig, 'm')
|
||||
})
|
||||
box.querySelectorAll('.hs-switch button').forEach((b) => b.addEventListener('click', () => {
|
||||
if (b.dataset.vp === heatSel.big) return
|
||||
heatSel.big = b.dataset.vp
|
||||
box.querySelectorAll('.hs-switch button').forEach((x) => {
|
||||
const on = x.dataset.vp === heatSel.big
|
||||
x.classList.toggle('on', on)
|
||||
x.setAttribute('aria-pressed', String(on))
|
||||
})
|
||||
stage.dataset.big = heatSel.big
|
||||
show(bigFig, heatSel.big)
|
||||
}))
|
||||
heatSel.big = big
|
||||
// Websites erst laden, wenn die Heatmap in Sicht kommt (sie steht unten auf der Seite)
|
||||
const start = () => {
|
||||
show(bigFig, heatSel.big)
|
||||
show(phoneFig, 'm')
|
||||
}
|
||||
if (typeof IntersectionObserver !== 'function') return start()
|
||||
const io = new IntersectionObserver((entries) => {
|
||||
if (!entries.some((e) => e.isIntersecting)) return
|
||||
io.disconnect()
|
||||
start()
|
||||
}, { rootMargin: '300px 0px' })
|
||||
io.observe(stage)
|
||||
}
|
||||
|
||||
// Ausschalten mit Bestaetigung in der Seite (zweiter Klick), ohne Browser-Dialog
|
||||
export function renderAnalyseOff(box, projectId, reload) {
|
||||
box.innerHTML = `
|
||||
<h3>Analyse ausschalten</h3>
|
||||
<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="an-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 } })
|
||||
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')
|
||||
e2.textContent = err.message
|
||||
e2.classList.remove('hidden')
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
+1
-1
@@ -1917,7 +1917,7 @@ const viewLoaders = { chat: initChat }
|
||||
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'))),
|
||||
analyse: () => import('/analyse.js').then((m) => m.initAnalyse(document.getElementById('analyse-body'))),
|
||||
termine: () => import('/termine.js').then((m) => m.initTermine(document.getElementById('termine-body'))),
|
||||
}
|
||||
const loadedViews = new Set()
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
<link rel="stylesheet" href="/kundenbereich.css">
|
||||
<link rel="stylesheet" href="/chat-composer.css">
|
||||
<link rel="stylesheet" href="/funktionen.css">
|
||||
<link rel="stylesheet" href="/analyse.css">
|
||||
<link rel="stylesheet" href="/termine.css">
|
||||
</head>
|
||||
<body class="portal-page dashboard-page kb-page">
|
||||
@@ -177,7 +178,7 @@
|
||||
<div id="news-body"><p class="kb-empty">Lade…</p></div>
|
||||
</section>
|
||||
|
||||
<!-- Analyse: Einschalten oder Auswertung (funktionen.js, initAnalyse) -->
|
||||
<!-- Analyse: Einschalten oder Auswertung (analyse.js, initAnalyse) -->
|
||||
<section class="kb-view" id="view-analyse">
|
||||
<header class="kb-head">
|
||||
<h1>Analyse</h1>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/* Gemeinsame Hilfen der Funktions-Module (funktionen.js, analyse.js): Escaping fuer Kundentexte, URL-Pruefung, API-Aufruf. */
|
||||
|
||||
export const esc = (value) => String(value ?? '')
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''')
|
||||
export const safeUrl = (url) => (/^https?:\/\//i.test(url || '') ? url : '')
|
||||
|
||||
export async function request(path, { method = 'GET', body, raw } = {}) {
|
||||
const response = await fetch(path, {
|
||||
method,
|
||||
credentials: 'same-origin',
|
||||
headers: raw ? { 'Content-Type': 'application/octet-stream' } : body ? { 'Content-Type': 'application/json' } : {},
|
||||
body: raw || (body ? JSON.stringify(body) : undefined),
|
||||
})
|
||||
const data = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
const err = new Error(data.error || (response.status === 502 ? 'Server nicht erreichbar. Bitte kurz warten und noch einmal versuchen.' : `Fehler ${response.status}`))
|
||||
err.status = response.status
|
||||
if (data.field) err.field = data.field
|
||||
throw err
|
||||
}
|
||||
return data
|
||||
}
|
||||
@@ -137,82 +137,3 @@
|
||||
.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; width: 150px; 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; }
|
||||
/* Heatmap-Buehne: grosses Geraet (Computer oder Tablet) und Handy nebeneinander, unten buendig (Rahmen: geraete.css) */
|
||||
.an-heat .hs-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-bottom: 10px; }
|
||||
.an-heat .hs-head h3 { margin: 0; }
|
||||
.hs-switch { display: flex; gap: 8px; }
|
||||
.hs-stage { display: grid; grid-template-columns: minmax(0, 3.7fr) minmax(0, 1fr); align-items: end; gap: clamp(16px, 3vw, 40px); max-width: 980px; margin: 24px auto 4px; }
|
||||
.hs-stage[data-big="t"] { grid-template-columns: minmax(0, 2.1fr) minmax(0, 1fr); max-width: 620px; }
|
||||
.hs-dev { margin: 0; min-width: 0; display: flex; flex-direction: column; align-items: center; gap: 14px; }
|
||||
.hs-frame { width: 100%; }
|
||||
.hs-cap { min-height: 1.5em; font-size: 13px; line-height: 1.5; color: var(--kb-muted); text-align: center; }
|
||||
.hs-dev .dm-canvas { background: #fff; }
|
||||
.hs-site { position: absolute; left: 0; background: #fff; }
|
||||
.hs-site iframe { display: block; width: 100%; height: 100%; border: 0; background: #fff; }
|
||||
@media (max-width: 640px) {
|
||||
.hs-stage, .hs-stage[data-big="t"] { grid-template-columns: minmax(0, 1fr); gap: 28px; }
|
||||
.hs-dev--phone .hs-frame { width: 62%; }
|
||||
}
|
||||
.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%; }
|
||||
}
|
||||
+2
-400
@@ -3,8 +3,8 @@
|
||||
Buchungsseite (off), Einrichtungsstatus (ordered/setup) oder Editor (active). Styles: funktionen.css.
|
||||
Kundentexte kommen nur ueber esc() bzw. textContent in die Seite. */
|
||||
|
||||
import { bannerColor, BANNER_PREVIEW_TEXT, controllerLines, chartModel, barRows, countryName, flagEmoji, refLabel, DEVICE_LABELS, CONTACT_LABELS } from '/analyse-view.js'
|
||||
import { HEAT_DEVICES, deviceHtml, heatCaption } from '/geraete.js'
|
||||
import { esc, safeUrl, request } from '/fn-util.js'
|
||||
import { analyseOf, analyseState, pickAnalyseProject, analyseSel } from '/analyse-view.js'
|
||||
import { ZU_VERTRAEGE } from './vertraege-link.js'
|
||||
|
||||
const PRICE = '30 €' // Rueckfall, wenn die Antwort von /api/funktionen kein features.news (Register) enthaelt
|
||||
@@ -28,10 +28,6 @@ const TYPES = [
|
||||
['combo', 'Foto + Text', 'beides', '<rect x="3" y="3" width="12" height="12" rx="2"/><path d="M9 21h10a2 2 0 0 0 2-2V9"/><path d="m3 11 3-3 4 4"/>'],
|
||||
]
|
||||
|
||||
const esc = (value) => String(value ?? '')
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''')
|
||||
const safeUrl = (url) => (/^https?:\/\//i.test(url || '') ? url : '')
|
||||
|
||||
function fmtDay(value) {
|
||||
const d = new Date(value)
|
||||
if (!value || Number.isNaN(d.getTime())) return ''
|
||||
@@ -44,23 +40,6 @@ function berlinIsoDay(offsetDays = 0) {
|
||||
return d.toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' })
|
||||
}
|
||||
|
||||
async function request(path, { method = 'GET', body, raw } = {}) {
|
||||
const response = await fetch(path, {
|
||||
method,
|
||||
credentials: 'same-origin',
|
||||
headers: raw ? { 'Content-Type': 'application/octet-stream' } : body ? { 'Content-Type': 'application/json' } : {},
|
||||
body: raw || (body ? JSON.stringify(body) : undefined),
|
||||
})
|
||||
const data = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
const err = new Error(data.error || (response.status === 502 ? 'Server nicht erreichbar. Bitte kurz warten und noch einmal versuchen.' : `Fehler ${response.status}`))
|
||||
err.status = response.status
|
||||
if (data.field) err.field = data.field
|
||||
throw err
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ Zustand --- */
|
||||
// Das im News-Bereich gewaehlte Projekt bleibt beim Wechseln zwischen Kacheln und News erhalten
|
||||
const selection = { projectId: '' }
|
||||
@@ -120,15 +99,9 @@ function newsTile(projects, price = PRICE) {
|
||||
const ZU_VERTRAEGE_LINK = `<button type="button" class="${ZU_VERTRAEGE.klasse}" data-view="${ZU_VERTRAEGE.view}">${ZU_VERTRAEGE.text}</button>`
|
||||
|
||||
/* 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 === 'unknown' ? 'unknown' : a.state === 'disabled' ? 'disabled' : a.state === 'active' ? 'active' : a.eligible ? 'off' : 'blocked')
|
||||
const DEV_HINT = 'Im Dev-Modus wird nicht gemessen.'
|
||||
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)
|
||||
@@ -656,374 +629,3 @@ 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.state === 'unknown') stage.innerHTML = '<div class="kb-panel"><p>Die Daten konnten gerade nicht geladen werden.</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' ? ` ${ZU_VERTRAEGE_LINK}` : ''}</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">${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>`
|
||||
}
|
||||
|
||||
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.</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">
|
||||
<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>
|
||||
${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>`
|
||||
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 || controller.ok !== true })
|
||||
button.addEventListener('click', async () => {
|
||||
if (!box.checked || controller.ok !== true) 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 || controller.ok !== true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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))}`
|
||||
|
||||
let statsRequestId = 0
|
||||
async function renderAnalyseStats(stage, project, reload) {
|
||||
const myRequest = ++statsRequestId
|
||||
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 || myRequest !== statsRequestId) return
|
||||
data.innerHTML = `<p class="error" role="alert">${esc(err.status === 503 ? 'Auswertung gerade nicht erreichbar' : err.message)}</p>`
|
||||
return
|
||||
}
|
||||
if (!stage.isConnected || myRequest !== statsRequestId) 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 || [])
|
||||
}
|
||||
|
||||
/* Heatmap: die echte Website in Geraeterahmen (gross: Computer oder Tablet, daneben das Handy), darueber die Klicks.
|
||||
Die Website laeuft im iframe in echter Breite; h.js zeichnet dort das Overlay, sperrt Links und meldet die Klickzahl
|
||||
per postMessage. Geraeterahmen: geraete.js + geraete.css (wird erst hier geladen). */
|
||||
const heatSel = { big: 'd' }
|
||||
const HEAT_TIMEOUT_MS = 20000
|
||||
|
||||
let geraeteCss = null
|
||||
function loadGeraeteCss() {
|
||||
if (!geraeteCss) {
|
||||
geraeteCss = new Promise((resolve) => {
|
||||
const link = document.createElement('link')
|
||||
link.rel = 'stylesheet'
|
||||
link.href = '/geraete.css'
|
||||
link.onload = link.onerror = () => resolve()
|
||||
document.head.appendChild(link)
|
||||
})
|
||||
}
|
||||
return geraeteCss
|
||||
}
|
||||
|
||||
// Nachricht von h.js aus einem der Rahmen: Klickzahl oder Fehler
|
||||
let heatListening = false
|
||||
function onHeatMessage(ev) {
|
||||
const d = ev.data
|
||||
if (!d || d.wk !== 'heat') return
|
||||
for (const fig of document.querySelectorAll('.hs-dev')) {
|
||||
const frame = fig.querySelector('iframe')
|
||||
if (!frame || frame.contentWindow !== ev.source || ev.origin !== fig.dataset.origin) continue
|
||||
clearTimeout(fig.heatTimer)
|
||||
fig.dataset.state = d.state === 'ready' ? 'ready' : 'error'
|
||||
setHeatCaption(fig, heatCaption(fig.dataset.state, Number(d.count) || 0, (n) => nf.format(n)))
|
||||
}
|
||||
}
|
||||
|
||||
function setHeatCaption(fig, text) {
|
||||
const cap = fig.querySelector('.hs-cap')
|
||||
if (cap) cap.textContent = text
|
||||
}
|
||||
|
||||
// Bildschirmflaeche (dm-canvas) in Designgroesse per scale() einpassen, wie device-frames.js der Website
|
||||
function fitHeatDevice(fig) {
|
||||
fig.heatResize?.disconnect()
|
||||
const canvas = fig.querySelector('.dm-canvas')
|
||||
const host = canvas?.parentElement
|
||||
if (!host) return
|
||||
const fit = (width) => {
|
||||
if (width > 0) canvas.style.setProperty('--dm-k', String(Math.round((width / Number(canvas.dataset.designWidth)) * 1e6) / 1e6))
|
||||
}
|
||||
fit(parseFloat(getComputedStyle(host).width) || host.clientWidth)
|
||||
if (typeof ResizeObserver === 'function') {
|
||||
fig.heatResize = new ResizeObserver((entries) => fit(entries[entries.length - 1].contentRect.width))
|
||||
fig.heatResize.observe(host)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHeatDevice(fig, projectId, vp, path) {
|
||||
const run = (fig.heatRun = (fig.heatRun || 0) + 1)
|
||||
clearTimeout(fig.heatTimer)
|
||||
fig.heatResize?.disconnect()
|
||||
fig.dataset.state = 'loading'
|
||||
fig.innerHTML = `<div class="hs-frame"></div><figcaption class="hs-cap" aria-live="polite">${esc(heatCaption('loading'))}</figcaption>`
|
||||
let url
|
||||
try {
|
||||
;({ url } = await request('/api/funktionen/analyse/heatmap-link', { method: 'POST', body: { projectId, path, vp, embed: true } }))
|
||||
} catch (err) {
|
||||
if (run === fig.heatRun) setHeatCaption(fig, err.status === 503 ? 'Auswertung gerade nicht erreichbar' : err.message)
|
||||
return
|
||||
}
|
||||
await loadGeraeteCss()
|
||||
if (run !== fig.heatRun || !fig.isConnected) return
|
||||
if (!safeUrl(url)) return setHeatCaption(fig, heatCaption('error'))
|
||||
fig.dataset.origin = new URL(url).origin
|
||||
fig.querySelector('.hs-frame').innerHTML = deviceHtml(vp, { src: url, title: `Heatmap ${HEAT_DEVICES[vp].label}, Seite ${path}` })
|
||||
fitHeatDevice(fig)
|
||||
// Seitenwechsel im Rahmen (Links sperrt h.js, Skripte der Seite koennten trotzdem wechseln): Heatmap neu laden
|
||||
let loads = 0
|
||||
fig.querySelector('iframe').addEventListener('load', () => {
|
||||
if (++loads > 1 && run === fig.heatRun && (fig.heatReloads = (fig.heatReloads || 0) + 1) <= 3) loadHeatDevice(fig, projectId, vp, path)
|
||||
})
|
||||
fig.heatTimer = setTimeout(() => {
|
||||
if (run === fig.heatRun && fig.dataset.state === 'loading') showHeatFallback(fig, projectId, vp, path)
|
||||
}, HEAT_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
// Website laesst sich nicht einbetten (oder das Analyse-Skript fehlt): Heatmap im eigenen Tab wie bisher
|
||||
async function showHeatFallback(fig, projectId, vp, path) {
|
||||
fig.dataset.state = 'timeout'
|
||||
const cap = fig.querySelector('.hs-cap')
|
||||
if (!cap) return
|
||||
cap.textContent = heatCaption('timeout')
|
||||
try {
|
||||
const { url } = await request('/api/funktionen/analyse/heatmap-link', { method: 'POST', body: { projectId, path, vp } })
|
||||
// Kein window.open nach await (Safari/iOS blockiert das): sichtbarer Link, den der Kunde selbst antippt
|
||||
if (safeUrl(url) && cap.isConnected) cap.insertAdjacentHTML('beforeend', ` <a class="kb-link" href="${esc(url)}" target="_blank" rel="noopener">In neuem Tab öffnen</a>`)
|
||||
} catch {
|
||||
/* Text reicht */
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
if (!heatListening) {
|
||||
window.addEventListener('message', onHeatMessage)
|
||||
heatListening = true
|
||||
}
|
||||
const big = heatSel.big === 't' ? 't' : 'd'
|
||||
box.innerHTML = `
|
||||
<div class="hs-head">
|
||||
<h3>Heatmap</h3>
|
||||
<div class="hs-switch" role="group" aria-label="Großes Gerät">${['d', 't'].map((vp) => `<button type="button" class="an-tab${vp === big ? ' active' : ''}" data-vp="${vp}" aria-pressed="${vp === big}">${HEAT_DEVICES[vp].label}</button>`).join('')}</div>
|
||||
</div>
|
||||
<p>So sieht deine Website auf den Geräten aus, mit den Klicks der letzten 30 Tage. Scrolle in den Geräten durch die Seite; Links sind hier gesperrt.</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>
|
||||
</div>
|
||||
<div class="hs-stage" data-big="${big}">
|
||||
<figure class="hs-dev hs-dev--big"></figure>
|
||||
<figure class="hs-dev hs-dev--phone"></figure>
|
||||
</div>`
|
||||
const stage = box.querySelector('.hs-stage')
|
||||
const bigFig = box.querySelector('.hs-dev--big')
|
||||
const phoneFig = box.querySelector('.hs-dev--phone')
|
||||
const page = () => box.querySelector('#an-heat-page').value || '/'
|
||||
const show = (fig, vp) => {
|
||||
fig.heatReloads = 0
|
||||
loadHeatDevice(fig, projectId, vp, page())
|
||||
}
|
||||
box.querySelector('#an-heat-page').addEventListener('change', () => {
|
||||
show(bigFig, heatSel.big)
|
||||
show(phoneFig, 'm')
|
||||
})
|
||||
box.querySelectorAll('.hs-switch button').forEach((b) => b.addEventListener('click', () => {
|
||||
if (b.dataset.vp === heatSel.big) return
|
||||
heatSel.big = b.dataset.vp
|
||||
box.querySelectorAll('.hs-switch button').forEach((x) => {
|
||||
const on = x.dataset.vp === heatSel.big
|
||||
x.classList.toggle('active', on)
|
||||
x.setAttribute('aria-pressed', String(on))
|
||||
})
|
||||
stage.dataset.big = heatSel.big
|
||||
show(bigFig, heatSel.big)
|
||||
}))
|
||||
heatSel.big = big
|
||||
// Websites erst laden, wenn die Heatmap in Sicht kommt (sie steht unten auf der Seite)
|
||||
const start = () => {
|
||||
show(bigFig, heatSel.big)
|
||||
show(phoneFig, 'm')
|
||||
}
|
||||
if (typeof IntersectionObserver !== 'function') return start()
|
||||
const io = new IntersectionObserver((entries) => {
|
||||
if (!entries.some((e) => e.isIntersecting)) return
|
||||
io.disconnect()
|
||||
start()
|
||||
}, { rootMargin: '300px 0px' })
|
||||
io.observe(stage)
|
||||
}
|
||||
|
||||
// Ausschalten mit Bestaetigung in der Seite (zweiter Klick), ohne Browser-Dialog
|
||||
export function renderAnalyseOff(box, projectId, reload) {
|
||||
box.innerHTML = `
|
||||
<h3>Analyse ausschalten</h3>
|
||||
<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')
|
||||
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 } })
|
||||
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')
|
||||
e2.textContent = err.message
|
||||
e2.classList.remove('hidden')
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/* Der Link „Zu Verträge“ in den Hinweisen ohne Wartungs-Abo (Grund kein_abo): die einzige Quelle für Text, Ziel und Klasse. Gelesen von
|
||||
funktionen.js (Kachel Analyse, Analyse-Seite, Kachel Terminplaner; baut daraus HTML) und von termine.js (Hinweis in der Ansicht #termine;
|
||||
baut daraus ein Element). Reine Daten ohne Import: beide laden das Modul relativ, funktionen.js behält seine zwei absoluten Importe.
|
||||
funktionen.js (Kachel Analyse, Kachel Terminplaner; baut daraus HTML), analyse.js (Analyse-Seite; baut daraus HTML) und termine.js (Hinweis
|
||||
in der Ansicht #termine; baut daraus ein Element). Reine Daten ohne Import: alle laden das Modul relativ.
|
||||
Die Hinweistexte selbst stehen nicht hier: sie kommen vom Server (aboMeldung in server/services/featureOrder.js). */
|
||||
export const ZU_VERTRAEGE = Object.freeze({ text: 'Zu Verträge', view: 'vertraege', klasse: 'kb-link' })
|
||||
Reference in new issue
Block a user