oglasi: manj vsiljivo in bolje placano

- vse enote se zahtevajo sele ~200 px pred vstopom v vidno polje
  (IntersectionObserver); prej so se vse pushale ob nalaganju strani, tudi
  tiste globoko spodaj, ki jih uporabnik ni nikoli videl. Odlocitev o praznosti
  (3 s) tece sele od zahteve, ne od mounta.
- clanek: zgornji pas odstranjen (potiskal je naslovnico in naslov pod pregib,
  placan pa je najslabse). Prvi oglas pride sele v besedilu.
- clanek: stevilo oglasov vezano na dolzino — 2 osnovna + 1 na vsakih 5 blokov
  cez 10, strop 5. Mediana (9 blokov) ostane pri 2, dolgi clanki dobijo do 5.
  Prvi je fluid in-article, vsi nadaljnji nov InContentDisplayAd (odzivni
  display, visja kreativa).
- TopBannerAd na seznamih: odzivni namesto trdih 90 px.
- mobilni lepljivi pas: odzivni vodoravni (do 110 px) namesto trdih 320x50,
  z onAdStatus — prazen se ne izrise.
- PageSideAds: en desni 300x600 sticky z onAdStatus namesto dveh 160x600 brez
  zaznave (prazna je pustila luknjo); prikaze se od 1700 px navzgor.
- interstitial odstranjen s horoskopa (prekrivalo cez zaslon, zaklenilo scroll);
  interstitial-ad.tsx in neuporabljen parallax-ad.tsx izbrisana.

