Implement HeaderAdBanner component and integrate ad loading logic with consent handling
This commit is contained in:
70
client/ADS-INTEGRATION.md
Normal file
70
client/ADS-INTEGRATION.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
Live testen (empfohlen): kopiere die Datei in dein Produktions‑Dokument‑Root so, dass sie unter `https://yourdomain/scripts/sw.js` erreichbar ist. In diesem Repo habe ich die Datei zusätzlich ins Projekt‑Root (`/sw.js`) und in [docroot/sw.js](docroot/sw.js) gelegt.
|
||||||
|
|
||||||
|
Live testen (empfohlen): kopiere die Datei in dein Produktions‑Dokument‑Root so, dass sie unter `https://yourdomain/sw.js` erreichbar ist (Root Pfad). In diesem Repo habe ich die Datei zusätzlich ins Projekt‑Root (`/sw.js`) und in [docroot/sw.js](docroot/sw.js) gelegt.
|
||||||
|
Kurz: Anleitung zur Aktivierung des Header‑Ads (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 Chat‑Ansicht eingebaut: [client/src/views/ChatView.vue](client/src/views/ChatView.vue)
|
||||||
|
|
||||||
|
3) Consent‑Handling (was du tun musst)
|
||||||
|
- Der Code wartet auf einen Consent-Wert in `localStorage` unter dem Key `ads_consent` (Wert `'true'`).
|
||||||
|
- Du solltest eine Consent‑UI (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) Propeller‑Integration
|
||||||
|
- Propeller liefert normalerweise ein Provider‑Snippet. Trage die korrekte `VITE_PROP_SCRIPT_URL` und `VITE_PROP_SLOT` ein.
|
||||||
|
- Falls Propeller ein Inline‑Div 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 Chat‑Konversation (eingeloggt) und prüfe, ob das Header‑Ad nach Interaktion oder Consent geladen wird.
|
||||||
|
|
||||||
|
6) A/B‑Test‑Vorschlag (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 Ad‑Load:
|
||||||
|
```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 Inline‑Snippet exakt so ein, wie Propeller es erwartet. Möchtest du, dass ich auch eine minimale Consent‑UI direkt in `client` ergänze (ein einfacher Banner mit Zustimmen/ablehnen)?
|
||||||
6
client/public/scripts/sw.js
Normal file
6
client/public/scripts/sw.js
Normal 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')
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
6
docroot/sw.js
Normal 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')
|
||||||
@@ -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 Ad‑Provider 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);
|
||||||
|
|||||||
Reference in New Issue
Block a user