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
@@ -188,6 +188,14 @@ test('Heatmap-Link: Prüfung, URL, Token', async () => {
|
||||
assert.equal(v.siteKey, key)
|
||||
assert.equal(v.path, '/preise')
|
||||
assert.equal(v.vp, 'm')
|
||||
assert.equal(url.searchParams.get('__wk_embed'), null, 'ohne embed kein Einbettungsparameter')
|
||||
// embed: fuer den Geraeterahmen im Portal
|
||||
const em = await link({ path: '/preise', vp: 'd', embed: true })
|
||||
assert.equal(em.status, 200)
|
||||
const eu = new URL(em.body.url)
|
||||
assert.equal(eu.searchParams.get('__wk_embed'), '1')
|
||||
assert.equal(verifyHeatToken(eu.searchParams.get('__wk_heat'), config.analyse.heatSecret, Date.now()).vp, 'd')
|
||||
assert.equal(new URL((await link({ path: '/', vp: 'd', embed: 'ja' })).body.url).searchParams.get('__wk_embed'), null, 'nur true zaehlt')
|
||||
// ohne Live-Domain: Subdomain
|
||||
await post(`${A}/activate`, { projectId: 'p3', consent: consent() }, { cookie })
|
||||
const sub = await post(`${A}/heatmap-link`, { projectId: 'p3', path: '/', vp: 't' }, { cookie })
|
||||
|
||||
@@ -5,10 +5,12 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
// public/funktionen.js wirklich laden (der absolute Import /analyse-view.js wird auf die Datei umgeleitet)
|
||||
// public/funktionen.js wirklich laden (die absoluten Importe /analyse-view.js und /geraete.js werden auf die Dateien umgeleitet)
|
||||
const dir = mkdtempSync(join(tmpdir(), 'fn-tiles-'))
|
||||
const viewUrl = pathToFileURL(new URL('../public/analyse-view.js', import.meta.url).pathname).href
|
||||
const src = readFileSync(new URL('../public/funktionen.js', import.meta.url), 'utf8').replace("'/analyse-view.js'", `'${viewUrl}'`)
|
||||
const fileUrl = (name) => pathToFileURL(new URL(`../public/${name}`, import.meta.url).pathname).href
|
||||
const src = readFileSync(new URL('../public/funktionen.js', import.meta.url), 'utf8')
|
||||
.replace("'/analyse-view.js'", `'${fileUrl('analyse-view.js')}'`)
|
||||
.replace("'/geraete.js'", `'${fileUrl('geraete.js')}'`)
|
||||
const file = join(dir, 'funktionen.mjs')
|
||||
writeFileSync(file, src)
|
||||
const { initFunktionen, initAnalyse, renderAnalyseOff } = await import(pathToFileURL(file).href)
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { HEAT_DEVICES, deviceHtml, heatCaption, addressOf, deviceClock } from '../public/geraete.js'
|
||||
import { vpClass } from '../analytics/client/core.js'
|
||||
|
||||
const SRC = 'https://baeckerei.de/preise?__wk_heat=abc.def&__wk_embed=1'
|
||||
|
||||
test('Geraete: Website-Breite liegt in der Geraeteklasse der Heatmap (m/t/d wie vpClass)', () => {
|
||||
assert.deepEqual(Object.keys(HEAT_DEVICES), ['d', 't', 'm'])
|
||||
for (const [vp, dev] of Object.entries(HEAT_DEVICES)) {
|
||||
assert.equal(vpClass(dev.site.width), vp, vp)
|
||||
assert.equal(dev.site.width, dev.canvas[0], 'volle Bildschirmbreite')
|
||||
assert.equal(dev.site.top + dev.site.height, dev.canvas[1], 'Website reicht bis zum unteren Rand')
|
||||
}
|
||||
assert.deepEqual(Object.fromEntries(Object.entries(HEAT_DEVICES).map(([k, d]) => [k, d.label])), { d: 'Computer', t: 'Tablet', m: 'Handy' })
|
||||
})
|
||||
|
||||
test('deviceHtml: Rahmen je Geraet, iframe in echter Breite mit enger Sandbox', () => {
|
||||
const frames = { d: 'dm-macbook', t: 'dm-ipad', m: 'dm-iphone' }
|
||||
for (const [vp, cls] of Object.entries(frames)) {
|
||||
const html = deviceHtml(vp, { src: SRC, title: 'Heatmap <Handy>' })
|
||||
const dev = HEAT_DEVICES[vp]
|
||||
assert.match(html, new RegExp(`^<div class="${cls}"`), vp)
|
||||
assert.match(html, new RegExp(`class="dm-canvas" data-design-width="${dev.canvas[0]}" data-design-height="${dev.canvas[1]}"`))
|
||||
assert.match(html, new RegExp(`class="hs-site" style="top:${dev.site.top}px;width:${dev.site.width}px;height:${dev.site.height}px"`))
|
||||
assert.match(html, /<iframe src="https:\/\/baeckerei\.de\/preise\?__wk_heat=abc\.def&__wk_embed=1"/)
|
||||
assert.match(html, /title="Heatmap <Handy>"/)
|
||||
const sandbox = /sandbox="([^"]*)"/.exec(html)[1]
|
||||
assert.equal(sandbox, 'allow-scripts allow-same-origin', 'keine Formulare, Pop-ups oder Navigation des Portals')
|
||||
assert.match(html, /referrerpolicy="no-referrer"/)
|
||||
assert.equal((html.match(/<iframe/g) || []).length, 1)
|
||||
}
|
||||
assert.match(deviceHtml('d', { src: SRC }), /<span>baeckerei\.de\/preise<\/span>/, 'Adresszeile ohne Parameter')
|
||||
assert.match(deviceHtml('m', { src: SRC }), /data-statusbar="dark"/, 'dunkle Statusleiste auf weisser Website')
|
||||
assert.equal(deviceHtml('x', { src: SRC }), '')
|
||||
assert.doesNotMatch(deviceHtml('d', { src: '"><script>alert(1)</script>' }), /<script>/)
|
||||
})
|
||||
|
||||
test('heatCaption', () => {
|
||||
const fmt = (n) => new Intl.NumberFormat('de-DE').format(n)
|
||||
assert.equal(heatCaption('loading'), 'Lädt …')
|
||||
assert.equal(heatCaption('ready', 0), 'Noch keine Klicks auf dieser Seite (letzte 30 Tage)')
|
||||
assert.equal(heatCaption('ready', 1), '1 Klick · letzte 30 Tage')
|
||||
assert.equal(heatCaption('ready', 1234, fmt), '1.234 Klicks · letzte 30 Tage')
|
||||
assert.equal(heatCaption('error'), 'Die Heatmap konnte nicht geladen werden.')
|
||||
assert.equal(heatCaption('timeout'), 'Deine Website lässt sich hier nicht anzeigen.')
|
||||
})
|
||||
|
||||
test('addressOf und deviceClock', () => {
|
||||
assert.equal(addressOf('https://baeckerei.de/?__wk_heat=x'), 'baeckerei.de')
|
||||
assert.equal(addressOf('https://cafe.project.webklar.com/karte/?a=1'), 'cafe.project.webklar.com/karte/')
|
||||
assert.equal(addressOf('kaputt'), '')
|
||||
assert.deepEqual(deviceClock(new Date('2026-10-02T10:00:00Z')), { mac: 'Fr. 2. Okt. 9:41', ipad: '9:41 Fr. 2. Okt.' })
|
||||
})
|
||||
Reference in new issue
Block a user