CMP ostaja nedotaknjen po dogovoru.
This commit is contained in:
Claude
2026-08-28 09:25:49 +00:00
parent 10c24d3e8c
commit db9eac20e2
6 changed files with 135 additions and 156 deletions
+104 -44
View File
@@ -11,6 +11,7 @@ interface AdSenseProps {
style?: Record<string, string>;
layout?: string;
layoutKey?: string;
eager?: boolean;
onAdStatus?: (filled: boolean) => void;
}
@@ -22,12 +23,48 @@ export default function AdSense({
style,
layout,
layoutKey,
eager = false,
onAdStatus,
}: AdSenseProps) {
const adRef = useRef<HTMLModElement>(null);
const pushed = useRef(false);
useEffect(() => {
if (pushed.current) return;
const ins = adRef.current;
let io: IntersectionObserver | null = null;
let obs: MutationObserver | null = null;
let timer: ReturnType<typeof setTimeout> | null = null;
let decided = false;
// Google postavi data-ad-status na "filled", "unfilled" ali "unfill-optimized".
// Ce statusa ni, se zanesemo na dejansko visino. Odlocimo se enkrat.
const decide = (filled: boolean) => {
if (decided) return;
decided = true;
obs?.disconnect();
if (timer) clearTimeout(timer);
onAdStatus?.(filled);
};
// Visina samega <ins> ni dokaz - nekatere enote imajo rezerviran prostor.
// Napolnjen oglas ima notri okvir (iframe) z dejansko visino.
const hasFrame = () => {
const f = ins?.querySelector("iframe");
return !!f && (f as HTMLIFrameElement).clientHeight > 10;
};
const check = () => {
const st = ins?.getAttribute("data-ad-status") || "";
if (st === "filled") return decide(true);
if (st.startsWith("unfill")) return decide(false);
if (hasFrame()) return decide(true);
};
// Oglas zahtevamo sele, ko se enota priblizuje vidnemu polju (200 px prej).
// Enote globoko pod pregibom so se prej zahtevale takoj ob nalaganju strani in
// vecine uporabnik ni nikoli videl - nizka vidnost pomeni nizjo ceno.
// Odlocitev o praznosti steje sele od zahteve naprej, ne od mounta.
const push = () => {
if (pushed.current) return;
try {
const adsbygoogle = (window as any).adsbygoogle || [];
@@ -35,38 +72,31 @@ export default function AdSense({
pushed.current = true;
} catch (e) {
}
if (!onAdStatus) return;
const ins = adRef.current;
if (!ins) return;
// Google postavi data-ad-status na "filled", "unfilled" ali "unfill-optimized".
// Ce statusa ni, se zanesemo na dejansko visino. Odlocimo se enkrat.
let decided = false;
const decide = (filled: boolean) => {
if (decided) return;
decided = true;
obs.disconnect();
clearTimeout(timer);
onAdStatus(filled);
};
// Visina samega <ins> ni dokaz - nekatere enote imajo rezerviran prostor.
// Napolnjen oglas ima notri okvir (iframe) z dejansko visino.
const hasFrame = () => {
const f = ins.querySelector("iframe");
return !!f && (f as HTMLIFrameElement).clientHeight > 10;
};
const check = () => {
const st = ins.getAttribute("data-ad-status") || "";
if (st === "filled") return decide(true);
if (st.startsWith("unfill")) return decide(false);
if (hasFrame()) return decide(true);
};
const obs = new MutationObserver(check);
if (!onAdStatus || !ins) return;
obs = new MutationObserver(check);
obs.observe(ins, { attributes: true, childList: true, subtree: true, attributeFilter: ["data-ad-status", "style"] });
const timer = setTimeout(() => decide(hasFrame()), 3000);
timer = setTimeout(() => decide(hasFrame()), 3000);
check();
return () => { obs.disconnect(); clearTimeout(timer); };
};
if (eager || !ins || typeof IntersectionObserver === "undefined") {
push();
} else {
io = new IntersectionObserver((entries) => {
if (entries.some((e) => e.isIntersecting)) {
io?.disconnect();
io = null;
push();
}
}, { rootMargin: "200px 0px" });
io.observe(ins);
}
return () => {
io?.disconnect();
obs?.disconnect();
if (timer) clearTimeout(timer);
};
}, []);
return (
@@ -123,8 +153,35 @@ export function TopBannerAd() {
slot="4154017639"
format="horizontal"
className="w-full"
style={{ display: "block", width: "100%", height: "90px", textAlign: "center", margin: "0 auto" }}
responsive={false}
style={{ display: "block", width: "100%", textAlign: "center", margin: "0 auto" }}
responsive
eager
onAdStatus={setFilled}
/>
</div>
);
}
// Navaden odzivni display v toku besedila. Za razliko od fluid in-article enote
// dobi Google celotno sirino in lahko postrezhe visoko kreativo (336x280, 300x600),
// torej en cel oglas, mimo katerega bralec odskrola - namesto vec drobnih.
export function InContentDisplayAd() {
const [filled, setFilled] = useState<boolean | null>(null);
if (filled === false) return null;
const shown = filled === true;
return (
<div
className={shown ? "my-6 rounded-md overflow-hidden bg-card border border-card-border" : ""}
data-testid="ad-in-content-display"
>
{shown && (
<div className="text-[9px] text-muted-foreground/40 text-center pt-1 uppercase tracking-widest">Anzeige</div>
)}
<AdSense
slot="4154017639"
format="auto"
responsive
style={{ display: "block", textAlign: "center" }}
onAdStatus={setFilled}
/>
</div>
@@ -173,26 +230,29 @@ export function MultiplexAd() {
);
}
// En sam desni stolpec 300x600 namesto dveh 160x600.
// 160x600 je stara velikost z malo povprasevanja, dve enoti sta si konkurirali,
// prazna pa je brez onAdStatus pustila luknjo. Prikaze se sele, ko je dovolj
// prostora desno od vsebine (min-[1700px]).
export function PageSideAds({ contentHalfWidth = 640 }: { contentHalfWidth?: number }) {
const gap = 10;
const gap = 24;
const offset = contentHalfWidth + gap;
const [filled, setFilled] = useState<boolean | null>(null);
if (filled === false) return null;
return (
<div className="relative">
<div className="hidden 2xl:flex fixed top-0 bottom-0 w-[160px] z-10 items-center justify-center pointer-events-none" style={{ left: `calc(50% - ${offset}px - 160px)` }} data-testid="ad-left-sidebar">
<div
className="hidden min-[1700px]:flex fixed top-0 bottom-0 w-[300px] z-10 items-center justify-center pointer-events-none"
style={{ right: `calc(50% - ${offset}px - 300px)` }}
data-testid="ad-right-sidebar"
>
<div className="pointer-events-auto">
<AdSense
slot="2398082838"
format="vertical"
style={{ display: "block", width: "160px", height: "600px" }}
/>
</div>
</div>
<div className="hidden 2xl:flex fixed top-0 bottom-0 w-[160px] z-10 items-center justify-center pointer-events-none" style={{ right: `calc(50% - ${offset}px - 160px)` }} data-testid="ad-right-sidebar">
<div className="pointer-events-auto">
<AdSense
slot="2398082838"
format="vertical"
style={{ display: "block", width: "160px", height: "600px" }}
responsive={false}
style={{ display: "block", width: "300px", height: "600px" }}
onAdStatus={setFilled}
/>
</div>
</div>
-75
View File
@@ -1,75 +0,0 @@
import { useState, useEffect } from "react";
import { X } from "lucide-react";
import AdSense from "./adsense";
const COUNTER_KEY = "folx-interstitial-count";
const DELAY_MS = 3000;
export default function InterstitialAd({ forceShow }: { forceShow?: boolean } = {}) {
const [visible, setVisible] = useState(false);
const [filled, setFilled] = useState<boolean | null>(null);
useEffect(() => {
if (forceShow) {
const timer = setTimeout(() => setVisible(true), DELAY_MS);
return () => clearTimeout(timer);
}
const raw = sessionStorage.getItem(COUNTER_KEY);
const count = raw ? parseInt(raw, 10) : 0;
const next = count + 1;
sessionStorage.setItem(COUNTER_KEY, next.toString());
const shouldShow = next % 2 === 1;
if (!shouldShow) return;
const timer = setTimeout(() => setVisible(true), DELAY_MS);
return () => clearTimeout(timer);
}, [forceShow]);
useEffect(() => {
if (visible && filled !== false) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "";
}
return () => {
document.body.style.overflow = "";
};
}, [visible, filled]);
if (!visible || filled === false) return null;
return (
<div
className={`fixed inset-0 z-[9999] flex items-center justify-center bg-black/70 animate-in fade-in duration-300`}
onClick={() => setVisible(false)}
data-testid="interstitial-ad-overlay"
>
<div
className="relative bg-card rounded-xl shadow-2xl w-[90vw] max-w-lg p-6"
onClick={(e) => e.stopPropagation()}
data-testid="interstitial-ad-container"
>
<button
onClick={() => setVisible(false)}
className="absolute top-3 right-3 z-10 w-8 h-8 flex items-center justify-center rounded-full bg-muted hover:bg-muted/80 text-foreground transition-colors"
data-testid="button-interstitial-close"
aria-label="Schließen"
>
<X className="w-5 h-5" />
</button>
<div className="text-[10px] text-muted-foreground/40 text-center mb-2 uppercase tracking-widest">Anzeige</div>
<div className="min-h-[250px] flex items-center justify-center">
<AdSense
slot="4154017639"
format="rectangle"
responsive={false}
style={{ display: "inline-block", width: "300px", height: "250px" }}
onAdStatus={setFilled}
/>
</div>
</div>
</div>
);
}
+17 -5
View File
@@ -8,6 +8,7 @@ const DISMISS_KEY = "folx-sticky-ad-dismissed";
export default function MobileStickyAd() {
const isMobile = useIsMobile();
const [visible, setVisible] = useState(false);
const [filled, setFilled] = useState<boolean | null>(null);
useEffect(() => {
if (!isMobile) return;
@@ -23,11 +24,13 @@ export default function MobileStickyAd() {
sessionStorage.setItem(DISMISS_KEY, "1");
};
if (!isMobile || !visible) return null;
if (!isMobile || !visible || filled === false) return null;
const shown = filled === true;
return (
<div className="fixed bottom-0 left-0 right-0 z-[100] animate-in slide-in-from-bottom duration-300" data-testid="mobile-sticky-ad">
<div className="relative bg-card border-t border-card-border shadow-2xl">
<div className={shown ? "relative bg-card border-t border-card-border shadow-2xl" : "relative"}>
{shown && (
<button
onClick={close}
className="absolute -top-8 right-2 w-7 h-7 flex items-center justify-center rounded-full bg-card border border-card-border shadow-lg text-muted-foreground hover:text-foreground transition-colors"
@@ -36,13 +39,22 @@ export default function MobileStickyAd() {
>
<X className="w-4 h-4" />
</button>
)}
{shown && (
<div className="text-[9px] text-muted-foreground/40 text-center pt-1 uppercase tracking-widest">Anzeige</div>
<div className="flex justify-center pb-1">
)}
{/* Odzivna vodoravna enota namesto trdih 320x50: Google lahko postrezhe
tudi 320x100, torej vecjo kreativo tam, kjer je prostor. Visino
omejimo, da sidro ne poje zaslona. */}
<div className="flex justify-center pb-1 w-full overflow-hidden" style={{ maxHeight: "110px" }}>
<AdSense
slot="4154017639"
format="horizontal"
style={{ display: "inline-block", width: "320px", height: "50px" }}
responsive={false}
className="w-full"
style={{ display: "block", width: "100%", maxHeight: "110px" }}
responsive
eager
onAdStatus={setFilled}
/>
</div>
</div>
-26
View File
@@ -1,26 +0,0 @@
import { useState } from "react";
import AdSense from "./adsense";
export default function ParallaxAd() {
const [filled, setFilled] = useState<boolean | null>(null);
if (filled === false) return null;
const shown = filled === true;
return (
<div className="relative" data-testid="parallax-ad-wrapper">
<div className={shown ? "sticky top-0 h-[350px] flex items-center justify-center bg-background z-0" : ""}>
<div className="text-center w-full max-w-lg mx-auto">
{shown && (
<div className="text-[10px] text-muted-foreground/40 text-center mb-2 uppercase tracking-widest">Anzeige</div>
)}
<AdSense
slot="2398082838"
format="rectangle"
style={{ display: "block", minHeight: "250px" }}
onAdStatus={setFilled}
/>
</div>
</div>
{shown && <div className="h-[350px]" aria-hidden="true" />}
</div>
);
}
+15 -5
View File
@@ -9,7 +9,7 @@ import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import Header from "@/components/header";
import Footer from "@/components/footer";
import { InArticleAd, PageSideAds, MultiplexAd, TopBannerAd } from "@/components/adsense";
import { InArticleAd, InContentDisplayAd, PageSideAds, MultiplexAd } from "@/components/adsense";
import { useIsMobile } from "@/hooks/use-mobile";
import DOMPurify from "dompurify";
import ShareButtons from "@/components/share-buttons";
@@ -304,7 +304,8 @@ export default function ArticlePage() {
<Header />
<PageSideAds contentHalfWidth={480} />
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<TopBannerAd />
{/* Zgornjega pasu na clanku namenoma ni: potiskal je naslovnico in naslov
pod pregib, placan pa je najslabse. Prvi oglas pride sele v besedilu. */}
<Link href="/">
<Button variant="ghost" size="sm" className="mb-3 gap-2" data-testid="button-back">
<ArrowLeft className="w-4 h-4" />
@@ -375,9 +376,18 @@ export default function ArticlePage() {
[&_div[style]]:flex [&_div[style]]:justify-center
[&_blockquote:not(.instagram-media)]:border-l-primary [&_blockquote:not(.instagram-media)]:bg-accent/50 [&_blockquote:not(.instagram-media)]:rounded-r-md [&_blockquote:not(.instagram-media)]:py-1
[&_.instagram-media]:!bg-transparent [&_.instagram-media]:!border-0 [&_.instagram-media]:!shadow-none [&_.instagram-media]:!p-0 [&_.instagram-media]:mx-auto`;
// Najvec dva oglasa v besedilu: po prvem odstavku in na sredini.
// Stevilo oglasov je vezano na dolzino clanka, ne fiksno na dva:
// dva osnovna, plus eden na vsakih pet blokov cez deset, strop pet.
// Prvi je fluid in-article (mehak, takoj za uvodom), vsi nadaljnji so
// navaden odzivni display - visja kreativa, mimo katere se odskrola.
const last = segments.length - 1;
const AD_AFTER = last <= 0 ? [] : (last === 1 ? [0] : [0, Math.floor(last / 2)]);
const extra = Math.min(3, Math.max(0, Math.floor((blocks.length - 10) / 5)));
const adCount = last <= 0 ? 0 : (last === 1 ? 1 : Math.min(2 + extra, last));
const AD_AFTER: number[] = [];
for (let i = 0; i < adCount; i++) {
const pos = i === 0 ? 0 : Math.round((i * last) / adCount);
if (pos < last && !AD_AFTER.includes(pos)) AD_AFTER.push(pos);
}
return (
<div ref={(el) => {
if (!el) return;
@@ -398,7 +408,7 @@ export default function ArticlePage() {
dangerouslySetInnerHTML={{ __html: html }}
data-testid={idx === 0 ? "article-content" : `article-content-${idx}`}
/>
{AD_AFTER.includes(idx) && <InArticleAd />}
{AD_AFTER.includes(idx) && (idx === AD_AFTER[0] ? <InArticleAd /> : <InContentDisplayAd />)}
</div>
))}
</div>
-2
View File
@@ -21,7 +21,6 @@ import {
} from "lucide-react";
import Header from "@/components/header";
import Footer from "@/components/footer";
import InterstitialAd from "@/components/interstitial-ad";
import { InArticleAd, PageSideAds, MultiplexAd } from "@/components/adsense";
import {
SIGNS,
@@ -385,7 +384,6 @@ export default function HoroscopePage() {
return (
<div className="min-h-screen bg-background">
<Header />
<InterstitialAd forceShow />
<PageSideAds />
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<h1 className="sr-only" data-testid="text-horoscope-title">