diff --git a/analytics/test/client.test.js b/analytics/test/client.test.js index 912b566..e438971 100644 --- a/analytics/test/client.test.js +++ b/analytics/test/client.test.js @@ -269,7 +269,7 @@ test('w.js: Banner-Aufbau (Dialog, Texte, gleiche Buttons, Akzent nur Annehmen)' assert.match(css, /button\{[^}]*background:#0a5a8a[^}]*color:#fff/); assert.match(css, /button\{[^}]*[; ]width:150px/); assert.equal((css.match(/(? 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-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,") 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; } + +/* 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; } +.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; } +.an-axis { fill: var(--an-muted); font-size: 13px; } +.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; } + +/* ------------------------------------------- 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; } + .an-tabs { gap: 20px; margin: 14px 2px; } + .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-axis { font-size: 17px; } + .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; } +} diff --git a/public/analyse.js b/public/analyse.js index b18d7ca..e65dfd1 100644 --- a/public/analyse.js +++ b/public/analyse.js @@ -1,16 +1,22 @@ /* Analyse-Ansicht im Kundenbereich (ES-Modul, wird von app.js erst beim Oeffnen geladen). - initAnalyse(rootEl, { projectId }): je nach Zustand der Analyse Einschalten (off), Hinweis (disabled/blocked) oder Auswertung (active). - Reine Hilfsfunktionen und der gemeinsame Zustand: analyse-view.js. Styles: funktionen.css. + 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, chartModel, barRows, countryName, flagEmoji, refLabel, DEVICE_LABELS, CONTACT_LABELS } from './analyse-view.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' 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.' +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 = `
${esc(err.message)}
` } export async function initAnalyse(root, { projectId } = {}) { if (!root) return @@ -18,32 +24,36 @@ export async function initAnalyse(root, { projectId } = {}) { const { projects = [] } = await request('/api/funktionen') const usable = projects.filter((p) => !analyseOf(p).comingSoon) if (!usable.length) { - root.innerHTML = 'Die Analyse ist für deine Websites noch nicht freigeschaltet.
${esc(err.message)}
` }) - }) - const stage = root.querySelector('#an-stage') const a = analyseOf(project) - const reload = () => initAnalyse(root).catch((err) => { root.innerHTML = `${esc(err.message)}
` }) - if (a.state === 'active') renderAnalyseStats(stage, project, reload) - else if (a.state === 'disabled') stage.innerHTML = 'Für diese Website ist die Analyse im Moment gesperrt. , dann klären wir das.
Die Daten konnten gerade nicht geladen werden.
${esc(a.reasonText || 'Für diese Website gerade nicht verfügbar.')}${a.reason === 'kein_abo' ? ' ' : ''}
Für diese Website ist die Analyse im Moment gesperrt. , dann klären wir das.
Die Daten konnten gerade nicht geladen werden.
${esc(a.reasonText || 'Für diese Website gerade nicht verfügbar.')}${a.reason === 'kein_abo' ? ' ' : ''}
Du siehst, wie oft deine Website aufgerufen wird, woher die Besucher kommen und was sie sich ansehen.
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.
@@ -112,76 +122,194 @@ export function renderAnalyseActivation(stage, project, reload) { }) } -function lineChart(series) { - const m = chartModel(series) - if (!m.points.length) return 'Noch keine Daten.
' - 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 ` - ` -} - -function barList(title, items, labelOf, { html = false } = {}) { - const rows = barRows(items, labelOf) - return ` -Noch keine Daten.
'} -Lade…
Länderdaten: IP-Geolocation by DB-IP
` - 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 = `${esc(err.status === 503 ? 'Auswertung gerade nicht erreichbar' : err.message)}
` - return +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 } - if (!stage.isConnected || myRequest !== statsRequestId) return - const t = stats.totals || {} - const rate = Math.round((Number(t.consentRate) || 0) * 100) - const kpi = (label, value) => `${esc(KPI_NOTE)}
` - renderAnalyseHeat(stage.querySelector('#an-heat'), pid, stats.pages || []) + 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 }) + 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}`) + } + async function loadStats(range) { + const stats = await request(`/api/funktionen/analyse/stats?projectId=${encodeURIComponent(pid)}&range=${range}`) + state.stats = stats + state.range = range + state.stamp = CLOCK.format(new Date()) + return stats + } + + 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() + try { + await loadStats(range) + } catch (err) { + if (!current(mine)) return + stage.innerHTML = errorHtml(err.status === 503 ? 'Auswertung gerade nicht erreichbar' : err.message) + return + } + if (!current(mine)) return + 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 = 'Lade…
' + try { + await loadStats(30) + } catch (err) { + if (!current(mine)) return + stage.innerHTML = errorHtml(err.status === 503 ? 'Auswertung gerade nicht erreichbar' : err.message) + return + } + if (!current(mine)) return + 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. @@ -264,7 +392,7 @@ async function loadHeatDevice(fig, projectId, vp, path) { 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) + if (run === fig.heatRun && fig.isConnected && fig.dataset.state === 'loading') showHeatFallback(fig, projectId, vp, path) }, HEAT_TIMEOUT_MS) } @@ -286,7 +414,7 @@ async function showHeatFallback(fig, projectId, vp, path) { function renderAnalyseHeat(box, projectId, pages) { if (!box) return if (!pages.length) { - box.innerHTML = 'Sobald Besucher zugestimmt haben und Seiten aufgerufen wurden, kannst du hier sehen, wo geklickt und gescrollt wird.
' + box.innerHTML = 'Sobald Besucher zugestimmt haben und Seiten aufgerufen wurden, kannst du hier sehen, wo geklickt und gescrollt wird.
' return } if (!heatListening) { @@ -295,13 +423,11 @@ function renderAnalyseHeat(box, projectId, pages) { } const big = heatSel.big === 't' ? 't' : 'd' box.innerHTML = ` -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.
-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.
- ` + ` const actions = box.querySelector('#an-off-actions') const error = box.querySelector('#an-off-error') box.querySelector('#an-off-ask').addEventListener('click', () => { diff --git a/public/dashboard.html b/public/dashboard.html index 6b48c97..e697b2e 100644 --- a/public/dashboard.html +++ b/public/dashboard.html @@ -22,6 +22,7 @@ + @@ -177,7 +178,7 @@Lade…