Heatmap in Geraeterahmen (Computer/Tablet + Handy) und Keks statt "Datenschutz-Einstellungen"

Heatmap im Portal (Funktionen -> Analyse): die echte Website laeuft jetzt in realistischen
Geraeterahmen, gross MacBook oder iPad (Umschalter oben), daneben das iPhone, jeweils in echter
Breite (1512 / 820 / 402 px), verkleinert und im Geraet scrollbar, mit den Klicks der
passenden Geraeteklasse als Overlay. MacBook und iPhone stammen aus den Rahmen der neuen
Website (device-mockups), das iPad ist im gleichen Stil nachgebaut (public/geraete.css/.js).

- heatmap-link: embed: true haengt __wk_embed=1 an; w.js reicht das als data-embed an h.js.
- h.js eingebettet (nur in einem Rahmen): keine Leiste, Links und Formulare gesperrt (kein
  Seitenwechsel, also keine Zaehlung und kein Banner), Scrollleisten aus, Klickzahl per
  postMessage nur an die Herkunft von h.js (project.webklar.com).
- iframe mit Sandbox allow-scripts allow-same-origin (keine Formulare, Pop-ups, keine
  Navigation des Portals); Laden erst, wenn die Heatmap in Sicht kommt. Laesst sich eine
  Website nicht einbetten, kommt nach 20 s ein Hinweis mit "In neuem Tab oeffnen" (alter Weg).

Banner-Skript: der Einstellungsknopf nach der Wahl ist jetzt ein angeknabberter Keks unten
rechts (Inline-SVG, 44 px). Er dreht sich langsam, haelt beim Darueberfahren/Fokus an und
zeigt dann "Datenschutz-Einstellungen" (auch sein Name fuer Screenreader, passend zum
Bannertext); bei reduzierter Bewegung dreht er sich nicht.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-02 15:09:17 +00:00
1 parent ff7fe35c06
commit 0a113c2983
14 files changed
+1658 -91

No files matched your search

+149 -23
View File
@@ -4,6 +4,7 @@
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'
const PRICE = '30 €'
const LEISTUNGSBESCHREIBUNG = '/vertragsunterlagen/funktionen-leistungsbeschreibung.html'
@@ -726,40 +727,165 @@ async function renderAnalyseStats(stage, project, reload) {
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 = `
<h3>Heatmap</h3>
<p>Sieh auf deiner Website, wo Besucher klicken und wie weit sie scrollen.</p>
<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>
<label>Gerät<select id="an-heat-vp">${Object.entries(DEVICE_LABELS).map(([k, v]) => `<option value="${k}"${k === 'm' ? ' selected' : ''}>${v}</option>`).join('')}</select></label>
</div>
<p class="error hidden" id="an-heat-error" role="alert"></p>
<div class="abo-actions"><button type="button" class="abo-btn" id="an-heat-go">Auf meiner Website ansehen</button></div>
<div class="abo-actions" id="an-heat-link" aria-live="polite"></div>`
const go = box.querySelector('#an-heat-go')
const error = box.querySelector('#an-heat-error')
go.addEventListener('click', async () => {
go.disabled = true
error.classList.add('hidden')
box.querySelector('#an-heat-link').innerHTML = ''
try {
const { url } = await request('/api/funktionen/analyse/heatmap-link', { method: 'POST', body: { projectId, path: box.querySelector('#an-heat-page').value || '/', vp: box.querySelector('#an-heat-vp').value } })
// Kein window.open nach await (Safari/iOS blockiert das): sichtbarer Link, den der Kunde selbst antippt
const link = box.querySelector('#an-heat-link')
link.innerHTML = safeUrl(url) ? `<a class="abo-btn" href="${esc(url)}" target="_blank" rel="noopener">Heatmap öffnen</a>` : ''
} catch (err) {
error.textContent = err.status === 503 ? 'Auswertung gerade nicht erreichbar' : err.message
error.classList.remove('hidden')
} finally {
go.disabled = false
}
<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