356 lines
12 KiB
TypeScript
356 lines
12 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import ArtistPatternBg from "./artist-pattern-bg";
|
|
|
|
type AdFormat = "auto" | "fluid" | "rectangle" | "horizontal" | "vertical" | "autorelaxed";
|
|
|
|
interface AdSenseProps {
|
|
slot: string;
|
|
format?: AdFormat;
|
|
responsive?: boolean;
|
|
className?: string;
|
|
style?: Record<string, string>;
|
|
layout?: string;
|
|
layoutKey?: string;
|
|
eager?: boolean;
|
|
onAdStatus?: (filled: boolean) => void;
|
|
}
|
|
|
|
export default function AdSense({
|
|
slot,
|
|
format = "auto",
|
|
responsive = true,
|
|
className = "",
|
|
style,
|
|
layout,
|
|
layoutKey,
|
|
// Privzeto zahtevamo takoj. Odlozena zahteva dvigne vidnost, a znizza stevilo
|
|
// prikazov pri enotah globlje na strani - in prav te (in-article, multiplex)
|
|
// nosijo prihodek. Lazy pot ostaja na voljo prek eager={false}, a je nikjer
|
|
// ne uporabljamo, dokler ucinka ne moremo izmeriti v AdSense.
|
|
eager = true,
|
|
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.
|
|
// AdSense odzivni enoti vnaprej doloci visino po sirini zaslona. Ce je
|
|
// postrezena kreativa nizja, ostane pod njo prazen rezerviran pas znotraj
|
|
// okvirja. Visino <ins> zato poravnamo na dejansko visino oglasnega okvirja.
|
|
// Nic se ne odreze - visina je natanko taksna kot oglas.
|
|
let fitTimers: ReturnType<typeof setTimeout>[] = [];
|
|
const fitToCreative = () => {
|
|
// Enota s trdno visino v style (fiksne Display enote) se ne prilagaja
|
|
// kreativi, sicer nam Google 300x400 razpotegne na 300x600.
|
|
if (style && typeof (style as any).height === "string") return;
|
|
const f = ins?.querySelector("iframe") as HTMLIFrameElement | null;
|
|
if (!ins || !f) return;
|
|
const h = f.getBoundingClientRect().height;
|
|
if (h > 10 && Math.abs(ins.getBoundingClientRect().height - h) > 2) {
|
|
ins.style.height = `${Math.ceil(h)}px`;
|
|
}
|
|
};
|
|
const scheduleFit = () => {
|
|
fitTimers.forEach(clearTimeout);
|
|
fitTimers = [0, 300, 1000, 2500].map((d) => setTimeout(fitToCreative, d));
|
|
};
|
|
|
|
const decide = (filled: boolean) => {
|
|
if (decided) return;
|
|
decided = true;
|
|
obs?.disconnect();
|
|
if (timer) clearTimeout(timer);
|
|
if (filled) scheduleFit();
|
|
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 || [];
|
|
adsbygoogle.push({});
|
|
pushed.current = true;
|
|
} catch (e) {
|
|
}
|
|
if (!ins) return;
|
|
// Opazujemo tudi pri enotah brez onAdStatus - potrebujemo jih za poravnavo
|
|
// visine na kreativo.
|
|
obs = new MutationObserver(() => { check(); fitToCreative(); });
|
|
obs.observe(ins, { attributes: true, childList: true, subtree: true, attributeFilter: ["data-ad-status", "style"] });
|
|
timer = setTimeout(() => decide(hasFrame()), 3000);
|
|
window.addEventListener("resize", scheduleFit);
|
|
check();
|
|
scheduleFit();
|
|
};
|
|
|
|
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);
|
|
fitTimers.forEach(clearTimeout);
|
|
window.removeEventListener("resize", scheduleFit);
|
|
};
|
|
}, []);
|
|
|
|
return (
|
|
<div className={`ad-container ${className}`} data-testid={`ad-slot-${slot}`}>
|
|
<ins
|
|
ref={adRef}
|
|
className="adsbygoogle"
|
|
style={style || { display: "block" }}
|
|
data-ad-client="ca-pub-4465464714854276"
|
|
data-ad-slot={slot}
|
|
{...(format ? { "data-ad-format": format } : {})}
|
|
data-full-width-responsive={responsive ? "true" : "false"}
|
|
{...(layout ? { "data-ad-layout": layout } : {})}
|
|
{...(layoutKey ? { "data-ad-layout-key": layoutKey } : {})}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
let adSeedCounter = 1;
|
|
|
|
// Vodoravni pas med vrsticami mreze. Oglas v celici je vrstico raztegnil na
|
|
// svojo visino (546 px proti 399 px kartice) in pod izvlecki je ostalo prazno.
|
|
// Pas cez vso sirino na visino kartic ne vpliva in ima boljso zapolnjenost.
|
|
export function GridBannerAd() {
|
|
const [filled, setFilled] = useState<boolean | null>(null);
|
|
if (filled === false) return null;
|
|
return (
|
|
<div className="my-2" data-testid="ad-grid-banner">
|
|
<div className="text-[9px] text-muted-foreground/40 text-center pb-1 uppercase tracking-widest">
|
|
Anzeige
|
|
</div>
|
|
<AdSense
|
|
slot="1023820241"
|
|
format="auto"
|
|
responsive={true}
|
|
style={{ display: "block" }}
|
|
onAdStatus={setFilled}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function ArticleCardAd() {
|
|
const seed = useMemo(() => adSeedCounter++, []);
|
|
const [filled, setFilled] = useState<boolean | null>(null);
|
|
if (filled === false) return null;
|
|
return (
|
|
// 31. 8. 2026: enota 3854634730 je v AdSense nastavljena kot fluid in-feed
|
|
// in si visino racuna sama (248 px), inline visine pa Googlova skripta
|
|
// prepise — z minHeight/height 400 je celico raztegnil na 600 in vrstica
|
|
// je narasla. Zato pustimo fluid, okvir pa se prilagodi vsebini in stoji
|
|
// na vrhu celice, da pod njim ni vidne luknje.
|
|
// Vzorec z imeni izvajalcev je bil pod oglasom viden kot prazna grafika,
|
|
// ker je fluid enota nizja od celice. Zdaj je okvir samo okoli oglasa in
|
|
// se skrci na njegovo visino.
|
|
// 31. 8. 2026: fiksna Display enota 300x400, ki jo je Sebastjan naredil
|
|
// posebej za mrezo. Fluid In-feed (3854634730) si je visino racunal sam in
|
|
// se ustavil pri 258 px, zato je pod njim ostajala luknja.
|
|
<div className="self-start w-full flex justify-center rounded-md border border-card-border bg-card overflow-hidden">
|
|
<AdSense
|
|
slot="9916590834"
|
|
responsive={false}
|
|
style={{ display: "inline-block", width: "300px", height: "400px" }}
|
|
onAdStatus={setFilled}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function TopBannerAd() {
|
|
const [filled, setFilled] = useState<boolean | null>(null);
|
|
if (filled === false) return null;
|
|
const shown = filled === true;
|
|
return (
|
|
<div
|
|
className={shown
|
|
? "rounded-md overflow-hidden bg-card border border-card-border mb-4 flex flex-col items-center"
|
|
: "flex flex-col items-center"}
|
|
data-testid="ad-top-banner"
|
|
>
|
|
{shown && (
|
|
<div className="text-[9px] text-muted-foreground/40 text-center pt-1 uppercase tracking-widest w-full">Anzeige</div>
|
|
)}
|
|
{/* format="auto" + full-width-responsive: Google dobi celo sirino in
|
|
izbere kreativo, ki jo zapolni. Pri "horizontal" s trdo visino je
|
|
vracal 320x50 in okrog je ostal prazen rob. */}
|
|
<AdSense
|
|
slot="4154017639"
|
|
format="auto"
|
|
className="w-full"
|
|
style={{ display: "block" }}
|
|
responsive={false}
|
|
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={false}
|
|
style={{ display: "block" }}
|
|
onAdStatus={setFilled}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function InArticleAd() {
|
|
// Prava AdSense In-article enota (fluid + data-ad-layout="in-article").
|
|
// Kadar oglasa ni, se okvir in rezerviran prostor ne izriseta.
|
|
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" : ""}
|
|
>
|
|
{shown && (
|
|
<div className="text-[9px] text-muted-foreground/40 text-center pt-1 uppercase tracking-widest">Anzeige</div>
|
|
)}
|
|
<AdSense
|
|
slot="7672178246"
|
|
format="fluid"
|
|
layout="in-article"
|
|
responsive={false}
|
|
style={{ display: "block", textAlign: "center" }}
|
|
onAdStatus={setFilled}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function MultiplexAd() {
|
|
const [filled, setFilled] = useState<boolean | null>(null);
|
|
if (filled === false) return null;
|
|
const shown = filled === true;
|
|
return (
|
|
<div className={shown ? "bg-card rounded-md border border-card-border overflow-hidden" : ""}>
|
|
<AdSense
|
|
slot="4593001335"
|
|
format="autorelaxed"
|
|
responsive={false}
|
|
style={{ display: "block" }}
|
|
className="w-full"
|
|
onAdStatus={setFilled}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Oba stolpca ostaneta (nic se ne odvzame), a se prilagodita sirini zaslona:
|
|
// 160x600 od 1536 px, 300x600 od 1700 px naprej, kjer je prostora dovolj.
|
|
// Sirino dolocz CSS, AdSense jo prebere ob zahtevi. Dodan onAdStatus, da prazna
|
|
// enota ne pusti luknje - prej je ni imela.
|
|
function SideRail({ side, offset }: { side: "left" | "right"; offset: number }) {
|
|
const [filled, setFilled] = useState<boolean | null>(null);
|
|
if (filled === false) return null;
|
|
return (
|
|
<div
|
|
className="hidden 2xl:flex fixed top-0 bottom-0 w-[160px] min-[1700px]:w-[300px] z-10 items-center justify-center pointer-events-none"
|
|
style={side === "left"
|
|
? { left: `calc(50% - ${offset}px - var(--rail-w))` }
|
|
: { right: `calc(50% - ${offset}px - var(--rail-w))` }}
|
|
data-testid={`ad-${side}-sidebar`}
|
|
>
|
|
<div className="pointer-events-auto w-full">
|
|
<AdSense
|
|
slot="2398082838"
|
|
format="vertical"
|
|
responsive={false}
|
|
style={{ display: "block", width: "100%", height: "600px" }}
|
|
onAdStatus={setFilled}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function PageSideAds({ contentHalfWidth = 640 }: { contentHalfWidth?: number }) {
|
|
const gap = 10;
|
|
const offset = contentHalfWidth + gap;
|
|
return (
|
|
<div className="relative page-side-rails">
|
|
<SideRail side="left" offset={offset} />
|
|
<SideRail side="right" offset={offset} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function SidebarAd() {
|
|
const [visible, setVisible] = useState(false);
|
|
|
|
return (
|
|
<div className={`sticky top-4 ${visible ? "" : "hidden"}`}>
|
|
<div className="bg-card rounded-md border border-card-border p-4 mt-4">
|
|
<AdSense
|
|
slot="2398082838"
|
|
format="auto"
|
|
responsive={false}
|
|
onAdStatus={(filled) => setVisible(filled)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|