Änderung der werbung
All checks were successful
Deploy SingleChat / deploy (push) Successful in 26s

This commit is contained in:
Torsten Schulz (local)
2026-06-18 08:33:42 +02:00
parent 0c6ab5727f
commit 62f5914b04
7 changed files with 274 additions and 568 deletions

View File

@@ -1,78 +1,31 @@
Aktuell habe ich das InPage Push Snippet, das du gepostet hast, als Default eingebaut:
```html
<script>(function(s){s.dataset.zone='11023637',s.src='https://nap5k.com/tag.min.js'})([document.documentElement, document.body].filter(Boolean).pop().appendChild(document.createElement('script')))</script>
Das ist jetzt die StandardKonfiguration in `HeaderAdBanner.vue` wenn `VITE_AD_PROVIDER=propeller` ist. Du kannst die Werte via Env überschreiben:
- `VITE_PROP_SCRIPT_URL` (default `https://nap5k.com/tag.min.js`)
- `VITE_PROP_SLOT` (default `11023637`)
# Header-Ad Integration
Wenn du später ein anderes Format/Zone anlegen willst, gib mir die neue ZoneID oder das komplette Snippet, dann passe ich es an.
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.
Aktuell nutzt `YpChat` im Header eine direkte Adsterra-Integration ueber `HeaderAdBanner.vue`.
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)
Verwendete Placements:
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
- Mobile: `320x50`
- Key: `fb9b5e7f817d40d72943dae0c54eb769`
- Desktop: `468x60`
- Key: `2b658317c1e28b4b4f234d26c8fca28d`
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)
Die Komponente waehlt automatisch anhand der Viewport-Breite:
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'));
```
- bis `720px`: `320x50`
- ab `721px`: `468x60`
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).
Einbauorte:
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.
- [client/src/components/HeaderAdBanner.vue](/mnt/share/torsten/Programs/SingleChat/client/src/components/HeaderAdBanner.vue)
- [client/src/views/ChatView.vue](/mnt/share/torsten/Programs/SingleChat/client/src/views/ChatView.vue)
- weitere SEO-/Info-Seiten mit `HeaderAdBanner`
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.
Technik:
Hinweis: Die Anwendung weist jedem neuen Besucher automatisch eine Variante zu (localStorage `ads_ab_variant` = `A` oder `B`).
- Adsterra `IFRAME SYNC`
- Script-Quelle:
- `https://www.highperformanceformat.com/<key>/invoke.js`
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();
```
Aktuell gibt es keine zusaetzliche Consent-, Provider- oder Fallback-Logik mehr in dieser Komponente.
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)?
Wenn neue Banner-Formate kommen, muessen nur die Keys und Groessen in `HeaderAdBanner.vue` angepasst werden.

View File

@@ -1,372 +1,103 @@
<template>
<div v-if="isEnabled" :class="['header-ad-banner', { sticky: finalSticky } ]">
<div ref="adContainer" class="ad-container" />
<div class="header-ad-banner">
<div
ref="adContainer"
class="ad-container"
:class="{ 'is-mobile': activePlacement.width === 320 }"
></div>
</div>
</template>
<script setup>
import { computed, nextTick, onMounted, ref } from 'vue';
import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue';
const MOBILE_BREAKPOINT = 720;
const placements = {
mobile: {
key: 'fb9b5e7f817d40d72943dae0c54eb769',
width: 320,
height: 50
},
desktop: {
key: '2b658317c1e28b4b4f234d26c8fca28d',
width: 468,
height: 60
}
};
const adContainer = ref(null);
const activePlacement = ref(selectPlacement());
let resizeTimer = 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 adSlot = import.meta.env.VITE_ADSENSE_HEADER_SLOT || '';
// Propeller config: you can set the script URL and the slot id via env
// Defaults set to the in-page push tag you provided (can be overridden via env)
const propScriptUrl = import.meta.env.VITE_PROP_SCRIPT_URL || 'https://nap5k.com/tag.min.js';
const propSlotId = import.meta.env.VITE_PROP_SLOT || '11023637';
// Show only if the selected provider has config
const isEnabled = computed(() => {
if (provider === 'adsense') return Boolean(adClient && adSlot);
if (provider === 'propeller') return Boolean(propScriptUrl && propSlotId);
return false;
});
// Small sticky header option (thin sticky header)
const sticky = (import.meta.env.VITE_HEADER_STICKY || 'true') === 'true';
// Debug bypass: allow forcing ad load with ?ads_debug=1
const debugForceLoad = typeof window !== 'undefined' && new URLSearchParams(window.location.search).get('ads_debug') === '1';
// Auto load flag (env) - set to 'true' to load ads on mount without waiting for interaction
const autoLoad = (import.meta.env.VITE_PROP_AUTOLOAD || '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 selectPlacement() {
if (typeof window === 'undefined') {
return placements.desktop;
}
return window.innerWidth <= MOBILE_BREAKPOINT ? placements.mobile : placements.desktop;
}
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() {
function clearAdContainer() {
if (!adContainer.value) return;
try {
// Insert Propeller in-page push tag exactly as provided by PropellerAds.
// Avoid duplicate insertion if same script+zone already present.
if (document.querySelector(`script[src="${propScriptUrl}"][data-zone="${propSlotId}"]`)) {
return;
adContainer.value.innerHTML = '';
}
function buildInvokeUrl(key) {
return `https://www.highperformanceformat.com/${key}/invoke.js`;
}
function renderAd() {
if (!adContainer.value) return;
const placement = selectPlacement();
activePlacement.value = placement;
clearAdContainer();
window.atOptions = {
key: placement.key,
format: 'iframe',
height: placement.height,
width: placement.width,
params: {}
};
const script = document.createElement('script');
script.src = buildInvokeUrl(placement.key);
script.async = true;
script.onload = () => {
console.log('Adsterra script loaded:', placement.key, `${placement.width}x${placement.height}`);
};
script.onerror = (error) => {
console.warn('Adsterra script failed to load:', placement.key, error);
};
adContainer.value.appendChild(script);
}
function handleResize() {
window.clearTimeout(resizeTimer);
resizeTimer = window.setTimeout(() => {
const nextPlacement = selectPlacement();
if (nextPlacement.key !== activePlacement.value.key) {
renderAd();
}
// create placeholder first and insert into ad container
const placeholder = document.createElement('div');
placeholder.className = 'propeller-ad-placeholder';
adContainer.value.appendChild(placeholder);
const s = document.createElement('script');
s.dataset.zone = propSlotId;
s.src = propScriptUrl;
s.async = true;
s.onload = () => {
console.log('Propeller script loaded:', propScriptUrl, 'zone:', propSlotId);
};
s.onerror = (e) => {
console.warn('Propeller script failed to load:', propScriptUrl, e);
try { window.dispatchEvent(new CustomEvent('ads:load-failed', { detail: { provider: 'propeller', zone: propSlotId, error: String(e) } })); } catch {}
};
// Append the script directly into the placeholder so the provider injects into it
placeholder.appendChild(s);
// Observe the document for injected ad nodes and relocate them into our placeholder
const observeAndRelocate = () => {
const parent = document.body;
try {
const observer = new MutationObserver(mutations => {
for (const m of mutations) {
for (const node of Array.from(m.addedNodes)) {
try {
// Handle text nodes that may contain raw script/HTML
if (node.nodeType === Node.TEXT_NODE) {
const txt = (node.nodeValue || '').trim();
if (txt.includes('<script') || txt.length > 500) {
// remove large/raw script text nodes to avoid visible JS
node.parentNode && node.parentNode.removeChild(node);
}
continue;
}
if (!(node instanceof HTMLElement)) continue;
// If element contains script tags, extract and execute them safely
const innerScripts = Array.from(node.querySelectorAll('script'));
if (innerScripts.length) {
for (const sc of innerScripts) {
try {
if (sc.src) {
const s2 = document.createElement('script');
s2.src = sc.src;
s2.async = true;
document.head.appendChild(s2);
} else if (sc.textContent) {
const s2 = document.createElement('script');
s2.textContent = sc.textContent;
document.head.appendChild(s2);
}
} catch (e) {
console.warn('execute inner script failed', e);
}
// remove the inline script node from original element to avoid visible code
sc.parentNode && sc.parentNode.removeChild(sc);
}
}
// If the added node is a script element itself, execute safely via head insertion
if (node.tagName === 'SCRIPT') {
const src = node.getAttribute('src');
if (src) {
const s2 = document.createElement('script');
s2.src = src;
s2.async = true;
s2.onload = () => console.log('relocated script loaded', src);
s2.onerror = (e) => console.warn('relocated script failed', src, e);
document.head.appendChild(s2);
node.parentNode && node.parentNode.removeChild(node);
continue;
} else if (node.textContent) {
const s2 = document.createElement('script');
s2.textContent = node.textContent;
document.head.appendChild(s2);
node.parentNode && node.parentNode.removeChild(node);
continue;
}
}
// Detect iframe ad nodes or nodes with nap5k indicators
const iframe = node.tagName === 'IFRAME' ? node : node.querySelector && node.querySelector('iframe');
const src = iframe ? (iframe.getAttribute('src') || '') : '';
const isAdNode = src.includes('nap5k') || (node.dataset && node.dataset.zone === propSlotId) || /nap5k|propel|propeller|inpage|push/i.test((node.className || '') + ' ' + (node.id || ''));
if (isAdNode) {
try {
// If the detected node is a SCRIPT, execute it in head and do not append it to placeholder
if (node.tagName === 'SCRIPT') {
const src = node.getAttribute('src');
if (src) {
const s2 = document.createElement('script');
s2.src = src;
s2.async = true;
document.head.appendChild(s2);
} else if (node.textContent) {
const s2 = document.createElement('script');
s2.textContent = node.textContent;
document.head.appendChild(s2);
}
node.parentNode && node.parentNode.removeChild(node);
continue;
}
// move iframe/ad node into placeholder (prefer inline iframe if present)
placeholder.innerHTML = '';
if (iframe && iframe.parentNode) {
placeholder.appendChild(iframe);
} else if (node.tagName !== 'SCRIPT') {
placeholder.appendChild(node);
}
const moved = placeholder.firstElementChild;
if (moved) {
// Normalize size
moved.style.maxWidth = '100%';
moved.style.display = 'block';
// If provider inserted a fixed/absolute positioned element (overlays),
// force it to behave as inline block inside our header placeholder.
try {
const cs = getComputedStyle(moved);
if (cs.position === 'fixed' || cs.position === 'absolute') {
moved.style.setProperty('position', 'relative', 'important');
moved.style.setProperty('top', 'auto', 'important');
moved.style.setProperty('right', 'auto', 'important');
moved.style.setProperty('left', 'auto', 'important');
moved.style.setProperty('bottom', 'auto', 'important');
moved.style.setProperty('z-index', '1', 'important');
moved.style.setProperty('width', '100%', 'important');
moved.style.setProperty('max-width', '970px', 'important');
}
if (moved.tagName === 'IFRAME') {
moved.style.width = '100%';
moved.style.height = moved.style.height || '60px';
}
} catch (e) {
console.warn('style normalize failed', e);
}
}
observer.disconnect();
try { window.dispatchEvent(new CustomEvent('ads:relocated',{detail:{provider:'propeller',zone:propSlotId}})); } catch {}
return;
} catch (e) {
console.warn('Failed to relocate ad node', e);
}
}
} catch (e) {
console.warn('mutation handler error', e);
}
}
}
});
observer.observe(parent, { childList: true, subtree: true });
// Check existing iframes right away
const existing = Array.from(document.querySelectorAll('iframe')).find(f => (f.src || '').includes('nap5k'));
if (existing) {
placeholder.innerHTML = '';
placeholder.appendChild(existing);
observer.disconnect();
}
// Failsafe: stop observing after 6s
setTimeout(() => observer.disconnect(), 6000);
} catch (e) {
console.warn('observeAndRelocate error', e);
}
};
// Start observing shortly after script insertion to allow provider to run
setTimeout(observeAndRelocate, 100);
// Fallback: wenn nach ScriptLoad innerhalb 3s kein Ad im Placeholder erscheint,
// lade ein iframeBackup. Das erhöht die Chance, ein sichtbares Banner im Header zu zeigen.
setTimeout(() => {
try {
const ph = adContainer.value.querySelector('.propeller-ad-placeholder');
const hasChild = ph && ph.children && ph.children.length > 0;
const height = ph ? parseInt(getComputedStyle(ph).height || '0', 10) : 0;
if (!hasChild && (!height || height === 0)) {
console.log('Ad placeholder empty — inserting iframe fallback');
const iframe = document.createElement('iframe');
iframe.width = '100%';
iframe.height = '90';
iframe.frameBorder = '0';
iframe.scrolling = 'no';
iframe.style.border = '0';
iframe.title = 'Advertisement';
// Fallback URL: attempt to call tag URL with slot param — may be accepted by provider
iframe.src = `${propScriptUrl}?slot=${encodeURIComponent(propSlotId)}&responsive=1`;
ph.innerHTML = '';
ph.appendChild(iframe);
try { window.dispatchEvent(new CustomEvent('ads:fallback-inserted',{detail:{provider:'propeller',zone:propSlotId}})); } catch {}
}
} catch (e) {
console.warn('Fallback insertion failed', e);
}
}, 3000);
} 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) {}
}, 120);
}
onMounted(async () => {
if (!isEnabled.value) return;
await nextTick();
renderAd();
window.addEventListener('resize', handleResize);
});
// If consent present, autoLoad, or debugForceLoad => load immediately
if (hasConsent() || autoLoad || debugForceLoad) {
await nextTick();
await loadAdIfNeeded();
return;
}
// Otherwise, wait for either a user interaction or consent event
const onFirstInteraction = async () => {
window.removeEventListener('pointerdown', onFirstInteraction);
window.removeEventListener('scroll', onFirstInteraction);
await loadAdIfNeeded();
};
window.addEventListener('pointerdown', onFirstInteraction, { once: true });
window.addEventListener('scroll', onFirstInteraction, { once: true });
listenForConsentEvent();
onBeforeUnmount(() => {
window.removeEventListener('resize', handleResize);
window.clearTimeout(resizeTimer);
clearAdContainer();
});
</script>
<style scoped>
.header-ad-banner {
width: 100%;
max-height: 90px;
display: flex;
align-items: center;
justify-content: center;
@@ -374,35 +105,23 @@ onMounted(async () => {
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;
min-height: 60px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.ad-container iframe,
.ad-container ins {
width: 100%;
max-width: 970px;
height: 60px;
.ad-container.is-mobile {
min-height: 50px;
}
@media (max-width: 720px) {
.ad-container iframe,
.ad-container ins {
height: 50px;
max-width: 320px;
}
.ad-container :deep(iframe) {
border: 0;
display: block;
margin: 0 auto;
max-width: 100%;
}
</style>