mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
250 lines
10 KiB
React
250 lines
10 KiB
React
import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
|
|
import { useNavigate } from "react-router-dom";
|
|
import api from "@/utils/api.util";
|
|
import ResponsiveModal from "@/components/generic/ResponsiveModal";
|
|
import { Button } from "@/components/ui/button";
|
|
import { useProfile } from "@/contexts/ProfileProvider";
|
|
|
|
const ClientAdvertisementsContext = createContext(null);
|
|
|
|
// After this many clicks on the same ad in one browser session, further clicks
|
|
// are held behind a confirmation dialog instead of following through straight
|
|
// away — guards against accidental/rapid repeat clicks inflating ad clicks.
|
|
const CLICK_LIMIT = 3;
|
|
const CLICK_STORAGE_KEY = "ad_click_counts";
|
|
|
|
function loadClickCounts() {
|
|
try {
|
|
return JSON.parse(sessionStorage.getItem(CLICK_STORAGE_KEY)) || {};
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
export function useClientAdvertisements() {
|
|
const ctx = useContext(ClientAdvertisementsContext);
|
|
if (!ctx) throw new Error("useClientAdvertisements must be used within a ClientAdvertisementsProvider");
|
|
return ctx;
|
|
}
|
|
|
|
export function ClientAdvertisementsProvider({ children }) {
|
|
const navigate = useNavigate();
|
|
const { profile, getProfile } = useProfile();
|
|
|
|
// Keyed by placement so multiple slots on the same page (e.g. dashboard.hero +
|
|
// dashboard.popup) can be fetched independently without clobbering each other.
|
|
const [advertisements, setAdvertisements] = useState({});
|
|
const [loading, setLoading] = useState({});
|
|
|
|
// Keyed by placement, holds the full list for carousel-style slots (e.g.
|
|
// dashboard.hero) — separate from `advertisements` above, which only ever
|
|
// holds the single highest-priority ad per placement.
|
|
const [adLists, setAdLists] = useState({});
|
|
const [listLoading, setListLoading] = useState({});
|
|
const [clickCounts, setClickCounts] = useState(loadClickCounts);
|
|
const [pendingClick, setPendingClick] = useState(null); // { ad, cta } awaiting confirmation
|
|
|
|
// Ad fetches must know the real preference before deciding visibility — never
|
|
// assume "show" as a default just because profile hasn't loaded yet. profileRef
|
|
// always holds the latest profile so even a stale fetch-function reference
|
|
// (captured by a page's mount-only effect) reads current data when it runs.
|
|
const profileRef = useRef(profile);
|
|
useEffect(() => { profileRef.current = profile; }, [profile]);
|
|
|
|
const profileFetchRef = useRef(null);
|
|
const ensureProfile = useCallback(async () => {
|
|
if (profileRef.current) return profileRef.current;
|
|
if (!profileFetchRef.current) {
|
|
profileFetchRef.current = getProfile().finally(() => { profileFetchRef.current = null; });
|
|
}
|
|
const fresh = await profileFetchRef.current;
|
|
profileRef.current = fresh;
|
|
return fresh;
|
|
}, [getProfile]);
|
|
|
|
// Gated by the Settings → Advertisements "Other ads" toggle.
|
|
const resolveVisibility = (profileData, ad) => {
|
|
if (!ad) return ad;
|
|
const showOtherAds = profileData?.personal_info?.show_other_ads ?? true;
|
|
return showOtherAds ? ad : null;
|
|
};
|
|
|
|
// ─── GET /api/client/advertisements/active?placement=dashboard.hero ───────
|
|
const getActiveAdvertisement = useCallback(
|
|
async (placement) => {
|
|
setLoading((prev) => ({ ...prev, [placement]: true }));
|
|
try {
|
|
const [currentProfile, { data }] = await Promise.all([
|
|
ensureProfile(),
|
|
api.get("/client/advertisements/active", { params: { placement } }),
|
|
]);
|
|
const ad = resolveVisibility(currentProfile, data?.data?.data ?? null);
|
|
setAdvertisements((prev) => ({ ...prev, [placement]: ad }));
|
|
return ad;
|
|
} catch {
|
|
setAdvertisements((prev) => ({ ...prev, [placement]: null }));
|
|
return null;
|
|
} finally {
|
|
setLoading((prev) => ({ ...prev, [placement]: false }));
|
|
}
|
|
},
|
|
[ensureProfile]
|
|
);
|
|
|
|
// ─── GET /api/client/advertisements/active-batch?placements=a,b,c ─────────
|
|
// Resolves several placements in one round-trip — use for any page that
|
|
// needs more than one simultaneous slot.
|
|
const getActiveAdvertisements = useCallback(
|
|
async (placements) => {
|
|
if (!placements?.length) return {};
|
|
setLoading((prev) => {
|
|
const next = { ...prev };
|
|
placements.forEach((p) => { next[p] = true; });
|
|
return next;
|
|
});
|
|
try {
|
|
const [currentProfile, { data }] = await Promise.all([
|
|
ensureProfile(),
|
|
api.get("/client/advertisements/active-batch", {
|
|
params: { placements: placements.join(",") },
|
|
}),
|
|
]);
|
|
const raw = data?.data?.data ?? {};
|
|
const result = Object.fromEntries(
|
|
Object.entries(raw).map(([placement, ad]) => [placement, resolveVisibility(currentProfile, ad)])
|
|
);
|
|
setAdvertisements((prev) => ({ ...prev, ...result }));
|
|
return result;
|
|
} catch {
|
|
const fallback = Object.fromEntries(placements.map((p) => [p, null]));
|
|
setAdvertisements((prev) => ({ ...prev, ...fallback }));
|
|
return fallback;
|
|
} finally {
|
|
setLoading((prev) => {
|
|
const next = { ...prev };
|
|
placements.forEach((p) => { next[p] = false; });
|
|
return next;
|
|
});
|
|
}
|
|
},
|
|
[ensureProfile]
|
|
);
|
|
|
|
// ─── GET /api/client/advertisements/active-list?placement=dashboard.hero ──
|
|
// Resolves every live ad for one placement — use for carousel-style slots
|
|
// that rotate through several ads instead of showing just the winner.
|
|
const getActiveAdvertisementList = useCallback(
|
|
async (placement, limit) => {
|
|
setListLoading((prev) => ({ ...prev, [placement]: true }));
|
|
try {
|
|
const [currentProfile, { data }] = await Promise.all([
|
|
ensureProfile(),
|
|
api.get("/client/advertisements/active-list", { params: { placement, limit } }),
|
|
]);
|
|
const raw = data?.data?.data ?? [];
|
|
const list = raw
|
|
.map((ad) => resolveVisibility(currentProfile, ad))
|
|
.filter(Boolean);
|
|
setAdLists((prev) => ({ ...prev, [placement]: list }));
|
|
return list;
|
|
} catch {
|
|
setAdLists((prev) => ({ ...prev, [placement]: [] }));
|
|
return [];
|
|
} finally {
|
|
setListLoading((prev) => ({ ...prev, [placement]: false }));
|
|
}
|
|
},
|
|
[ensureProfile]
|
|
);
|
|
|
|
// ─── POST /api/client/advertisements/:advertisementId/click ───────────────
|
|
// Fire-and-forget — never await this on a navigation-blocking path.
|
|
const trackClick = useCallback(
|
|
(advertisementId) => {
|
|
if (!advertisementId) return;
|
|
api.post(`/client/advertisements/${advertisementId}/click`).catch(() => {});
|
|
},
|
|
[]
|
|
);
|
|
|
|
// Tracks the click then follows the link: the CTA's own link wins if present,
|
|
// else the ad's redirect_link, else its internal Page Builder landing page
|
|
// (/ads/:uuid) if one was authored. External links open in a new tab.
|
|
const goToCta = useCallback(
|
|
(ad, cta) => {
|
|
trackClick(ad?.advertisement_id);
|
|
const link = cta?.link || ad?.redirect_link || (ad?.landing_page ? `/ads/${ad.uuid}` : null);
|
|
if (!link) return;
|
|
if (/^https?:\/\//.test(link)) {
|
|
window.open(link, "_blank", "noopener,noreferrer");
|
|
} else {
|
|
navigate(link);
|
|
}
|
|
},
|
|
[navigate, trackClick]
|
|
);
|
|
|
|
// ─── CTA click guard ────────────────────────────────────────────────────
|
|
// Shared onCtaClick for every ad block (Banner/Hero/Sidebar/Popup). Counts
|
|
// clicks per advertisement for the browser session; once the limit is
|
|
// exceeded, hold the click behind a confirmation dialog instead of
|
|
// silently continuing.
|
|
const handleAdCtaClick = useCallback(
|
|
(ad, cta) => {
|
|
const id = ad?.advertisement_id;
|
|
if (!id) {
|
|
goToCta(ad, cta);
|
|
return;
|
|
}
|
|
|
|
const nextCount = (clickCounts[id] || 0) + 1;
|
|
setClickCounts((prev) => {
|
|
const next = { ...prev, [id]: nextCount };
|
|
sessionStorage.setItem(CLICK_STORAGE_KEY, JSON.stringify(next));
|
|
return next;
|
|
});
|
|
|
|
if (nextCount <= CLICK_LIMIT) {
|
|
goToCta(ad, cta);
|
|
} else {
|
|
setPendingClick({ ad, cta });
|
|
}
|
|
},
|
|
[clickCounts, goToCta]
|
|
);
|
|
|
|
const confirmPendingClick = () => {
|
|
if (pendingClick) goToCta(pendingClick.ad, pendingClick.cta);
|
|
setPendingClick(null);
|
|
};
|
|
|
|
return (
|
|
<ClientAdvertisementsContext.Provider value={{
|
|
advertisements,
|
|
loading,
|
|
getActiveAdvertisement,
|
|
getActiveAdvertisements,
|
|
adLists,
|
|
listLoading,
|
|
getActiveAdvertisementList,
|
|
trackClick,
|
|
handleAdCtaClick,
|
|
}}>
|
|
{children}
|
|
|
|
<ResponsiveModal
|
|
open={!!pendingClick}
|
|
onOpenChange={(open) => { if (!open) setPendingClick(null); }}
|
|
title="Continue to this ad?"
|
|
description="You've clicked this advertisement several times already. Confirm you'd like to keep visiting it."
|
|
footer={
|
|
<>
|
|
<Button variant="outline" onClick={() => setPendingClick(null)}>Cancel</Button>
|
|
<Button onClick={confirmPendingClick}>Continue</Button>
|
|
</>
|
|
}
|
|
/>
|
|
</ClientAdvertisementsContext.Provider>
|
|
);
|
|
}
|