import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Button } from '@/components/ui/button'; import { useFocusTrap } from '../hooks/use-focus-trap'; import { useScrollLock } from '../hooks/use-scroll-lock'; import { trackPromoModalViewed, trackPromoModalClosed, trackPromoModalCtaClicked, } from '../lib/mixpanel'; import { NEWS_FEED_CONSENT_EVENT, readNewsFeedConsent } from '../lib/privacy-settings'; function isSafeHttpUrl(url: string): boolean { try { const parsed = new URL(url, window.location.origin); return parsed.protocol === 'http:' || parsed.protocol === 'https:'; } catch { return false; } } /** A single call-to-action button. An empty/absent `url` makes it a plain close button. */ export interface PromoCta { label: string; url?: string; } /** * Centered promo popup driven by external JSON (see includes/data/promo-modals.sample.json * and class-promo-modal.php). `conditions` are evaluated server-side (plugin / plan / date) * and are NOT sent to the browser — items received here already passed their conditions. * Frequency capping (`maxDisplays`) and dismissal are handled client-side via localStorage. */ export interface PromoModalItem { /** Unique, stable id — used for frequency capping + dismissal persistence. */ id: string; eyebrow?: string; title?: string; description?: string; /** The image is the centerpiece of the popup. */ imageSrc?: string; imageAlt?: string; /** Optional: makes the image itself a link. */ imageLink?: string; imageLinkInternal?: boolean; /** Whether to render the X button (default true). */ dismissible?: boolean; /** How many times this modal may appear on this browser (default 1). */ maxDisplays?: number; /** Higher wins when several modals are eligible at once. */ priority?: number; /** * Optional hard stop date. After this moment the modal is never shown. * `YYYY-MM-DD` = inclusive through end of that day; or any ISO / parseable datetime. * Empty or omitted = no expiry. */ expiresAt?: string; primaryCta?: PromoCta; primaryCtaInternal?: boolean; secondaryCta?: PromoCta; secondaryCtaInternal?: boolean; } interface PromoApiResponse { success: boolean; configured?: boolean; consent_required?: boolean; feedVersion?: string; lastUpdated?: string; items?: PromoModalItem[]; error?: string; } interface PerModalState { count: number; done: boolean; } type PromoState = Record; const STORAGE_KEY = 'llmagnet_promo_modal_state_v1'; function getRestConfig(): { root: string; nonce: string } | null { const w = window as { wpApiSettings?: { root: string; nonce: string } }; if (w.wpApiSettings?.root && w.wpApiSettings?.nonce) { return { root: w.wpApiSettings.root.replace(/\/$/, ''), nonce: w.wpApiSettings.nonce }; } return null; } function readState(): PromoState { try { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return {}; const parsed = JSON.parse(raw) as PromoState; return parsed && typeof parsed === 'object' ? parsed : {}; } catch { return {}; } } function writeState(state: PromoState) { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } catch { /* ignore */ } } function clearState() { try { localStorage.removeItem(STORAGE_KEY); } catch { /* ignore */ } } /** Dev helper: ?llmagnet_promo_reset=1 clears display state, then strips the param from the URL. */ function consumePromoResetParam(): boolean { const params = new URLSearchParams(window.location.search); const reset = params.get('llmagnet_promo_reset'); if (reset !== '1' && reset !== 'true') { return false; } clearState(); params.delete('llmagnet_promo_reset'); const next = params.toString(); const url = `${window.location.pathname}${next ? `?${next}` : ''}${window.location.hash}`; window.history.replaceState(window.history.state, '', url); return true; } /** True when `expiresAt` is empty or the current time is still on/before it. */ function isWithinExpiry(modal: PromoModalItem): boolean { const raw = modal.expiresAt?.trim(); if (!raw) return true; const end = /^\d{4}-\d{2}-\d{2}$/.test(raw) ? new Date(`${raw}T23:59:59`) : new Date(raw); if (Number.isNaN(end.getTime())) return true; return Date.now() <= end.getTime(); } /** First modal (highest priority) that is not done and still under its display cap. */ function pickEligible(items: PromoModalItem[], state: PromoState): PromoModalItem | null { const eligible = items .filter((m) => m && typeof m.id === 'string' && m.id !== '') .filter(isWithinExpiry) .filter((m) => { const s = state[m.id]; const max = typeof m.maxDisplays === 'number' && m.maxDisplays > 0 ? m.maxDisplays : 1; if (s?.done) return false; return (s?.count ?? 0) < max; }); if (eligible.length === 0) return null; eligible.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0)); return eligible[0]; } export function PromoModal() { const [active, setActive] = useState(null); const dialogRef = useRef(null); const viewedRef = useRef(null); const close = useCallback( (method: 'backdrop' | 'x' | 'secondary' | 'escape') => { setActive((current) => { if (current) trackPromoModalClosed(current.id, method); return null; }); }, [], ); useFocusTrap(dialogRef, { active: !!active, onEscape: () => close('escape') }); useScrollLock(!!active); useEffect(() => { let cancelled = false; const loadPromo = async () => { if (!readNewsFeedConsent()) { return; } const cfg = getRestConfig(); if (!cfg) return; consumePromoResetParam(); try { const res = await fetch(`${cfg.root}/llm-analytics/v1/promo-modal`, { headers: { 'X-WP-Nonce': cfg.nonce }, credentials: 'same-origin', }); const data = (await res.json()) as PromoApiResponse; if (cancelled || !data.success || data.consent_required) return; const items = data.items || []; const state = readState(); const chosen = pickEligible(items, state); if (!chosen) return; const next: PromoState = { ...state }; const prev = next[chosen.id] ?? { count: 0, done: false }; next[chosen.id] = { ...prev, count: prev.count + 1 }; writeState(next); if (viewedRef.current !== chosen.id) { viewedRef.current = chosen.id; trackPromoModalViewed(chosen.id, next[chosen.id].count); } setActive(chosen); } catch { /* silently ignore — a promo popup should never block the UI */ } }; void loadPromo(); const onConsent = (event: Event) => { const enabled = (event as CustomEvent<{ enabled?: boolean }>).detail?.enabled; if (enabled) { void loadPromo(); } }; window.addEventListener(NEWS_FEED_CONSENT_EVENT, onConsent); return () => { cancelled = true; window.removeEventListener(NEWS_FEED_CONSENT_EVENT, onConsent); }; }, []); const markDone = useCallback((id: string) => { const state = readState(); const prev = state[id] ?? { count: 0, done: false }; writeState({ ...state, [id]: { ...prev, done: true } }); }, []); const handleCta = useCallback( (cta: PromoCta | undefined, type: 'primary' | 'secondary', internal: boolean) => { if (!active) return; const url = cta?.url?.trim() || ''; trackPromoModalCtaClicked(active.id, type, cta?.label || '', url, internal); if (!url) { // No URL → this button is just a "close" action. close(type === 'secondary' ? 'secondary' : 'x'); return; } // Taking a CTA action → don't show this modal again. markDone(active.id); if (!isSafeHttpUrl(url)) { close(type === 'secondary' ? 'secondary' : 'x'); return; } if (internal) { window.location.href = url; } else { window.open(url, '_blank', 'noopener,noreferrer'); setActive(null); } }, [active, close, markDone], ); const secondaryOnlyCloses = useMemo( () => !(active?.secondaryCta?.url && active.secondaryCta.url.trim() !== ''), [active], ); if (!active) return null; return (
{ if (e.target === e.currentTarget) close('backdrop'); }} >
e.stopPropagation()} > {active.imageSrc && ( { if (active.imageLink) { trackPromoModalCtaClicked( active.id, 'primary', active.imageAlt || 'image', active.imageLink, active.imageLinkInternal ?? false, ); markDone(active.id); } }} /> )}
{active.eyebrow && (

{active.eyebrow}

)} {active.title && (

{active.title}

)} {active.description && (

{active.description}

)} {(active.primaryCta?.label || active.secondaryCta?.label) && (
{active.primaryCta?.label && ( )} {active.secondaryCta?.label && ( )}
)}
); } interface PromoImageProps { src: string; alt: string; href?: string; internal?: boolean; onLinkClick: () => void; } function PromoImage({ src, alt, href, internal, onLinkClick }: PromoImageProps) { const img = ( {alt} { const el = (e.target as HTMLImageElement).parentElement; if (el) el.style.display = 'none'; }} /> ); const wrapStyle: React.CSSProperties = { width: '100%', display: 'block', lineHeight: 0, }; if (href) { return ( {img} ); } return
{img}
; }