Ä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,130 +1,22 @@
# AdSense in YpChat # Werbung in YpChat
## Ziel Die bisherige AdSense-/Propeller-Dokumentation ist veraltet.
Im Header kann ein Google-AdSense-Banner eingeblendet werden. Die Einbindung ist bereits vorbereitet, aber nur aktiv, wenn die passenden Vite-Variablen gesetzt sind. Aktuell ist im Projekt eine direkte Adsterra-Headerbanner-Loesung aktiv:
## Bereits im Code vorbereitet - Mobile: `320x50`
- Key: `fb9b5e7f817d40d72943dae0c54eb769`
- Desktop: `468x60`
- Key: `2b658317c1e28b4b4f234d26c8fca28d`
- Header-Komponente: [HeaderAdBanner.vue](/mnt/share/torsten/Programs/YpChat/client/src/components/HeaderAdBanner.vue) Implementierung:
- Einbindung in die Kopfzeilen:
- [ChatView.vue](/mnt/share/torsten/Programs/YpChat/client/src/views/ChatView.vue)
- [PartnersView.vue](/mnt/share/torsten/Programs/YpChat/client/src/views/PartnersView.vue)
- [FeedbackView.vue](/mnt/share/torsten/Programs/YpChat/client/src/views/FeedbackView.vue)
Aktiv wird der Banner nur mit: - [client/src/components/HeaderAdBanner.vue](/mnt/share/torsten/Programs/SingleChat/client/src/components/HeaderAdBanner.vue)
- `VITE_ADSENSE_CLIENT` Funktionsweise:
- `VITE_ADSENSE_HEADER_SLOT`
## Was bei Google AdSense erledigt werden muss - bis `720px` Viewport wird das `320x50`-Banner geladen
- ab `721px` Viewport wird das `468x60`-Banner geladen
- eingebunden ueber Adsterra `IFRAME SYNC`
### 1. AdSense-Konto und Website Wird spaeter ein anderer Werbeanbieter oder ein weiteres Adsterra-Format verwendet, sollte diese Datei entsprechend aktualisiert werden.
In AdSense selbst:
1. Website `ypchat.net` hinzufügen
2. Eigentum/Einbindung abschließen
3. Warten, bis die Website von Google geprüft und freigegeben wurde
Ohne freigegebene Website werden in der Regel keine regulären Anzeigen ausgeliefert.
### 2. Anzeigenblock anlegen
Für den Header in AdSense einen normalen responsiven Display-Anzeigenblock anlegen.
Benötigt werden daraus:
- Publisher-ID
Beispiel: `ca-pub-1234567890123456`
- Slot-ID des Header-Anzeigenblocks
Beispiel: `1234567890`
### 3. `ads.txt` korrekt pflegen
AdSense erwartet in der Regel einen korrekten Eintrag in `/ads.txt`.
Für Google AdSense ist das Format typischerweise:
```txt
google.com, pub-1234567890123456, DIRECT, f08c47fec0942fa0
```
Wichtig:
- `pub-...` muss zu deinem echten AdSense-Konto passen
- die Datei muss öffentlich unter `https://ypchat.net/ads.txt` erreichbar sein
- Änderungen brauchen oft etwas Zeit, bis Google sie erkennt
Im Projekt liegt aktuell eine Datei unter [docroot/ads.txt](/mnt/share/torsten/Programs/YpChat/docroot/ads.txt). Diese muss auf deine echte Publisher-ID geprüft und ggf. angepasst werden.
## Was im Projekt erledigt werden muss
### 1. Vite-Variablen setzen
In der Projekt-`.env` die beiden Werte ergänzen:
```env
VITE_ADSENSE_CLIENT=ca-pub-1234567890123456
VITE_ADSENSE_HEADER_SLOT=1234567890
```
Hinweis:
- `VITE_...` ist notwendig, damit die Werte im Client verfügbar sind
- ohne diese Werte bleibt der Banner automatisch unsichtbar
### 2. Frontend neu bauen
Nach Änderung der `.env`:
```bash
cd client
npm run build
```
Danach wie bisher den Build nach `docroot/dist` deployen.
### 3. Server/Deployment aktualisieren
Je nach Deploy-Prozess:
1. neuen Client-Build deployen
2. prüfen, dass `docroot/dist` aktuell ist
3. Service neu starten oder Deployment neu laden
## Prüfung nach dem Deploy
### Technisch
Prüfen:
- ist im HTML ein AdSense-Script geladen?
- erscheint im Header ein reservierter Anzeigenbereich?
- gibt es Fehler in der Browser-Konsole?
### Extern
Prüfen:
- `https://ypchat.net/ads.txt` ist erreichbar
- AdSense zeigt keinen `ads.txt`-Fehler mehr
- die Website ist in AdSense als bereit/freigegeben markiert
## Wichtige Hinweise
- Im lokalen Development erscheinen AdSense-Anzeigen oft nicht sinnvoll oder gar nicht.
- Nach dem ersten Einbau kann es dauern, bis Google echte Anzeigen ausliefert.
- Wenn Header-Anzeigen die UX zu stark stören, sollte der Banner auf Mobile ausgeblendet bleiben. Das ist im Code bereits berücksichtigt.
- Für Consent-/CMP-Themen kann je nach Land eine zusätzliche Einwilligungslösung nötig sein. Das ist aktuell nicht Teil dieser Implementierung.
## Kurz-Checkliste
1. In AdSense Website hinzufügen und freigeben lassen.
2. Header-Display-Ad-Unit anlegen.
3. Publisher-ID und Slot-ID notieren.
4. `docroot/ads.txt` auf korrekten Google-Eintrag prüfen.
5. `.env` mit `VITE_ADSENSE_CLIENT` und `VITE_ADSENSE_HEADER_SLOT` ergänzen.
6. Client neu bauen.
7. Deployen.
8. Live prüfen, ob `ads.txt` und Banner korrekt ausgeliefert werden.

