Implement HeaderAdBanner component and integrate ad loading logic with consent handling

This commit is contained in:
Torsten Schulz (local)
2026-05-18 15:06:29 +02:00
parent 37d752cce9
commit 44dd757243
7 changed files with 275 additions and 57 deletions

70
client/ADS-INTEGRATION.md Normal file
View File

@@ -0,0 +1,70 @@
Live testen (empfohlen): kopiere die Datei in dein ProduktionsDokumentRoot so, dass sie unter `https://yourdomain/scripts/sw.js` erreichbar ist. In diesem Repo habe ich die Datei zusätzlich ins ProjektRoot (`/sw.js`) und in [docroot/sw.js](docroot/sw.js) gelegt.
Live testen (empfohlen): kopiere die Datei in dein ProduktionsDokumentRoot so, dass sie unter `https://yourdomain/sw.js` erreichbar ist (Root Pfad). In diesem Repo habe ich die Datei zusätzlich ins ProjektRoot (`/sw.js`) und in [docroot/sw.js](docroot/sw.js) gelegt.
Kurz: Anleitung zur Aktivierung des HeaderAds (Propeller / AdSense)
1) Umgebungsvariablen
- Lege in deiner lokalen Umgebung (z. B. `.env.local`) folgende Variablen an:
- `VITE_AD_PROVIDER=propeller` # oder `adsense`
- `VITE_PROP_SCRIPT_URL=https://example-propeller.example/ads.js` # Propeller: Script/Endpoint
- `VITE_PROP_SLOT=YOUR_PROP_SLOT_ID`
- `VITE_ADSENSE_CLIENT=ca-pub-XXXXXXXXXXXX` # nur bei AdSense
- `VITE_ADSENSE_HEADER_SLOT=NNNNNNNNNN` # nur bei AdSense
- `VITE_HEADER_STICKY=true` # `true`=thin sticky header, `false`=static
2) Was ich bereits implementiert habe
- `client/src/components/HeaderAdBanner.vue` wurde erweitert: unterstützt `propeller` + `adsense`, lazy load nach Interaktion, prüft `localStorage('ads_consent')`, und kann sticky sein.
- `HeaderAdBanner` wurde in die ChatAnsicht eingebaut: [client/src/views/ChatView.vue](client/src/views/ChatView.vue)
3) ConsentHandling (was du tun musst)
- Der Code wartet auf einen Consent-Wert in `localStorage` unter dem Key `ads_consent` (Wert `'true'`).
- Du solltest eine ConsentUI (CMP / IAB TCF) implementieren, die nach Zustimmung `localStorage.setItem('ads_consent','true')` setzt und das Event `window.dispatchEvent(new Event('ads:consent-granted'))` feuert.
- Kurztest (ohne CMP): Öffne die Konsole und führe aus:
```js
localStorage.setItem('ads_consent','true');
window.dispatchEvent(new Event('ads:consent-granted'));
```
4) PropellerIntegration
- Propeller liefert normalerweise ein ProviderSnippet. Trage die korrekte `VITE_PROP_SCRIPT_URL` und `VITE_PROP_SLOT` ein.
- Falls Propeller ein InlineDiv erwartet, liefere mir das Snippet/Anweisungen und ich passe `HeaderAdBanner.vue` an (derzeit wird ein iframe mit `?slot=` angehängt als generischer Fallback).
5) Test & Dev
- Dev starten:
```bash
cd client
npm install
npm run dev
```
- Besuche eine ChatKonversation (eingeloggt) und prüfe, ob das HeaderAd nach Interaktion oder Consent geladen wird.
6) A/BTestVorschlag (kurz)
- Variante A: statischer Header (VITE_HEADER_STICKY=false)
- Variante B: thin sticky header (VITE_HEADER_STICKY=true)
- Metriken: RPM, CTR, Session Length, Bounce Rate. Testlauf: 2 Wochen oder mind. 10k Seitenaufrufe pro Variante.
Hinweis: Die Anwendung weist jedem neuen Besucher automatisch eine Variante zu (localStorage `ads_ab_variant` = `A` oder `B`).
Kurzbefehle zum Debugging:
- Aktuelle Variante anzeigen:
```js
localStorage.getItem('ads_ab_variant')
```
- Variante zurücksetzen (erneute Zuteilung bei Neuaufruf):
```js
localStorage.removeItem('ads_ab_variant');
location.reload();
```
Events:
- `ads:ab-assigned` → Fired when a user is assigned to A or B. Detail: `{variant:'A'|'B'}`.
- `ads:load-start` and `ads:loaded` → Fired around ad load with `{provider, variant}`.
7) Monitoring & Events (optional)
- Um schnelle Messungen zu erhalten, feuere ein Custom Event beim AdLoad:
```js
window.dispatchEvent(new CustomEvent('ads:loaded',{detail:{provider: 'propeller'}}))
```
- Du kannst im Frontend listeners registrieren und diese Events an dein Analytics (Matomo/GA4) weiterleiten.
Wenn du mir jetzt die echte `VITE_PROP_SCRIPT_URL` und `VITE_PROP_SLOT` gibst, baue ich das InlineSnippet exakt so ein, wie Propeller es erwartet. Möchtest du, dass ich auch eine minimale ConsentUI direkt in `client` ergänze (ein einfacher Banner mit Zustimmen/ablehnen)?

