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

View File

@@ -1,90 +1,208 @@
<template>
<div v-if="isEnabled" class="header-ad-banner">
<ins
ref="adElement"
class="adsbygoogle"
style="display:inline-block;width:320px;height:50px"
:data-ad-client="adClient"
:data-ad-slot="adSlot"
></ins>
<div v-if="isEnabled" :class="['header-ad-banner', { sticky: finalSticky } ]">
<div ref="adContainer" class="ad-container" />
</div>
</template>
<script setup>
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 adSlot = import.meta.env.VITE_ADSENSE_HEADER_SLOT || '';
const isEnabled = computed(() => Boolean(adClient && adSlot));
function ensureAdSenseScript() {
if (!isEnabled.value) return;
if (document.querySelector('script[data-adsense-loader="true"]')) return;
// Propeller config: you can set the script URL and the slot id via env
const propScriptUrl = import.meta.env.VITE_PROP_SCRIPT_URL || '';
const propSlotId = import.meta.env.VITE_PROP_SLOT || '';
const script = document.createElement('script');
script.async = true;
script.src = `https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${adClient}`;
script.crossOrigin = 'anonymous';
script.dataset.adsenseLoader = 'true';
document.head.appendChild(script);
// 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';
// 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 () => {
if (!isEnabled.value) return;
ensureAdSenseScript();
await nextTick();
try {
// Avoid duplicate initialization on remount.
if (adElement.value?.dataset.adsInitialized === 'true') return;
(window.adsbygoogle = window.adsbygoogle || []).push({});
if (adElement.value) {
adElement.value.dataset.adsInitialized = 'true';
}
} catch (error) {
console.warn('AdSense Banner konnte nicht initialisiert werden:', error);
// If consent present, load immediately (but still lazy after nextTick to avoid blocking)
if (hasConsent()) {
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();
});
</script>
<style scoped>
.header-ad-banner {
flex: 0 0 auto;
width: 320px;
min-width: 320px;
max-width: 320px;
height: 50px;
margin: 0 16px;
overflow: hidden;
width: 100%;
max-height: 90px;
display: flex;
align-items: center;
justify-content: center;
padding: 6px 12px;
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;
align-items: center;
justify-content: center;
}
.header-ad-banner :deep(ins) {
min-height: 50px;
}
@media (max-width: 960px) {
.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;
}
.ad-container iframe,
.ad-container ins {
width: 100%;
max-width: 970px;
height: 60px;
}
@media (max-width: 720px) {
.header-ad-banner {
display: none;
.ad-container iframe,
.ad-container ins {
height: 50px;
max-width: 320px;
}
}
</style>

View File

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