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:
1 parent
ff7fe35c06
commit
0a113c2983
14 files changed
+1658
-91
No files matched your search
+149
-23
@@ -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
|
||||
|
||||
Reference in new issue
Block a user