Files
starr-philproperties/src/contexts/ClientAdvertisementContext.jsx
T
kennethobsequio fa92d924f4 added
Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
2026-07-12 12:39:47 +08:00

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>
);
}