@@ -0,0 +1,12 @@
|
||||
import { connection } from "next/server";
|
||||
|
||||
import { getAdvitiseCached } from "@/lib/api/home";
|
||||
|
||||
import { AdDialogHost } from "./ad-dialog";
|
||||
|
||||
/** 服务端插槽:从 /api/app/home/base 拉取 advitise.dialogList(Suspense + connection) */
|
||||
export async function AdDialogSlot() {
|
||||
await connection();
|
||||
const advitise = await getAdvitiseCached();
|
||||
return <AdDialogHost advitise={advitise} />;
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
||||
|
||||
import { getAgeGateOpen, subscribeAgeGate } from "@/components/age-gate/age-gate-dialog";
|
||||
import { DecryptedImage } from "@/components/common/decrypted-image";
|
||||
import { adTrackProps } from "@/components/common/ads/pick";
|
||||
import { useChromeless } from "@/components/layout/route-visibility";
|
||||
import type { AdvertiseInfo, AdvitiseData } from "@/lib/api/types";
|
||||
|
||||
/** 对齐 jha-1043 dialogAd.js:签名队列 + session 进度 / 关闭态 */
|
||||
const QUEUE_VERSION = "2";
|
||||
const DISMISSED_KEY = "dialogAdDismissed";
|
||||
const PROGRESS_KEY = "dialogAdProgress";
|
||||
|
||||
let queueIdle = true;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function emit() {
|
||||
listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
function setQueueIdle(idle: boolean) {
|
||||
if (queueIdle === idle) return;
|
||||
queueIdle = idle;
|
||||
emit();
|
||||
}
|
||||
|
||||
export function subscribeAd(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
/** 当前是否正在展示广告弹窗 */
|
||||
export const getAdOpen = () => !queueIdle;
|
||||
|
||||
/**
|
||||
* 广告队列是否已结束(已全部关闭 / 无广告 / 已 dismiss)
|
||||
* 供最新地址弹窗排序:年龄确认 → 广告队列 → 地址弹窗
|
||||
*/
|
||||
export function isAdQueueDone(signature: string) {
|
||||
if (!signature) return true;
|
||||
try {
|
||||
return window.sessionStorage.getItem(DISMISSED_KEY) === signature;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** @deprecated 兼容旧调用:等价于队列空闲 */
|
||||
export const hasAdShown = () => queueIdle;
|
||||
|
||||
const getAgeGateServerSnapshot = () => true;
|
||||
|
||||
function readSession(key: string) {
|
||||
try {
|
||||
return window.sessionStorage.getItem(key) || "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function writeSession(key: string, value: string) {
|
||||
try {
|
||||
window.sessionStorage.setItem(key, value);
|
||||
} catch {
|
||||
// sessionStorage 不可用时仍允许本页依次展示
|
||||
}
|
||||
}
|
||||
|
||||
function removeSession(key: string) {
|
||||
try {
|
||||
window.sessionStorage.removeItem(key);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function buildSignature(ads: AdvertiseInfo[]) {
|
||||
return (
|
||||
QUEUE_VERSION +
|
||||
"|" +
|
||||
ads
|
||||
.map((item, index) => item.id || item.images || String(index))
|
||||
.join("|")
|
||||
);
|
||||
}
|
||||
|
||||
function readProgress(signature: string) {
|
||||
try {
|
||||
const raw = readSession(PROGRESS_KEY);
|
||||
if (!raw) return 0;
|
||||
const progress = JSON.parse(raw) as { signature?: string; index?: number };
|
||||
if (!progress || progress.signature !== signature) return 0;
|
||||
const value = Number(progress.index);
|
||||
if (!Number.isFinite(value) || value < 0) return 0;
|
||||
return Math.floor(value);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function rememberProgress(signature: string, index: number) {
|
||||
writeSession(PROGRESS_KEY, JSON.stringify({ signature, index }));
|
||||
}
|
||||
|
||||
function pickDialogAds(items?: AdvertiseInfo[] | null, advitise?: AdvitiseData | null) {
|
||||
const source = items ?? advitise?.dialogList ?? [];
|
||||
return source.filter((item): item is AdvertiseInfo & { images: string } => Boolean(item.images?.trim()));
|
||||
}
|
||||
|
||||
/** 弹窗广告:使用 advitise.dialogList,全站队列展示(对齐 jha-1043) */
|
||||
export function AdDialogHost({
|
||||
items,
|
||||
advitise,
|
||||
}: {
|
||||
items?: AdvertiseInfo[] | null;
|
||||
advitise?: AdvitiseData | null;
|
||||
}) {
|
||||
const chromeless = useChromeless();
|
||||
const ageGateOpen = useSyncExternalStore(subscribeAgeGate, getAgeGateOpen, getAgeGateServerSnapshot);
|
||||
const ads = useMemo(() => pickDialogAds(items, advitise), [items, advitise]);
|
||||
const signature = useMemo(() => buildSignature(ads), [ads]);
|
||||
|
||||
const [index, setIndex] = useState(-1);
|
||||
const hasDisplayedRef = useRef(false);
|
||||
const enabled = !chromeless && !ageGateOpen && ads.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
await Promise.resolve();
|
||||
if (cancelled) return;
|
||||
if (!enabled) {
|
||||
if (!ads.length) setQueueIdle(true);
|
||||
return;
|
||||
}
|
||||
if (isAdQueueDone(signature)) {
|
||||
setIndex(-1);
|
||||
setQueueIdle(true);
|
||||
return;
|
||||
}
|
||||
const start = readProgress(signature);
|
||||
if (start >= ads.length) {
|
||||
if (hasDisplayedRef.current) writeSession(DISMISSED_KEY, signature);
|
||||
removeSession(PROGRESS_KEY);
|
||||
setIndex(-1);
|
||||
setQueueIdle(true);
|
||||
return;
|
||||
}
|
||||
setIndex(start);
|
||||
setQueueIdle(false);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [enabled, signature, ads.length]);
|
||||
|
||||
const finishQueue = useCallback(() => {
|
||||
if (hasDisplayedRef.current) writeSession(DISMISSED_KEY, signature);
|
||||
else removeSession(DISMISSED_KEY);
|
||||
removeSession(PROGRESS_KEY);
|
||||
setIndex(-1);
|
||||
setQueueIdle(true);
|
||||
}, [signature]);
|
||||
|
||||
const showNext = useCallback(() => {
|
||||
setIndex((current) => {
|
||||
const next = current + 1;
|
||||
if (next >= ads.length) {
|
||||
finishQueue();
|
||||
return -1;
|
||||
}
|
||||
rememberProgress(signature, next);
|
||||
return next;
|
||||
});
|
||||
}, [ads.length, finishQueue, signature]);
|
||||
|
||||
const skipBroken = useCallback(() => {
|
||||
showNext();
|
||||
}, [showNext]);
|
||||
|
||||
useEffect(() => {
|
||||
if (index < 0 || index >= ads.length) return;
|
||||
hasDisplayedRef.current = true;
|
||||
rememberProgress(signature, index);
|
||||
setQueueIdle(false);
|
||||
}, [index, ads.length, signature]);
|
||||
|
||||
useEffect(() => {
|
||||
if (index < 0) return;
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") showNext();
|
||||
};
|
||||
const { overflow } = document.body.style;
|
||||
document.body.style.overflow = "hidden";
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
document.body.style.overflow = overflow;
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [index, showNext]);
|
||||
|
||||
const ad = index >= 0 && index < ads.length ? ads[index] : null;
|
||||
const adId = ad ? String(ad.advertiseCode || ad.id || "") : "";
|
||||
const trackAttrs = ad
|
||||
? adTrackProps(ad, {
|
||||
slotKey: "dialog_ad",
|
||||
slotName: "弹窗广告位",
|
||||
adType: "interstitial",
|
||||
})
|
||||
: {};
|
||||
|
||||
useEffect(() => {
|
||||
if (!ad?.images || !adId) return;
|
||||
document.dispatchEvent(
|
||||
new CustomEvent("dialog-ad:track", {
|
||||
detail: {
|
||||
eventType: "show",
|
||||
element: Object.assign(document.createElement("div"), {
|
||||
dataset: {
|
||||
advertisingKey: "dialog_ad",
|
||||
advertisingName: "弹窗广告",
|
||||
advertisingId: adId,
|
||||
dialogAdId: adId,
|
||||
},
|
||||
}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}, [ad, adId]);
|
||||
|
||||
if (!ad?.images) return null;
|
||||
|
||||
const picture = (
|
||||
<DecryptedImage
|
||||
src={ad.images}
|
||||
alt={ad.title || "广告"}
|
||||
priority
|
||||
className="h-[468px] w-[300px] rounded-3xl object-cover lg:h-[602px] lg:w-[400px] lg:rounded-[40px]"
|
||||
onError={skipBroken}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="ad-dialog"
|
||||
className="fixed inset-0 z-50 flex flex-col items-center overflow-y-auto bg-black/70 px-4 py-10"
|
||||
onClick={(event) => event.target === event.currentTarget && showNext()}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={ad.title || "广告"}
|
||||
className="my-auto flex flex-col items-center gap-9 lg:gap-6"
|
||||
onClick={(event) => event.target === event.currentTarget && showNext()}
|
||||
data-advertising-key="dialog_ad"
|
||||
data-advertising-name="弹窗广告"
|
||||
data-advertising-id={adId}
|
||||
data-dialog-ad-id={adId}
|
||||
>
|
||||
{ad.link ? (
|
||||
<a
|
||||
href={ad.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer sponsored"
|
||||
className="block"
|
||||
{...trackAttrs}
|
||||
onClick={() => {
|
||||
document.dispatchEvent(
|
||||
new CustomEvent("dialog-ad:track", {
|
||||
detail: {
|
||||
eventType: "click",
|
||||
element: Object.assign(document.createElement("div"), {
|
||||
dataset: {
|
||||
advertisingKey: "dialog_ad",
|
||||
advertisingName: "弹窗广告",
|
||||
advertisingId: adId,
|
||||
dialogAdId: adId,
|
||||
},
|
||||
}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}}
|
||||
>
|
||||
{picture}
|
||||
</a>
|
||||
) : (
|
||||
picture
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭广告"
|
||||
data-dialog-ad-close=""
|
||||
onClick={showNext}
|
||||
className="size-9 cursor-pointer"
|
||||
>
|
||||
<svg viewBox="0 0 36 36" className="size-full" aria-hidden="true">
|
||||
<circle cx="18" cy="18" r="14" fill="none" stroke="white" strokeWidth="2" />
|
||||
<path d="M13 13l10 10M23 13L13 23" stroke="white" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user