Files
GDPR-Content-Blocker/gdpr-content-blocker/assets/frontend.js
s4luorth 2ef1d0769b feat: Werbe-Badge im content blocker (unten rechts) + abschaltbar nur mit unlimited (v1.5.0)
- Kleines Badge "GDPR Content Blocker" unten rechts im platzhalter, verlinkt
  auf die kaufseite (rel=nofollow sponsored). Server- und client-seitig.
- Wird IMMER angezeigt; abschaltbar via checkbox im Darstellung-tab
  ("Werbe-Badge ausblenden") - die checkbox ist aber nur mit aktiver
  Unlimited-lizenz nutzbar (sonst disabled) und greift am render-punkt nur,
  wenn is_unlimited() true ist.
- Dafuer speichert das plugin jetzt max_activations aus der backend-antwort
  (activate/validate/swap) und bietet is_unlimited().

+ i18n DE/EN (134 strings). version 1.5.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 14:08:08 +02:00

503 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Content Blocker – frontend.js
* Click-to-load: the real iframe is CREATED only after the user actively
* consents per service. Consent is stored in localStorage per service id.
*/
( function () {
'use strict';
const STORAGE_PREFIX = 'cb_consent_';
const CFG = window.cbConfig || { services: [], i18n: {} };
/* ───────────────────────── consent storage ───────────────────────── */
function hasConsent( serviceId ) {
try {
return localStorage.getItem( STORAGE_PREFIX + serviceId ) === '1';
} catch ( e ) {
return false;
}
}
function grantConsent( serviceId ) {
try {
localStorage.setItem( STORAGE_PREFIX + serviceId, '1' );
} catch ( e ) {
// localStorage unavailable; allow the load for this session only.
}
// Release any third-party <script> blocked for this service (handled by
// the early head guard, which exposes cbActivateScripts).
if ( typeof window.cbActivateScripts === 'function' ) {
window.cbActivateScripts( serviceId );
}
}
/**
* Public: grant consent for a service from a custom control
* ([content_blocker_consent]) — used for script-only services that have no
* visible embed. Reloads so blocked scripts/embeds load cleanly.
*/
window.cbConsent = function ( serviceId ) {
if ( ! serviceId ) {
return;
}
grantConsent( serviceId );
window.location.reload();
};
/* ─────────────── height-reservation (no whitespace) ─────────────── */
// Known wrappers that force a fixed aspect ratio / height around an embed.
const WRAP_SEL = '.e-youtube-base, .e-vimeo-base, .elementor-fit-aspect-ratio, '
+ '.elementor-open-inline, .elementor-wrapper, .wp-has-aspect-ratio, '
+ '.wp-block-embed__wrapper, .elementor-widget-video';
// One injected stylesheet neutralises every height reservation on a collapsed
// wrapper — CSS aspect-ratio, min/explicit height, the ::before padding-hack
// (WordPress oEmbed) and inline `height:Xpx`. Using a rule (with !important)
// means we never touch the element's own inline style, so restoring is just
// removing the attribute and the original size comes back untouched.
function ensureCollapseStyle() {
if ( document.getElementById( 'cb-collapse-style' ) ) return;
const st = document.createElement( 'style' );
st.id = 'cb-collapse-style';
st.textContent =
'[data-cb-collapsed]{aspect-ratio:auto !important;min-height:0 !important;height:auto !important;}'
+ '[data-cb-collapsed]::before,[data-cb-collapsed]::after{display:none !important;padding-top:0 !important;content:none !important;}';
( document.head || document.documentElement ).appendChild( st );
}
function shouldCollapse( el ) {
if ( ! el.matches ) return false;
if ( el.matches( WRAP_SEL ) ) return true;
// Otherwise only tight single-child wrappers, so we never shrink a section
// that also holds other content.
if ( el.childElementCount > 1 ) return false;
if ( /height\s*:\s*\d/i.test( el.getAttribute( 'style' ) || '' ) ) return true;
try {
const cs = getComputedStyle( el );
if ( cs.aspectRatio && cs.aspectRatio !== 'auto' ) return true;
if ( parseFloat( cs.minHeight ) > 80 ) return true;
} catch ( e ) { /* ignore */ }
return false;
}
function collapseAncestors( blocker ) {
ensureCollapseStyle();
let el = blocker.parentElement;
let n = 0;
while ( el && n < 5 ) {
if ( shouldCollapse( el ) ) {
el.setAttribute( 'data-cb-collapsed', '1' );
}
el = el.parentElement;
n++;
}
}
function restoreCollapsed( node ) {
let el = node.parentElement;
let n = 0;
while ( el && n < 6 ) {
if ( el.getAttribute && el.getAttribute( 'data-cb-collapsed' ) === '1' ) {
el.removeAttribute( 'data-cb-collapsed' );
}
el = el.parentElement;
n++;
}
}
/* ───────────────────────── iframe loading ────────────────────────── */
/** Replace a .cb-blocker element with the real iframe. src comes from data-src. */
function loadContent( blockerEl ) {
const src = blockerEl.dataset.src;
if ( ! src ) {
blockerEl.remove();
return;
}
// Give the wrappers their reserved size back so the embed loads full-size.
restoreCollapsed( blockerEl );
const iframe = document.createElement( 'iframe' );
if ( blockerEl.dataset.width ) iframe.width = blockerEl.dataset.width;
if ( blockerEl.dataset.height ) iframe.height = blockerEl.dataset.height;
// Responsive aspect (Elementor video widgets we build ourselves): full
// width at the given ratio, so it loads at its original proportions even
// without a definite parent height.
if ( blockerEl.dataset.cbAspect ) {
iframe.style.width = '100%';
iframe.style.height = 'auto';
iframe.style.aspectRatio = blockerEl.dataset.cbAspect;
}
iframe.style.border = '0';
iframe.setAttribute( 'loading', 'lazy' );
iframe.setAttribute( 'allowfullscreen', '' );
iframe.setAttribute( 'referrerpolicy', 'no-referrer-when-downgrade' );
iframe.setAttribute( 'data-cb-loaded', '1' ); // mark as ours (observer skips)
// Set src last — this is the moment the network request is made.
iframe.src = src;
blockerEl.parentNode.replaceChild( iframe, blockerEl );
}
function loadPreConsented() {
document.querySelectorAll( '.cb-blocker[data-cb-id]' ).forEach( function ( el ) {
const id = el.dataset.cbId;
if ( id && hasConsent( id ) ) {
loadContent( el );
}
} );
}
/* ───────────────────────── consent buttons ───────────────────────── */
function attachButtons() {
document.addEventListener( 'click', function ( e ) {
const btn = e.target.closest( '.cb-blocker__button' );
if ( ! btn ) return;
const serviceId = btn.dataset.cbId;
if ( ! serviceId ) return;
const wrapper = btn.closest( '.cb-blocker' );
// Placeholder without its own embed URL (e.g. an Elementor widget we
// couldn't read the URL from): persist consent and reload so the
// original embed renders normally.
if ( wrapper && wrapper.getAttribute( 'data-cb-reload' ) === '1' ) {
grantConsent( serviceId );
window.location.reload();
return;
}
// "Remember" checkbox (default checked): persist consent only when
// ticked; otherwise load this embed once without storing consent.
const remember = wrapper
? wrapper.querySelector( '.cb-blocker__remember-cb' )
: null;
if ( ! remember || remember.checked ) {
grantConsent( serviceId );
}
if ( wrapper ) {
loadContent( wrapper );
}
} );
}
/* ───────────────────────── revoke (Art. 7 (3)) ───────────────────── */
window.cbRevokeAll = function () {
try {
const keysToRemove = [];
for ( let i = 0; i < localStorage.length; i++ ) {
const key = localStorage.key( i );
if ( key && key.indexOf( STORAGE_PREFIX ) === 0 ) {
keysToRemove.push( key );
}
}
keysToRemove.forEach( function ( k ) {
localStorage.removeItem( k );
} );
} catch ( e ) {
// Silently ignore if localStorage is unavailable.
}
window.location.reload();
};
/* ─────────────── client-side detection (JS-injected iframes) ─────────────── */
/** Find a configured service whose match pattern is contained in the URL. */
function findService( url ) {
if ( ! url ) return null;
const list = CFG.services || [];
for ( let i = 0; i < list.length; i++ ) {
if ( list[ i ].match && url.indexOf( list[ i ].match ) !== -1 ) {
return list[ i ];
}
}
return null;
}
/** Convert a YouTube/Vimeo watch URL into its embeddable URL. */
function toEmbedUrl( url ) {
let m = url.match( /(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([\w-]+)/ );
if ( m ) return 'https://www.youtube.com/embed/' + m[ 1 ];
m = url.match( /(?:player\.)?vimeo\.com\/(?:video\/)?(\d+)/ );
if ( m ) return 'https://player.vimeo.com/video/' + m[ 1 ];
return url;
}
/** Build a consent placeholder element (mirrors PHP render_placeholder). */
function buildPlaceholder( svc, src, dims ) {
const i18n = CFG.i18n || {};
dims = dims || {};
const wrap = document.createElement( 'div' );
wrap.className = 'cb-blocker';
wrap.setAttribute( 'data-cb-id', svc.id );
wrap.dataset.src = src;
// Keep the dims for the iframe we create on consent, but do NOT reserve
// height — the placeholder is sized to its own content (no empty space).
if ( dims.width ) wrap.dataset.width = dims.width;
if ( dims.height ) wrap.dataset.height = dims.height;
const inner = document.createElement( 'div' );
inner.className = 'cb-blocker__inner';
const text = document.createElement( 'p' );
text.className = 'cb-blocker__text';
if ( svc.placeholder ) {
text.textContent = svc.placeholder;
} else {
const tpl = i18n.defaultText || '%s';
const parts = tpl.split( '%s' );
text.appendChild( document.createTextNode( parts[ 0 ] || '' ) );
const strong = document.createElement( 'strong' );
strong.textContent = svc.name;
text.appendChild( strong );
text.appendChild( document.createTextNode( parts[ 1 ] || '' ) );
}
inner.appendChild( text );
const rec = document.createElement( 'p' );
rec.className = 'cb-blocker__recipient';
const recLabel = document.createElement( 'strong' );
recLabel.textContent = ( i18n.recipient || 'Empfänger:' ) + ' ';
rec.appendChild( recLabel );
rec.appendChild( document.createTextNode( svc.recipient || '' ) );
if ( svc.third_country ) {
const tc = document.createElement( 'span' );
tc.className = 'cb-blocker__third-country';
tc.textContent = i18n.thirdCountry || '';
rec.appendChild( tc );
}
inner.appendChild( rec );
const pur = document.createElement( 'p' );
pur.className = 'cb-blocker__purpose';
const purLabel = document.createElement( 'strong' );
purLabel.textContent = ( i18n.purpose || 'Zweck:' ) + ' ';
pur.appendChild( purLabel );
pur.appendChild( document.createTextNode( svc.purpose || '' ) );
inner.appendChild( pur );
if ( svc.privacy_url ) {
const p = document.createElement( 'p' );
const a = document.createElement( 'a' );
a.className = 'cb-blocker__privacy-link';
a.href = svc.privacy_url;
a.target = '_blank';
a.rel = 'noopener noreferrer';
a.textContent = i18n.privacy || 'Datenschutz';
p.appendChild( a );
inner.appendChild( p );
}
const btn = document.createElement( 'button' );
btn.type = 'button';
btn.className = 'cb-blocker__button';
btn.setAttribute( 'data-cb-id', svc.id );
btn.textContent = ( i18n.load || '%s' ).replace( '%s', svc.name );
inner.appendChild( btn );
const label = document.createElement( 'label' );
label.className = 'cb-blocker__remember';
const cb = document.createElement( 'input' );
cb.type = 'checkbox';
cb.className = 'cb-blocker__remember-cb';
cb.checked = true;
label.appendChild( cb );
label.appendChild( document.createTextNode( ' ' + ( i18n.remember || '' ) ) );
inner.appendChild( label );
wrap.appendChild( inner );
// Promo badge (bottom-right), mirrors the server-rendered placeholder.
if ( CFG.showBadge ) {
const badge = document.createElement( 'a' );
badge.className = 'cb-blocker__badge';
badge.href = 'https://lucas-orth.de/wp-plugins/gdpr-content-blocker/';
badge.target = '_blank';
badge.rel = 'noopener noreferrer nofollow sponsored';
badge.textContent = 'GDPR Content Blocker';
wrap.appendChild( badge );
}
return wrap;
}
/** Replace a freshly seen iframe with a placeholder (or let it load if consented). */
function handleIframe( iframe ) {
if ( iframe.dataset.cbLoaded ) return; // one we created
if ( iframe.closest( '.cb-blocker' ) ) return; // inside a placeholder
const rawSrc = iframe.getAttribute( 'src' ) || '';
const svc = findService( rawSrc );
if ( ! svc ) return;
if ( hasConsent( svc.id ) ) return; // already consented → leave it
iframe.setAttribute( 'src', 'about:blank' ); // stop the request ASAP
const dims = {
width: iframe.getAttribute( 'width' ) || '',
height: iframe.getAttribute( 'height' ) || '',
};
const placeholder = buildPlaceholder( svc, rawSrc, dims );
if ( iframe.parentNode ) {
iframe.parentNode.replaceChild( placeholder, iframe );
collapseAncestors( placeholder );
}
}
/** Reconstruct a YouTube watch URL from a thumbnail (i.ytimg.com/vi/ID/…). */
function ytIdFromThumb( s ) {
const m = String( s || '' ).match( /(?:i\.ytimg\.com|img\.youtube\.com)\/vi\/([\w-]{6,})\// );
return m ? m[ 1 ] : '';
}
/**
* Find the embedded video URL of an Elementor video widget. Elementor often
* does NOT put the URL in data-settings (only {"video_type":"youtube"}) and
* builds the player via the JS API — so we look in several places.
*/
function videoUrlForWidget( widget ) {
const ds = widget.getAttribute( 'data-settings' ) || '';
// 1) Explicit URL keys in data-settings (older/other Elementor versions).
try {
const s = JSON.parse( ds || '{}' );
const u = s.youtube_url || s.vimeo_url || s.video_url || s.external_url
|| s.hosted_url || s.source || s.url || ( s.link && s.link.url ) || '';
if ( u ) return u;
} catch ( e ) { /* not JSON / no keys */ }
// 2) Any youtube/vimeo URL anywhere in the data-settings string (JSON values
// are quoted, so stop at a quote/space; tolerate escaped slashes \/).
const m = ds.match( /https?:[^"\s]*?(?:youtube\.com|youtu\.be|vimeo\.com)[^"\s]*/i );
if ( m ) return m[ 0 ].replace( /\\\//g, '/' ).replace( /\\/g, '' );
// 3) A server-rendered iframe already inside the widget.
const ifr = widget.querySelector( 'iframe[src]' );
if ( ifr ) return ifr.getAttribute( 'src' );
// 4) A YouTube thumbnail (lazyload / image-overlay mode) → rebuild the URL.
const thumb = widget.querySelector(
'img[src*="ytimg.com/vi/"], img[src*="img.youtube.com/vi/"], [style*="ytimg.com/vi/"]'
);
if ( thumb ) {
const id = ytIdFromThumb( thumb.getAttribute( 'src' ) || thumb.getAttribute( 'style' ) || '' );
if ( id ) return 'https://www.youtube.com/watch?v=' + id;
}
return '';
}
/** Elementor video widgets build the player via JS — replace them with our
* consent placeholder before that happens. */
function handleElementorVideos() {
const widgets = document.querySelectorAll(
'.elementor-widget-video, .elementor-widget[data-widget_type^="video"], '
+ '.e-youtube-base, .e-vimeo-base, [data-e-type^="e-youtube"], [data-e-type^="e-vimeo"]'
);
widgets.forEach( function ( widget ) {
const container = widget.querySelector( '.elementor-widget-container' ) || widget;
// Already turned into a placeholder (e.g. by the server-side autodetect
// catching a rendered iframe) → leave it.
if ( container.querySelector( '.cb-blocker' ) ) return;
const raw = videoUrlForWidget( widget );
const embed = raw ? toEmbedUrl( raw ) : '';
let svc = raw ? ( findService( raw ) || findService( embed ) ) : null;
// Fallback: no URL in the DOM (Elementor only kept {"video_type":"…"}).
// Identify the service by the video type so we can still show consent.
if ( ! svc ) {
const vt = ( widget.getAttribute( 'data-settings' ) || '' )
.match( /"video_type"\s*:\s*"(youtube|vimeo|dailymotion)"/i );
if ( vt ) {
svc = findService( vt[ 1 ] + '.com' );
}
}
if ( ! svc ) return;
if ( embed ) {
// We fully manage this widget — independent of Elementor's own JS
// build (which is unreliable and needs the blocked iframe_api).
// This also means it works the same on a fresh load AND after a
// reload when consent is already stored.
widget.removeAttribute( 'data-settings' );
container.innerHTML = '';
const placeholder = buildPlaceholder( svc, embed, {} );
placeholder.dataset.cbAspect = '16 / 9'; // responsive, original proportions
container.appendChild( placeholder );
if ( hasConsent( svc.id ) ) {
loadContent( placeholder ); // already consented → show the video now
} else {
collapseAncestors( placeholder ); // no whitespace while blocked
}
return;
}
// No URL found in the DOM → we can't build the iframe ourselves. Fall
// back to a reload so Elementor renders the player. If already
// consented, leave the widget untouched for Elementor.
if ( hasConsent( svc.id ) ) return;
widget.removeAttribute( 'data-settings' );
container.innerHTML = '';
const placeholder = buildPlaceholder( svc, embed, {} );
placeholder.setAttribute( 'data-cb-reload', '1' );
const remember = placeholder.querySelector( '.cb-blocker__remember' );
if ( remember ) remember.remove();
container.appendChild( placeholder );
collapseAncestors( placeholder );
} );
}
function scanExistingIframes() {
document.querySelectorAll( 'iframe[src]' ).forEach( handleIframe );
}
function startObserver() {
if ( typeof MutationObserver === 'undefined' ) return;
const obs = new MutationObserver( function ( mutations ) {
for ( const m of mutations ) {
for ( const node of m.addedNodes ) {
if ( node.nodeType !== 1 ) continue;
if ( node.tagName === 'IFRAME' ) {
handleIframe( node );
} else if ( node.querySelectorAll ) {
node.querySelectorAll( 'iframe[src]' ).forEach( handleIframe );
}
}
}
} );
obs.observe( document.documentElement, { childList: true, subtree: true } );
}
/* ───────────────────────── bootstrap ─────────────────────────────── */
function init() {
loadPreConsented();
attachButtons();
handleElementorVideos();
scanExistingIframes();
startObserver();
// Collapse the reserved aspect height around every remaining placeholder
// (also the server-rendered ones from the PHP autodetect) so the blocker
// shows no empty space below it.
document.querySelectorAll( '.cb-blocker' ).forEach( collapseAncestors );
}
if ( document.readyState === 'loading' ) {
document.addEventListener( 'DOMContentLoaded', init );
} else {
init();
}
} )();