mirror of
https://github.com/WSA-Installer/wsa-website.git
synced 2026-07-29 11:24:39 -07:00
- Fix VideoAdPlayer CSS conflict and YouTube API race condition - Replace padding-bottom with aspect-video on homepage - Add real AdSense slot IDs to all ad placements (4266907782 banner, 2044808434 in-article, 5341055619 PIP) - AdFrame uses adSenseSlot from config instead of logical slot name - AdFrame shows network name badge and 3 dot indicators for rotation - Add InArticleAd component injecting ads every 4 paragraphs in blog posts - Navigation ad: remove skip button, 5s countdown before Continue clickable - Navigation ad: show on every page navigation (no counter) - Navigation ad: square 400x400 ad area using PIP slot 5341055619
315 lines
9.8 KiB
TypeScript
315 lines
9.8 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
|
import { useMonetizationConfig } from "@/hooks/useRuntimeConfig";
|
|
|
|
interface AdFrameProps {
|
|
slot: string;
|
|
className?: string;
|
|
format?: "banner" | "native" | "sidebar";
|
|
rotate?: boolean;
|
|
rotateInterval?: number;
|
|
}
|
|
|
|
interface AdSlide {
|
|
networkId: string;
|
|
loaded: boolean;
|
|
failed: boolean;
|
|
}
|
|
|
|
interface AdsbygoogleWindow {
|
|
adsbygoogle?: Array<Record<string, unknown>>;
|
|
}
|
|
|
|
export default function AdFrame({
|
|
slot,
|
|
className = "",
|
|
format = "banner",
|
|
rotate = false,
|
|
rotateInterval = 15000,
|
|
}: AdFrameProps) {
|
|
const { adNetworks, adPlacements } = useMonetizationConfig();
|
|
|
|
const placement = adPlacements.find((p) => p.slot === slot);
|
|
|
|
const enabledNetworks = useMemo(
|
|
() =>
|
|
placement?.networks
|
|
? adNetworks
|
|
.filter((n) => n.enabled && placement.networks?.includes(n.id))
|
|
.sort((a, b) => a.priority - b.priority)
|
|
: adNetworks
|
|
.filter((n) => n.enabled)
|
|
.sort((a, b) => a.priority - b.priority),
|
|
[adNetworks, placement]
|
|
);
|
|
|
|
const enabledNetworkKey = enabledNetworks.map((n) => n.id).join(",");
|
|
const [slides, setSlides] = useState<AdSlide[]>(() =>
|
|
enabledNetworks.map((n) => ({ networkId: n.id, loaded: false, failed: false }))
|
|
);
|
|
const [activeIndex, setActiveIndex] = useState(0);
|
|
const containerRefs = useRef<(HTMLDivElement | null)[]>([]);
|
|
const rotateTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
const loadTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
useEffect(() => {
|
|
setSlides(enabledNetworks.map((n) => ({ networkId: n.id, loaded: false, failed: false })));
|
|
setActiveIndex(0);
|
|
}, [enabledNetworkKey]);
|
|
|
|
const markLoaded = useCallback((networkId: string) => {
|
|
setSlides((prev) =>
|
|
prev.map((s) => (s.networkId === networkId ? { ...s, loaded: true } : s))
|
|
);
|
|
}, []);
|
|
|
|
const markFailed = useCallback((networkId: string) => {
|
|
setSlides((prev) =>
|
|
prev.map((s) => (s.networkId === networkId ? { ...s, failed: true } : s))
|
|
);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!rotate || slides.length <= 1) return;
|
|
|
|
rotateTimerRef.current = setInterval(() => {
|
|
setActiveIndex((prev) => {
|
|
let next = (prev + 1) % slides.length;
|
|
let attempts = 0;
|
|
while (slides[next]?.failed && attempts < slides.length) {
|
|
next = (next + 1) % slides.length;
|
|
attempts++;
|
|
}
|
|
return next;
|
|
});
|
|
}, rotateInterval);
|
|
|
|
return () => {
|
|
if (rotateTimerRef.current) clearInterval(rotateTimerRef.current);
|
|
};
|
|
}, [rotate, rotateInterval, slides]);
|
|
|
|
useEffect(() => {
|
|
if (rotate) return;
|
|
if (enabledNetworks.length === 0) return;
|
|
|
|
let currentIndex = 0;
|
|
let cancelled = false;
|
|
|
|
const tryLoad = () => {
|
|
if (cancelled || currentIndex >= enabledNetworks.length) return;
|
|
|
|
const container = containerRefs.current[0];
|
|
if (!container) return;
|
|
|
|
container.innerHTML = "";
|
|
const network = enabledNetworks[currentIndex];
|
|
|
|
const timeout = setTimeout(() => {
|
|
if (!cancelled) {
|
|
currentIndex++;
|
|
tryLoad();
|
|
}
|
|
}, 5000);
|
|
|
|
loadTimeoutRef.current = timeout;
|
|
|
|
const detectAdLoad = (targetEl: Element) => {
|
|
const observer = new MutationObserver(() => {
|
|
const hasAd = targetEl.querySelector("iframe, ins.adsbygoogle, script[src]");
|
|
if (hasAd) {
|
|
markLoaded(network.id);
|
|
clearTimeout(timeout);
|
|
observer.disconnect();
|
|
}
|
|
});
|
|
observer.observe(targetEl, { childList: true, subtree: true });
|
|
|
|
setTimeout(() => {
|
|
observer.disconnect();
|
|
if (!cancelled) {
|
|
currentIndex++;
|
|
tryLoad();
|
|
}
|
|
}, 4500);
|
|
};
|
|
|
|
if (network.type === "adsense" && network.publisherId) {
|
|
try {
|
|
const ins = document.createElement("ins");
|
|
ins.className = "adsbygoogle";
|
|
ins.style.display = "block";
|
|
if (format === "sidebar") {
|
|
ins.style.width = "100%";
|
|
ins.style.height = "600px";
|
|
} else if (format === "native") {
|
|
ins.style.width = "100%";
|
|
ins.setAttribute("data-ad-format", "fluid");
|
|
ins.setAttribute("data-ad-layout", "in-article");
|
|
ins.style.textAlign = "center";
|
|
} else if (slot === "pip-top") {
|
|
ins.style.display = "block";
|
|
ins.style.width = "100%";
|
|
ins.setAttribute("data-ad-format", "auto");
|
|
ins.setAttribute("data-full-width-responsive", "true");
|
|
} else {
|
|
ins.style.display = "block";
|
|
ins.style.width = "100%";
|
|
ins.style.height = "90px";
|
|
}
|
|
ins.setAttribute("data-ad-client", network.publisherId);
|
|
ins.setAttribute("data-ad-slot", placement?.adSenseSlot || slot);
|
|
container.appendChild(ins);
|
|
try {
|
|
if (!ins.getAttribute("data-ad-status")) {
|
|
const win = window as unknown as AdsbygoogleWindow;
|
|
win.adsbygoogle = win.adsbygoogle || [];
|
|
win.adsbygoogle.push({});
|
|
ins.setAttribute("data-ad-status", "pushed");
|
|
}
|
|
} catch { /* ignore */ }
|
|
detectAdLoad(ins);
|
|
} catch {
|
|
clearTimeout(timeout);
|
|
currentIndex++;
|
|
tryLoad();
|
|
}
|
|
} else if (network.type === "monetag" && network.publisherId) {
|
|
try {
|
|
const script = document.createElement("script");
|
|
script.src = `//viblast.com/${network.publisherId}/invoke.js`;
|
|
script.async = true;
|
|
script.onload = () => {
|
|
markLoaded(network.id);
|
|
clearTimeout(timeout);
|
|
};
|
|
script.onerror = () => {
|
|
clearTimeout(timeout);
|
|
currentIndex++;
|
|
tryLoad();
|
|
};
|
|
container.appendChild(script);
|
|
} catch {
|
|
clearTimeout(timeout);
|
|
currentIndex++;
|
|
tryLoad();
|
|
}
|
|
} else if (network.type === "adsterra" && network.publisherId) {
|
|
try {
|
|
const script = document.createElement("script");
|
|
script.src = `//pl${network.publisherId}.adsterra.com/${network.publisherId}.js`;
|
|
script.async = true;
|
|
script.onload = () => {
|
|
markLoaded(network.id);
|
|
clearTimeout(timeout);
|
|
};
|
|
script.onerror = () => {
|
|
clearTimeout(timeout);
|
|
currentIndex++;
|
|
tryLoad();
|
|
};
|
|
container.appendChild(script);
|
|
} catch {
|
|
clearTimeout(timeout);
|
|
currentIndex++;
|
|
tryLoad();
|
|
}
|
|
} else {
|
|
clearTimeout(timeout);
|
|
currentIndex++;
|
|
tryLoad();
|
|
}
|
|
};
|
|
|
|
tryLoad();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
if (loadTimeoutRef.current) clearTimeout(loadTimeoutRef.current);
|
|
};
|
|
}, [enabledNetworks, format, slot, rotate, markLoaded]);
|
|
|
|
const heightClass =
|
|
format === "sidebar"
|
|
? "min-h-[400px]"
|
|
: format === "native"
|
|
? "min-h-[150px]"
|
|
: "min-h-[60px]";
|
|
|
|
const hasLoadedAd = slides.some((s) => s.loaded);
|
|
|
|
const frameContent = (
|
|
<>
|
|
<div className="absolute -top-px -left-px w-4 h-4 border-t-2 border-l-2 border-accent-primary/60 rounded-tl-lg" />
|
|
<div className="absolute -bottom-px -right-px w-4 h-4 border-b-2 border-r-2 border-accent-primary/60 rounded-br-lg" />
|
|
|
|
<div className="absolute top-2 right-2 z-10">
|
|
<span className="text-[9px] font-mono text-text-muted/50 uppercase tracking-wider">
|
|
{enabledNetworks[activeIndex]?.name || "Ad"}
|
|
</span>
|
|
</div>
|
|
|
|
{rotate ? (
|
|
enabledNetworks.map((network, i) => (
|
|
<div
|
|
key={network.id}
|
|
ref={(el) => { containerRefs.current[i] = el; }}
|
|
className={`absolute inset-0 transition-opacity duration-700 ease-in-out ${
|
|
i === activeIndex ? "opacity-100 z-[1]" : "opacity-0 z-0 pointer-events-none"
|
|
}`}
|
|
/>
|
|
))
|
|
) : (
|
|
<div ref={(el) => { containerRefs.current[0] = el; }} className="w-full h-full" />
|
|
)}
|
|
|
|
{!hasLoadedAd && (
|
|
<div className="absolute inset-0 flex items-center justify-center z-[2]">
|
|
<div className="flex flex-col items-center gap-1.5 opacity-40">
|
|
<div className="w-6 h-6 rounded border border-text-muted/40 flex items-center justify-center">
|
|
<span className="text-[8px] text-text-muted">AD</span>
|
|
</div>
|
|
<span className="text-[9px] text-text-muted font-mono">Advertisement</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{rotate && (
|
|
<div className="absolute bottom-2 left-1/2 -translate-x-1/2 z-10 flex items-center gap-1.5">
|
|
{[0, 1, 2].map((i) => {
|
|
const slide = slides[i];
|
|
const isActive = i === activeIndex;
|
|
const isFailed = slide?.failed;
|
|
return (
|
|
<button
|
|
key={i}
|
|
onClick={() => { if (i < slides.length) setActiveIndex(i); }}
|
|
className={`w-1.5 h-1.5 rounded-full transition-all duration-300 ${
|
|
isActive
|
|
? "bg-accent-primary w-3"
|
|
: isFailed
|
|
? "bg-text-muted/30"
|
|
: "bg-text-muted/60 hover:bg-text-muted"
|
|
}`}
|
|
title={enabledNetworks[i]?.name || `Slot ${i + 1}`}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
|
|
return (
|
|
<div
|
|
className={`relative overflow-hidden rounded-xl border border-border-primary shadow-2xl box-glow ${heightClass} ${className}`}
|
|
style={{
|
|
background: "rgba(10, 10, 20, 0.65)",
|
|
}}
|
|
>
|
|
{frameContent}
|
|
</div>
|
|
);
|
|
}
|