View File

@@ -4,7 +4,12 @@ import android.Manifest
import android.content.Context import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.Color as AndroidColor
import android.net.Uri import android.net.Uri
import android.webkit.WebChromeClient
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
@@ -66,6 +71,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
@@ -128,6 +134,8 @@ private val Primary500 = Color(0xFF3D8654)
private val Primary100 = Color(0xFFE7F1EA) private val Primary100 = Color(0xFFE7F1EA)
private val Danger = Color(0xFFA24040) private val Danger = Color(0xFFA24040)
private const val PrivacyPolicyUrl = "https://www.ypchat.net/datenschutz" private const val PrivacyPolicyUrl = "https://www.ypchat.net/datenschutz"
private const val AndroidAdMobileKey = "fb9b5e7f817d40d72943dae0c54eb769"
private const val AndroidAdDesktopKey = "2b658317c1e28b4b4f234d26c8fca28d"
private data class GenderOption(val value: String, val label: String) private data class GenderOption(val value: String, val label: String)
private data class SmileyItem(val token: String, val hexCode: String, val tooltip: String) private data class SmileyItem(val token: String, val hexCode: String, val tooltip: String)
@@ -194,6 +202,7 @@ private fun LoginScreen(state: ChatState, onLogin: (String, String, Int, String)
) { ) {
Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(16.dp)) { Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(16.dp)) {
LandingIntroCard() LandingIntroCard()
AndroidHeaderAdBanner()
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp), shape = RoundedCornerShape(20.dp),
@@ -333,7 +342,9 @@ private fun ChatShell(state: ChatState, viewModel: ChatViewModel) {
} }
} }
) { padding -> ) { padding ->
Box(modifier = Modifier.padding(padding).fillMaxSize()) { Column(modifier = Modifier.padding(padding).fillMaxSize()) {
AndroidHeaderAdBanner()
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
if (state.currentConversation != null) { if (state.currentConversation != null) {
ChatScreen(state, viewModel) ChatScreen(state, viewModel)
} else { } else {
@@ -349,6 +360,7 @@ private fun ChatShell(state: ChatState, viewModel: ChatViewModel) {
} }
} }
} }
}
@Composable @Composable
private fun TopStatusBar(state: ChatState, onLogout: () -> Unit) { private fun TopStatusBar(state: ChatState, onLogout: () -> Unit) {
@@ -380,6 +392,118 @@ private fun UserListScreen(users: List<UserDto>, countries: List<CountryOption>,
} }
} }
private data class AndroidAdPlacement(
val key: String,
val width: Int,
val height: Int
)
@Composable
private fun AndroidHeaderAdBanner() {
val configuration = LocalConfiguration.current
val placement = remember(configuration.screenWidthDp) {
if (configuration.screenWidthDp <= 600) {
AndroidAdPlacement(
key = AndroidAdMobileKey,
width = 320,
height = 50
)
} else {
AndroidAdPlacement(
key = AndroidAdDesktopKey,
width = 468,
height = 60
)
}
}
val html = remember(placement) { adsterraHtml(placement) }
Box(
modifier = Modifier
.fillMaxWidth()
.background(Color(0xFFF4F8F5))
.padding(horizontal = 12.dp, vertical = 6.dp),
contentAlignment = Alignment.Center
) {
AndroidView(
modifier = Modifier
.fillMaxWidth()
.height(placement.height.dp),
factory = { context ->
WebView(context).apply {
setBackgroundColor(AndroidColor.TRANSPARENT)
overScrollMode = WebView.OVER_SCROLL_NEVER
isVerticalScrollBarEnabled = false
isHorizontalScrollBarEnabled = false
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
settings.cacheMode = WebSettings.LOAD_DEFAULT
settings.loadsImagesAutomatically = true
webChromeClient = WebChromeClient()
webViewClient = object : WebViewClient() {}
loadDataWithBaseURL(
"https://www.highperformanceformat.com/",
html,
"text/html",
"utf-8",
null
)
}
},
update = { webView ->
val currentPlacementKey = webView.tag as? String
if (currentPlacementKey != placement.key) {
webView.tag = placement.key
webView.loadDataWithBaseURL(
"https://www.highperformanceformat.com/",
html,
"text/html",
"utf-8",
null
)
}
}
)
}
}
private fun adsterraHtml(placement: AndroidAdPlacement): String = """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0" />
<style>
html, body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow: hidden;
background: transparent;
}
body {
display: flex;
align-items: center;
justify-content: center;
}
</style>
</head>
<body>
<script>
atOptions = {
'key' : '${placement.key}',
'format' : 'iframe',
'height' : ${placement.height},
'width' : ${placement.width},
'params' : {}
};
</script>
<script src="https://www.highperformanceformat.com/${placement.key}/invoke.js"></script>
</body>
</html>
""".trimIndent()
@Composable @Composable
private fun UserRow(user: UserDto, countries: List<CountryOption>, onUserClick: (String) -> Unit) { private fun UserRow(user: UserDto, countries: List<CountryOption>, onUserClick: (String) -> Unit) {
val countryLabel = remember(user.country, user.isoCountryCode, countries) { displayCountryName(user, countries) } val countryLabel = remember(user.country, user.isoCountryCode, countries) { displayCountryName(user, countries) }

View File

@@ -1,78 +1,31 @@
Aktuell habe ich das InPage Push Snippet, das du gepostet hast, als Default eingebaut: # Header-Ad Integration
```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`)
Wenn du später ein anderes Format/Zone anlegen willst, gib mir die neue ZoneID oder das komplette Snippet, dann passe ich es an. 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/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. Verwendete Placements:
Kurz: Anleitung zur Aktivierung des HeaderAds (Propeller / AdSense)
1) Umgebungsvariablen - Mobile: `320x50`
- Lege in deiner lokalen Umgebung (z. B. `.env.local`) folgende Variablen an: - Key: `fb9b5e7f817d40d72943dae0c54eb769`
- `VITE_AD_PROVIDER=propeller` # oder `adsense` - Desktop: `468x60`
- `VITE_PROP_SCRIPT_URL=https://example-propeller.example/ads.js` # Propeller: Script/Endpoint - Key: `2b658317c1e28b4b4f234d26c8fca28d`
- `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 Die Komponente waehlt automatisch anhand der Viewport-Breite:
- `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) - bis `720px`: `320x50`
- Der Code wartet auf einen Consent-Wert in `localStorage` unter dem Key `ads_consent` (Wert `'true'`). - ab `721px`: `468x60`
- 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 Einbauorte:
- 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 - [client/src/components/HeaderAdBanner.vue](/mnt/share/torsten/Programs/SingleChat/client/src/components/HeaderAdBanner.vue)
- Dev starten: - [client/src/views/ChatView.vue](/mnt/share/torsten/Programs/SingleChat/client/src/views/ChatView.vue)
```bash - weitere SEO-/Info-Seiten mit `HeaderAdBanner`
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) Technik:
- 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`). - Adsterra `IFRAME SYNC`
- Script-Quelle:
- `https://www.highperformanceformat.com/<key>/invoke.js`
Kurzbefehle zum Debugging: Aktuell gibt es keine zusaetzliche Consent-, Provider- oder Fallback-Logik mehr in dieser Komponente.
- 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: Wenn neue Banner-Formate kommen, muessen nur die Keys und Groessen in `HeaderAdBanner.vue` angepasst werden.
- `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

@@ -1,372 +1,103 @@
<template> <template>
<div v-if="isEnabled" :class="['header-ad-banner', { sticky: finalSticky } ]"> <div class="header-ad-banner">
<div ref="adContainer" class="ad-container" /> <div
ref="adContainer"
class="ad-container"
:class="{ 'is-mobile': activePlacement.width === 320 }"
></div>
</div> </div>
</template> </template>
<script setup> <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 adContainer = ref(null);
const activePlacement = ref(selectPlacement());
let resizeTimer = null;
// Provider selection via env: 'adsense' or 'propeller' function selectPlacement() {
const provider = (import.meta.env.VITE_AD_PROVIDER || 'propeller').toLowerCase(); if (typeof window === 'undefined') {
return placements.desktop;
// AdSense config (kept for compatibility) }
const adClient = import.meta.env.VITE_ADSENSE_CLIENT || ''; return window.innerWidth <= MOBILE_BREAKPOINT ? placements.mobile : placements.desktop;
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() { function clearAdContainer() {
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; 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;
}
// 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.innerHTML = '';
adContainer.value.appendChild(ins);
(window.adsbygoogle = window.adsbygoogle || []).push({});
} catch (err) {
console.warn('AdSense load failed', err);
}
} }
let adLoaded = false; function buildInvokeUrl(key) {
async function loadAdIfNeeded() { return `https://www.highperformanceformat.com/${key}/invoke.js`;
if (adLoaded || !isEnabled.value) return; }
adLoaded = true;
try { function renderAd() {
if (adContainer.value) adContainer.value.dataset.abVariant = variant; if (!adContainer.value) return;
window.dispatchEvent(new CustomEvent('ads:load-start', { detail: { provider, variant } }));
} catch (e) {} const placement = selectPlacement();
if (provider === 'propeller') await renderPropeller(); activePlacement.value = placement;
else if (provider === 'adsense') await renderAdSense(); clearAdContainer();
try { window.dispatchEvent(new CustomEvent('ads:loaded', { detail: { provider, variant } })); } catch (e) {}
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();
}
}, 120);
} }
onMounted(async () => { onMounted(async () => {
if (!isEnabled.value) return;
// If consent present, autoLoad, or debugForceLoad => load immediately
if (hasConsent() || autoLoad || debugForceLoad) {
await nextTick(); await nextTick();
await loadAdIfNeeded(); renderAd();
return; window.addEventListener('resize', handleResize);
} });
// Otherwise, wait for either a user interaction or consent event onBeforeUnmount(() => {
const onFirstInteraction = async () => { window.removeEventListener('resize', handleResize);
window.removeEventListener('pointerdown', onFirstInteraction); window.clearTimeout(resizeTimer);
window.removeEventListener('scroll', onFirstInteraction); clearAdContainer();
await loadAdIfNeeded();
};
window.addEventListener('pointerdown', onFirstInteraction, { once: true });
window.addEventListener('scroll', onFirstInteraction, { once: true });
listenForConsentEvent();
}); });
</script> </script>
<style scoped> <style scoped>
.header-ad-banner { .header-ad-banner {
width: 100%; width: 100%;
max-height: 90px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -374,35 +105,23 @@ onMounted(async () => {
box-sizing: border-box; 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 { .ad-container {
width: 100%; width: 100%;
max-width: 980px; min-height: 60px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
overflow: hidden;
} }
.ad-container iframe, .ad-container.is-mobile {
.ad-container ins { min-height: 50px;
width: 100%;
max-width: 970px;
height: 60px;
} }
@media (max-width: 720px) { .ad-container :deep(iframe) {
.ad-container iframe, border: 0;
.ad-container ins { display: block;
height: 50px; margin: 0 auto;
max-width: 320px; max-width: 100%;
}
} }
</style> </style>

View File

@@ -125,8 +125,14 @@ echo "✓ Dateien kopiert"
echo "" echo ""
echo "Synchronisiere .env Datei mit Vorlage..." echo "Synchronisiere .env Datei mit Vorlage..."
SESSION_SECRET="$(openssl rand -hex 32)" SESSION_SECRET="$(openssl rand -hex 32)"
if [ -f "$TARGET_DIR/.env" ]; then
ENV_BACKUP_PATH="$TARGET_DIR/.env.bak"
cp -a "$TARGET_DIR/.env" "$ENV_BACKUP_PATH"
echo "✓ Backup der bisherigen .env erstellt: $ENV_BACKUP_PATH"
fi
"$ENV_MERGE_SCRIPT" "$TARGET_DIR/.env.example" "$TARGET_DIR/.env" "$SESSION_SECRET" "$ENV_MERGE_SCRIPT" "$TARGET_DIR/.env.example" "$TARGET_DIR/.env" "$SESSION_SECRET"
chown $USER:$GROUP "$TARGET_DIR/.env" chown $USER:$GROUP "$TARGET_DIR/.env"
chmod 640 "$TARGET_DIR/.env"
echo "✓ .env Datei synchronisiert (bestehende Werte beibehalten)" echo "✓ .env Datei synchronisiert (bestehende Werte beibehalten)"
echo "" echo ""

View File

@@ -141,11 +141,17 @@ fi
log "Synchronisiere .env mit Vorlage" log "Synchronisiere .env mit Vorlage"
session_secret="$(openssl rand -hex 32)" session_secret="$(openssl rand -hex 32)"
if [ -f "$APP_DIR/.env" ]; then
env_backup_path="$APP_DIR/.env.bak"
cp -a "$APP_DIR/.env" "$env_backup_path"
log "Backup der bisherigen .env erstellt: $env_backup_path"
fi
"$ENV_MERGE_SCRIPT" "$ENV_TEMPLATE" "$APP_DIR/.env" "$session_secret" "$ENV_MERGE_SCRIPT" "$ENV_TEMPLATE" "$APP_DIR/.env" "$session_secret"
if [ "$(id -u)" -eq 0 ]; then if [ "$(id -u)" -eq 0 ]; then
chown "$RUN_USER:$RUN_GROUP" "$APP_DIR/.env" chown "$RUN_USER:$RUN_GROUP" "$APP_DIR/.env"
fi fi
chmod 640 "$APP_DIR/.env"
log "Baue Client" log "Baue Client"
run_as_deploy_user npm run build run_as_deploy_user npm run build

View File

@@ -19,6 +19,11 @@ fi
tmp_output="$(mktemp)" tmp_output="$(mktemp)"
trap 'rm -f "$tmp_output"' EXIT trap 'rm -f "$tmp_output"' EXIT
target_mode="640"
if [ -f "$env_file" ]; then
target_mode="$(stat -c '%a' "$env_file" 2>/dev/null || printf '640')"
fi
if [ -f "$env_file" ]; then if [ -f "$env_file" ]; then
awk ' awk '
function key_from(line, key) { function key_from(line, key) {
@@ -74,4 +79,5 @@ if [ -n "$session_secret" ] && grep -q '^SESSION_SECRET=$' "$tmp_output"; then
sed -i "s/^SESSION_SECRET=$/SESSION_SECRET=$session_secret/" "$tmp_output" sed -i "s/^SESSION_SECRET=$/SESSION_SECRET=$session_secret/" "$tmp_output"
fi fi
mv "$tmp_output" "$env_file" cat "$tmp_output" > "$env_file"
chmod "$target_mode" "$env_file"