import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode, } from 'react'; import { useLocation } from 'react-router-dom'; import type { ISeoBadgeCheck, ISeoBadges } from '../service/seo/seo.interface'; import { recomaze_ai_personalization_env } from '../env'; import { fetchSeoBadges } from '../service/seo/seo.service'; export type NavCountTone = 'danger' | 'warning'; export interface NavCount { count: number; tone: NavCountTone; } /** Keyed by route: `/seo/checkup?group=server`, `/seo/titles?section=homepage`. */ export type NavCounts = ReadonlyMap; interface SeoBadgesValue { badges: ISeoBadges | null; counts: NavCounts; refresh: () => void; } const SeoBadgesContext = createContext({ badges: null, counts: new Map(), refresh: () => undefined, }); const PENDING_RETRY_MS = 15000; const PENDING_RETRIES = 6; const CHECKUP = '/seo/checkup'; const CHECK_ROUTES: Record = { frequent_404: '/seo/404', broken_links: '/seo/links', orphans: '/seo/links', }; const add = ( counts: Map, route: string, count: number, severity: ISeoBadgeCheck['severity'] ): void => { if (count <= 0) return; const current = counts.get(route); const danger = severity === 'error' || current?.tone === 'danger'; counts.set(route, { count: (current?.count ?? 0) + count, tone: danger ? 'danger' : 'warning', }); }; /** * Every count is of failed checks that need doing, problems and warnings: * the checkup and its groups, the 404 and Links pages (the checks they * answer, not their rows), and a settings page or section that fixes one. * Suggestions stay inside the checkup, so no menu row nags about them. */ export function countsFromChecks(checks: ISeoBadgeCheck[]): NavCounts { const counts = new Map(); checks.forEach(check => { if (check.severity === 'info') { return; } add(counts, CHECKUP, 1, check.severity); add(counts, `${CHECKUP}?group=${check.group}`, 1, check.severity); const own = CHECK_ROUTES[check.id]; if (own && own !== check.to?.split('?')[0]) add(counts, own, 1, check.severity); if (check.to) { add(counts, check.to, 1, check.severity); const page = check.to.split('?')[0]; if (page !== check.to) add(counts, page, 1, check.severity); } }); return counts; } export function SeoBadgesProvider({ children, }: { children: ReactNode; }): JSX.Element { const [badges, setBadges] = useState(null); const retries = useRef(0); const timer = useRef(null); const location = useLocation(); const load = useCallback(async (): Promise => { // No module on this site, no SEO routes to ask. if (!(recomaze_ai_personalization_env?.seo_enabled ?? true)) { return; } try { const next = await fetchSeoBadges(); setBadges(next); if (next.pending && retries.current < PENDING_RETRIES) { retries.current += 1; timer.current = window.setTimeout(() => void load(), PENDING_RETRY_MS); } } catch { // No capability or no WP REST: the menu shows no counts. } }, []); useEffect(() => { void load(); return () => { if (timer.current) window.clearTimeout(timer.current); }; }, [load]); // A fix on any SEO page changes what the menu says; the read is cached. const seoPath = location.pathname.startsWith('/seo') ? location.pathname : null; useEffect(() => { if (seoPath) void load(); }, [seoPath, load]); const counts = useMemo( () => countsFromChecks(badges?.checks ?? []), [badges] ); const value = useMemo( () => ({ badges, counts, refresh: () => void load() }), [badges, counts, load] ); return ( {children} ); } export const useSeoBadges = (): SeoBadgesValue => useContext(SeoBadgesContext); export const navCount = ( counts: NavCounts, to: string | undefined ): NavCount | undefined => (to ? counts.get(to) : undefined);