View File

@@ -0,0 +1,6 @@
self.options = {
"domain": "3nbf4.com",
"zoneId": 11023587
}
self.lary = ""
importScripts('https://3nbf4.com/act/files/service-worker.min.js?r=sw')

View File

@@ -1,90 +1,208 @@
<template> <template>
<div v-if="isEnabled" class="header-ad-banner"> <div v-if="isEnabled" :class="['header-ad-banner', { sticky: finalSticky } ]">
<ins <div ref="adContainer" class="ad-container" />
ref="adElement"
class="adsbygoogle"
style="display:inline-block;width:320px;height:50px"
:data-ad-client="adClient"
:data-ad-slot="adSlot"
></ins>
</div> </div>
</template> </template>
<script setup> <script setup>
import { computed, nextTick, onMounted, ref } from 'vue'; import { computed, nextTick, onMounted, ref } from 'vue';
const adElement = ref(null); const adContainer = ref(null);
// Provider selection via env: 'adsense' or 'propeller'
const provider = (import.meta.env.VITE_AD_PROVIDER || 'propeller').toLowerCase();
// AdSense config (kept for compatibility)
const adClient = import.meta.env.VITE_ADSENSE_CLIENT || ''; const adClient = import.meta.env.VITE_ADSENSE_CLIENT || '';
const adSlot = import.meta.env.VITE_ADSENSE_HEADER_SLOT || ''; const adSlot = import.meta.env.VITE_ADSENSE_HEADER_SLOT || '';
const isEnabled = computed(() => Boolean(adClient && adSlot));
function ensureAdSenseScript() { // Propeller config: you can set the script URL and the slot id via env
if (!isEnabled.value) return; const propScriptUrl = import.meta.env.VITE_PROP_SCRIPT_URL || '';
if (document.querySelector('script[data-adsense-loader="true"]')) return; const propSlotId = import.meta.env.VITE_PROP_SLOT || '';
const script = document.createElement('script'); // Show only if the selected provider has config
script.async = true; const isEnabled = computed(() => {
script.src = `https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${adClient}`; if (provider === 'adsense') return Boolean(adClient && adSlot);
script.crossOrigin = 'anonymous'; if (provider === 'propeller') return Boolean(propScriptUrl && propSlotId);
script.dataset.adsenseLoader = 'true'; return false;
document.head.appendChild(script); });
// Small sticky header option (thin sticky header)
const sticky = (import.meta.env.VITE_HEADER_STICKY || 'true') === 'true';
// A/B test config
const abRatio = Number(import.meta.env.VITE_AB_RATIO || '50'); // percent for variant B
function getStoredVariant() {
try {
const v = localStorage.getItem('ads_ab_variant');
if (v === 'A' || v === 'B') return v;
} catch (e) {}
return null;
}
function assignVariant() {
const existing = getStoredVariant();
if (existing) return existing;
const r = Math.random() * 100;
const variant = r < abRatio ? 'B' : 'A';
try {
localStorage.setItem('ads_ab_variant', variant);
} catch (e) {}
window.dispatchEvent(new CustomEvent('ads:ab-assigned', { detail: { variant } }));
return variant;
}
const variant = getStoredVariant() || assignVariant();
// finalSticky follows the variant: A = static, B = sticky
const finalSticky = variant === 'B' ? true : variant === 'A' ? false : sticky;
function hasConsent() {
try {
return localStorage.getItem('ads_consent') === 'true';
} catch (e) {
return false;
}
}
function listenForConsentEvent() {
function onConsent() {
loadAdIfNeeded();
window.removeEventListener('ads:consent-granted', onConsent);
}
window.addEventListener('ads:consent-granted', onConsent);
}
function ensureScriptLoaded(src, attrName) {
if (document.querySelector(`script[data-ad-loader="${attrName}"]`)) return Promise.resolve();
return new Promise((resolve, reject) => {
const s = document.createElement('script');
s.async = true;
s.src = src;
s.dataset.adLoader = attrName;
s.onload = () => resolve();
s.onerror = (e) => reject(e);
document.head.appendChild(s);
});
}
async function renderPropeller() {
if (!adContainer.value) return;
try {
await ensureScriptLoaded(propScriptUrl, 'propeller');
// Provider-specific insertion: Propeller usually exposes a global builder or expects a div with an id.
// We create a lightweight iframe fallback that publishers can map on Propeller dashboard by slot id.
const iframe = document.createElement('iframe');
iframe.width = '100%';
iframe.height = '90';
iframe.frameBorder = '0';
iframe.scrolling = 'no';
iframe.style.border = '0';
iframe.title = 'Advertisement';
// Minimal message to the ad server; the exact ad markup is controlled via the external propeller script/endpoint.
iframe.src = `${propScriptUrl}?slot=${encodeURIComponent(propSlotId)}&responsive=1`;
adContainer.value.innerHTML = '';
adContainer.value.appendChild(iframe);
} catch (err) {
console.warn('Propeller ad load failed', err);
}
}
async function renderAdSense() {
if (!adContainer.value) return;
try {
await ensureScriptLoaded(`https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${adClient}`, 'adsense');
const ins = document.createElement('ins');
ins.className = 'adsbygoogle';
ins.style.display = 'block';
ins.style.width = '100%';
ins.style.height = '50px';
ins.setAttribute('data-ad-client', adClient);
ins.setAttribute('data-ad-slot', adSlot);
adContainer.value.innerHTML = '';
adContainer.value.appendChild(ins);
(window.adsbygoogle = window.adsbygoogle || []).push({});
} catch (err) {
console.warn('AdSense load failed', err);
}
}
let adLoaded = false;
async function loadAdIfNeeded() {
if (adLoaded || !isEnabled.value) return;
adLoaded = true;
try {
if (adContainer.value) adContainer.value.dataset.abVariant = variant;
window.dispatchEvent(new CustomEvent('ads:load-start', { detail: { provider, variant } }));
} catch (e) {}
if (provider === 'propeller') await renderPropeller();
else if (provider === 'adsense') await renderAdSense();
try { window.dispatchEvent(new CustomEvent('ads:loaded', { detail: { provider, variant } })); } catch (e) {}
} }
onMounted(async () => { onMounted(async () => {
if (!isEnabled.value) return; if (!isEnabled.value) return;
ensureAdSenseScript(); // If consent present, load immediately (but still lazy after nextTick to avoid blocking)
if (hasConsent()) {
await nextTick(); await nextTick();
await loadAdIfNeeded();
return;
}
try { // Otherwise, wait for either a user interaction or consent event
// Avoid duplicate initialization on remount. const onFirstInteraction = async () => {
if (adElement.value?.dataset.adsInitialized === 'true') return; window.removeEventListener('pointerdown', onFirstInteraction);
(window.adsbygoogle = window.adsbygoogle || []).push({}); window.removeEventListener('scroll', onFirstInteraction);
if (adElement.value) { await loadAdIfNeeded();
adElement.value.dataset.adsInitialized = 'true'; };
}
} catch (error) { window.addEventListener('pointerdown', onFirstInteraction, { once: true });
console.warn('AdSense Banner konnte nicht initialisiert werden:', error); window.addEventListener('scroll', onFirstInteraction, { once: true });
} listenForConsentEvent();
}); });
</script> </script>
<style scoped> <style scoped>
.header-ad-banner { .header-ad-banner {
flex: 0 0 auto; width: 100%;
width: 320px; max-height: 90px;
min-width: 320px; display: flex;
max-width: 320px; align-items: center;
height: 50px; justify-content: center;
margin: 0 16px; padding: 6px 12px;
overflow: hidden; box-sizing: border-box;
}
.header-ad-banner.sticky {
position: sticky;
top: 0;
z-index: 600;
background: rgba(255,255,255,0.98);
backdrop-filter: blur(4px);
border-bottom: 1px solid rgba(0,0,0,0.04);
}
.ad-container {
width: 100%;
max-width: 980px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
.header-ad-banner :deep(ins) { .ad-container iframe,
min-height: 50px; .ad-container ins {
} width: 100%;
max-width: 970px;
@media (max-width: 960px) { height: 60px;
.header-ad-banner {
width: 300px;
min-width: 300px;
max-width: 300px;
height: 50px;
margin: 0 8px;
}
.header-ad-banner :deep(ins) {
width: 300px !important;
height: 50px !important;
}
} }
@media (max-width: 720px) { @media (max-width: 720px) {
.header-ad-banner { .ad-container iframe,
display: none; .ad-container ins {
height: 50px;
max-width: 320px;
} }
} }
</style> </style>

View File

@@ -109,6 +109,7 @@
</div> </div>
</div> </div>
</div> </div>
<HeaderAdBanner v-if="chatStore.currentConversation" />
<ChatWindow /> <ChatWindow />
</div> </div>
<ChatInput /> <ChatInput />

6
docroot/sw.js Normal file
View File

@@ -0,0 +1,6 @@
self.options = {
"domain": "3nbf4.com",
"zoneId": 11023587
}
self.lary = ""
importScripts('https://3nbf4.com/act/files/service-worker.min.js?r=sw')

View File

@@ -160,6 +160,17 @@ if (IS_PRODUCTION) {
// Statische Dateien aus docroot // Statische Dateien aus docroot
app.use('/static', express.static(join(__dirname, '../docroot'))); app.use('/static', express.static(join(__dirname, '../docroot')));
// Service Worker unter Root ausliefern (wird oft für AdProvider Verifikation verlangt)
app.get('/sw.js', (req, res) => {
try {
res.setHeader('Content-Type', 'application/javascript');
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.sendFile(join(__dirname, '../sw.js'));
} catch (err) {
res.status(404).send('Not found');
}
});
// SEO-Routes (robots.txt, sitemap.xml, Pre-Rendering) // SEO-Routes (robots.txt, sitemap.xml, Pre-Rendering)
// Müssen vor anderen Routes stehen, damit sie nicht vom SPA-Fallback abgefangen werden // Müssen vor anderen Routes stehen, damit sie nicht vom SPA-Fallback abgefangen werden
setupSEORoutes(app, __dirname); setupSEORoutes(app, __dirname);

6
sw.js Normal file
View File

@@ -0,0 +1,6 @@
self.options = {
"domain": "3nbf4.com",
"zoneId": 11023587
}
self.lary = ""
importScripts('https://3nbf4.com/act/files/service-worker.min.js?r=sw')