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:
@@ -11,6 +11,7 @@ interface AdSenseProps {
|
||||
style?: Record<string, string>;
|
||||
layout?: string;
|
||||
layoutKey?: string;
|
||||
eager?: boolean;
|
||||
onAdStatus?: (filled: boolean) => void;
|
||||
}
|
||||
|
||||
@@ -22,6 +23,7 @@ export default function AdSense({
|
||||
style,
|
||||
layout,
|
||||
layoutKey,
|
||||
eager = false,
|
||||
onAdStatus,
|
||||
}: AdSenseProps) {
|
||||
const adRef = useRef<HTMLModElement>(null);
|
||||
@@ -29,44 +31,72 @@ export default function AdSense({
|
||||
|
||||
useEffect(() => {
|
||||
if (pushed.current) return;
|
||||
try {
|
||||
const adsbygoogle = (window as any).adsbygoogle || [];
|
||||
adsbygoogle.push({});
|
||||
pushed.current = true;
|
||||
} catch (e) {
|
||||
}
|
||||
|
||||
if (!onAdStatus) return;
|
||||
const ins = adRef.current;
|
||||
if (!ins) return;
|
||||
|
||||
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.
|
||||
let decided = false;
|
||||
const decide = (filled: boolean) => {
|
||||
if (decided) return;
|
||||
decided = true;
|
||||
obs.disconnect();
|
||||
clearTimeout(timer);
|
||||
onAdStatus(filled);
|
||||
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");
|
||||
const f = ins?.querySelector("iframe");
|
||||
return !!f && (f as HTMLIFrameElement).clientHeight > 10;
|
||||
};
|
||||
const check = () => {
|
||||
const st = ins.getAttribute("data-ad-status") || "";
|
||||
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);
|
||||
obs.observe(ins, { attributes: true, childList: true, subtree: true, attributeFilter: ["data-ad-status", "style"] });
|
||||
const timer = setTimeout(() => decide(hasFrame()), 3000);
|
||||
check();
|
||||
return () => { obs.disconnect(); clearTimeout(timer); };
|
||||
|
||||
// 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 || [];
|
||||
adsbygoogle.push({});
|
||||
pushed.current = true;
|
||||
} catch (e) {
|
||||
}
|
||||
if (!onAdStatus || !ins) return;
|
||||
obs = new MutationObserver(check);
|
||||
obs.observe(ins, { attributes: true, childList: true, subtree: true, attributeFilter: ["data-ad-status", "style"] });
|
||||
timer = setTimeout(() => decide(hasFrame()), 3000);
|
||||
check();
|
||||
};
|
||||
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,26 +24,37 @@ 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">
|
||||
<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"
|
||||
data-testid="button-sticky-ad-close"
|
||||
aria-label="Schließen"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
<div className="text-[9px] text-muted-foreground/40 text-center pt-1 uppercase tracking-widest">Anzeige</div>
|
||||
<div className="flex justify-center pb-1">
|
||||
<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"
|
||||
data-testid="button-sticky-ad-close"
|
||||
aria-label="Schließen"
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
{/* 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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user