import { useEffect, useId, useRef, useState } from "react"; import { createRoot } from "react-dom/client"; import { CauseWiseFunnel, CauseWiseGoals, CauseWiseStats, CauseWiseTopPages, CauseWiseTrafficChart, DEFAULT_DASHBOARD_LAYOUT, chartTypeForPanel, } from "@razorsoft/causewise-react"; import type { FunnelData, FunnelsSection, GoalsSection, OverviewStats, RecentEvent, ReportingPeriod, StatusNotice, TopPage, TrafficSeries, } from "@razorsoft/causewise-react"; import componentCss from "@razorsoft/causewise-react/styles.css"; import { adminBarOffset, AnalyticsAnchor, RecentEventsPanel, SectionNav, SectionPlaceholder, type EventFilter, } from "./analytics-screen"; import { dashboardHeadingFromCleanTitle } from "./dashboard-title"; import { Referrers } from "./referrers"; import adminCss from "./admin.css"; type RangeId = "today" | "7d" | "30d"; type ViewName = "overview" | "dashboard" | "events"; type Variant = "dashboard" | "stats" | "pageviews" | "sessions" | "traffic" | "pages"; type Referrer = { label: string; count: number }; type SiteChoice = { id: string; name: string; domain: string }; type AnalyticsPayload = { ok: true; range: string; site: { id: string; name: string; domain: string }; advancedUrl: string; period: ReportingPeriod; stats: OverviewStats; trend: TrafficSeries | null; topPages: TopPage[]; referrers: Referrer[] | null; goals: GoalsSection | null; funnels: FunnelsSection | null; recentEvents: RecentEvent[] | null; overviewError: StatusNotice | null; summaryError: StatusNotice | null; }; type ErrorPayload = { ok: false; code?: string; message?: string; sites?: SiteChoice[]; }; type ClientConfig = { restUrl: string; nonce: string; dashboardUrl: string; settingsUrl: string; }; declare global { interface Window { causewiseAnalytics?: ClientConfig; } } const trafficChart = chartTypeForPanel(DEFAULT_DASHBOARD_LAYOUT, "traffic"); const RANGES: { id: RangeId; label: string }[] = [ { id: "today", label: "Today" }, { id: "7d", label: "Last 7 days" }, { id: "30d", label: "Last 30 days" }, ]; const WIDGET_RANGES: { id: RangeId; label: string }[] = [ { id: "today", label: "Today" }, { id: "7d", label: "7 Days" }, { id: "30d", label: "30 Days" }, ]; const pending = new Map>(); function rangeLabel(range: string | undefined) { return RANGES.find((item) => item.id === range)?.label ?? range ?? ""; } function formatCount(value: number) { return new Intl.NumberFormat("en-US").format(value); } function formatDate(value: string) { const date = new Date(value); if (Number.isNaN(date.getTime())) return value; return new Intl.DateTimeFormat(undefined, { dateStyle: "medium" }).format(date); } function safeAdvancedUrl(url: string | undefined) { try { const parsed = new URL(url ?? ""); if (parsed.protocol === "https:" && parsed.hostname === "causewise.io") { return parsed.toString(); } } catch { /* Use the workspace dashboard when the payload has no site link. */ } return "https://causewise.io/dashboard"; } function siteHref(siteId: string | undefined, suffix: string) { if (!siteId || !/^[A-Za-z0-9_-]{1,64}$/.test(siteId)) { return "https://causewise.io/dashboard"; } return safeAdvancedUrl(`https://causewise.io/sites/${encodeURIComponent(siteId)}${suffix}`); } function userMessage(message: string | undefined, fallback: string) { const text = message?.trim() ?? ""; if (!text || /dashboard_|denominator|\bscope\b|rollup|\bpayloads?\b|property condition|visitor id|pass the overview|pass funnel|were not included|step detail was not/i.test(text)) { return fallback; } return text; } function formatRate(value: number | null | undefined) { if (value == null) return "Not available"; return `${new Intl.NumberFormat("en-US", { maximumFractionDigits: 1 }).format(value)}%`; } function hasFunnelSteps(funnel: FunnelData) { return (funnel.steps ?? []).some( (step) => typeof step?.stepId === "string" && typeof step.name === "string" && typeof step.visitors === "number", ); } function config(): ClientConfig | null { const value = window.causewiseAnalytics; if (!value?.restUrl || !value.nonce) return null; return value; } async function fetchAnalytics(range: string, view: ViewName): Promise { const client = config(); if (!client) { return { ok: false, code: "client_error", message: "Analytics could not be loaded." }; } const url = new URL(client.restUrl, window.location.href); url.searchParams.set("range", range); url.searchParams.set("view", view); let response: Response; try { response = await fetch(url.toString(), { credentials: "same-origin", headers: { Accept: "application/json", "X-WP-Nonce": client.nonce, }, }); } catch { return { ok: false, code: "network_error", message: "CauseWise could not be reached." }; } const body = (await response.json().catch(() => null)) as | (AnalyticsPayload & ErrorPayload) | null; if (!body || typeof body !== "object") { return { ok: false, code: "causewise_error", message: "Analytics could not be loaded." }; } if (body.ok === false || !response.ok) { return { ok: false, code: typeof body.code === "string" ? body.code : "causewise_error", message: typeof body.message === "string" ? body.message : "Analytics could not be loaded.", sites: Array.isArray(body.sites) ? body.sites : undefined, }; } return body; } type EventsPage = { ok: true; events: RecentEvent[]; nextCursor: string | null; filter: EventFilter; range: string; limit: number; }; function visibleEvents(value: unknown): RecentEvent[] { if (!Array.isArray(value)) return []; return value.map((item) => { const event = item && typeof item === "object" ? (item as Record) : {}; return { id: typeof event.id === "string" ? event.id : "", type: typeof event.type === "string" ? event.type : "", path: typeof event.path === "string" ? event.path : "", timestamp: typeof event.timestamp === "string" ? event.timestamp : "", }; }); } async function fetchEvents(input: { range: string; filter: EventFilter; limit: number; cursor?: string | null; }): Promise { const client = config(); if (!client) { return { ok: false, code: "client_error", message: "Recent events could not be loaded." }; } const url = new URL(client.restUrl, window.location.href); url.searchParams.set("view", "events"); url.searchParams.set("range", input.range); url.searchParams.set("filter", input.filter); url.searchParams.set("limit", String(input.limit)); if (input.cursor) url.searchParams.set("cursor", input.cursor); let response: Response; try { response = await fetch(url.toString(), { credentials: "same-origin", headers: { Accept: "application/json", "X-WP-Nonce": client.nonce, }, }); } catch { return { ok: false, code: "network_error", message: "CauseWise could not be reached." }; } const body = (await response.json().catch(() => null)) as | (EventsPage & ErrorPayload & { events?: unknown; nextCursor?: unknown; filter?: unknown }) | null; if (!body || typeof body !== "object") { return { ok: false, code: "causewise_error", message: "Recent events could not be loaded." }; } if (body.ok === false || !response.ok) { return { ok: false, code: typeof body.code === "string" ? body.code : "causewise_error", message: typeof body.message === "string" ? body.message : "Recent events could not be loaded.", }; } const filter = body.filter === "pageviews" || body.filter === "custom" ? body.filter : input.filter; return { ok: true, events: visibleEvents(body.events), nextCursor: typeof body.nextCursor === "string" && body.nextCursor ? body.nextCursor : null, filter, range: typeof body.range === "string" ? body.range : input.range, limit: input.limit, }; } function loadAnalytics(range: string, view: ViewName) { const key = `${view}:${range}`; const existing = pending.get(key); if (existing) return existing; const request = fetchAnalytics(range, view).finally(() => { pending.delete(key); }); pending.set(key, request); return request; } function SettingsLink({ code }: { code?: string }) { const href = config()?.settingsUrl; if (!href) return null; const label = code === "not_configured" || code === "site_required" ? "Open settings" : "Review settings"; return (

{label}

); } function Notice({ title, message, code, sites, tone, }: { title: string; message: string; code?: string; sites?: SiteChoice[]; tone: "error" | "empty"; }) { const titleId = useId(); return (

{title}

{userMessage(message, "Analytics could not be loaded. Check the connection in settings.")}

{sites && sites.length > 0 ? ( <>

Use one of these site IDs in settings.

    {sites.map((site) => (
  • {[site.name, site.domain].filter(Boolean).join(" · ") || "Site"} {site.id}
  • ))}
) : null}
); } function PeriodLine({ period }: { period?: ReportingPeriod | null }) { if (!period) return null; const dates = period.start && period.end ? `, ${formatDate(period.start)} to ${formatDate(period.end)}` : ""; const retention = period.retentionDays != null ? `. CauseWise keeps this data for ${period.retentionDays} days.` : ""; return (

{rangeLabel(period.range)} {dates} {retention}

); } function ExternalLink({ href, children }: { href: string; children: string }) { return (

{children}

); } function EmptyGuide({ title, headline, body, href, linkLabel, }: { title: string; headline: string; body: string; href: string; linkLabel: string; }) { const titleId = useId(); return (

{title}

{headline}

{body}

{linkLabel}
); } function GoalsPanel({ goals, siteId }: { goals: GoalsSection; siteId: string }) { const manageHref = siteHref(siteId, "#goals"); if (!goals.available) { return ( ); } if (goals.goals.length === 0) { return ( ); } return ( <> {goals.truncated ? (

Some goals are not shown here.{" "} Manage Goals

) : null} ); } function FunnelBlock({ funnel, manageHref }: { funnel: FunnelData; manageHref: string }) { if (!hasFunnelSteps(funnel)) { const titleId = useId(); return (

{funnel.name || "Funnel"}

Entered
{formatCount(funnel.entryVisitors)}
Completed
{formatCount(funnel.completionCount)}
Completion rate
{formatRate(funnel.overallConversionRate)}

Open CauseWise to see each step of this funnel.

Manage Funnels
); } return ( ); } function FunnelsPanel({ funnels, siteId }: { funnels: FunnelsSection; siteId: string }) { const manageHref = siteHref(siteId, "/funnels"); if (!funnels.available) { return ( ); } if (funnels.funnels.length === 0) { return ( ); } return ( <> {funnels.funnels.map((funnel) => ( ))} {funnels.truncated ? (

Some funnels are not shown here.{" "} Manage Funnels

) : null} ); } function MissingSection({ title, message }: { title: string; message: string }) { const titleId = useId(); return (

{title}

{message}

); } function TrafficEmpty() { const titleId = useId(); return (

Traffic

No traffic in this period.

); } export function DashboardScreen({ initialRange }: { initialRange: string }) { const [requested, setRequested] = useState(initialRange); const [payload, setPayload] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(true); const [eventFilter, setEventFilter] = useState("all"); const [pageSize, setPageSize] = useState(20); const [eventRows, setEventRows] = useState(null); const [nextCursor, setNextCursor] = useState(null); const [eventsLoading, setEventsLoading] = useState(true); const [eventsRefreshing, setEventsRefreshing] = useState(false); const [eventsLoadingMore, setEventsLoadingMore] = useState(false); const [eventsError, setEventsError] = useState(null); const requestSerial = useRef(0); const eventsSerial = useRef(0); useEffect(() => { const serial = ++requestSerial.current; let cancelled = false; setLoading(true); loadAnalytics(requested, "dashboard").then((result) => { if (cancelled || serial !== requestSerial.current) return; setLoading(false); if (result.ok) { setPayload(result); setError(null); } else { setPayload(null); setError(result); } }); return () => { cancelled = true; }; }, [requested]); useEffect(() => { const serial = ++eventsSerial.current; let cancelled = false; setEventsLoading(true); setEventsRefreshing(false); setEventsLoadingMore(false); setEventsError(null); setEventRows(null); setNextCursor(null); fetchEvents({ range: requested, filter: eventFilter, limit: pageSize }).then((result) => { if (cancelled || serial !== eventsSerial.current) return; setEventsLoading(false); if (result.ok) { setEventRows(result.events); setNextCursor(result.nextCursor); } else { setEventRows(null); setEventsError(userMessage(result.message, "Recent events could not be loaded.")); } }); return () => { cancelled = true; }; }, [requested, eventFilter, pageSize]); function refreshEvents() { const serial = ++eventsSerial.current; const range = requested; const filter = eventFilter; const limit = pageSize; setEventsRefreshing(true); setEventsLoading(false); setEventsError(null); fetchEvents({ range, filter, limit }).then((result) => { if (serial !== eventsSerial.current) return; setEventsRefreshing(false); if (result.ok) { setEventRows(result.events); setNextCursor(result.nextCursor); } else { setEventsError(userMessage(result.message, "Recent events could not be refreshed.")); } }); } function loadMoreEvents() { if (!nextCursor || eventsLoadingMore) return; const serial = eventsSerial.current; const cursor = nextCursor; setEventsLoadingMore(true); setEventsError(null); fetchEvents({ range: requested, filter: eventFilter, limit: pageSize, cursor }).then((result) => { if (serial !== eventsSerial.current) return; setEventsLoadingMore(false); if (result.ok) { setEventRows((current) => [...(current ?? []), ...result.events]); setNextCursor(result.nextCursor); } else { setEventsError(userMessage(result.message, "More events could not be loaded.")); } }); } const active = payload?.range || requested; function selectRange(next: RangeId) { if (next === requested && !error) return; setRequested(next); const url = new URL(window.location.href); url.searchParams.set("range", next); window.history.replaceState(null, "", url); } const screenWatch = loading ? "loading" : payload ? "ready" : "error"; return (

{payload ? dashboardHeadingFromCleanTitle(payload.site.name.trim()) : "Analytics"}

{payload?.site.domain ?

{payload.site.domain}

: null} {payload ? :

{rangeLabel(requested)}

}
{RANGES.map((range) => ( ))}
Advanced analytics
{loading || payload ? : null} {loading ? ( <>
) : null} {!loading && error ? ( ) : null} {!loading && payload ? ( <> {payload.summaryError ? (

{userMessage(payload.summaryError.message, "Some analytics could not be loaded. Check the connection in settings.")}

) : null} {payload.trend && payload.trend.trend.length > 0 ? ( ) : payload.trend ? ( ) : ( )}
{payload.goals ? ( ) : ( )} {payload.funnels ? ( ) : ( )} ) : null}
); } function isRangeId(value: string | null | undefined): value is RangeId { return value === "today" || value === "7d" || value === "30d"; } function storedStatsRange(fallback: string): RangeId { try { const saved = window.localStorage.getItem("causewise-stats-range"); if (isRangeId(saved)) return saved; } catch { /* Storage can be blocked. The widget still works for this visit. */ } return isRangeId(fallback) ? fallback : "7d"; } function useOverview(range: string) { const [payload, setPayload] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(true); useEffect(() => { let cancelled = false; setLoading(true); loadAnalytics(range, "overview").then((result) => { if (cancelled) return; setLoading(false); if (result.ok) { setPayload(result); setError(null); } else { setPayload(null); setError(result); } }); return () => { cancelled = true; }; }, [range]); return { payload, error, loading }; } function StatsWidget({ initialRange }: { initialRange: string }) { const [range, setRange] = useState(() => storedStatsRange(initialRange)); const [tab, setTab] = useState<"pages" | "referrers">("pages"); const { payload, error, loading } = useOverview(range); function selectRange(next: RangeId) { setRange(next); try { window.localStorage.setItem("causewise-stats-range", next); } catch { /* The selected range still applies until the page is reloaded. */ } } return (
{WIDGET_RANGES.map((item) => ( ))}
{loading ?

Loading

: null} {!loading && error ? (

{userMessage(error.message, "Analytics could not be loaded. Check the connection in settings.")}

{error.sites && error.sites.length > 0 ? (
    {error.sites.map((site) => (
  • {[site.name, site.domain].filter(Boolean).join(" · ") || "Site"} {site.id}
  • ))}
) : null}
) : null} {!loading && !error && payload ? ( <>

Page views

{formatCount(payload.stats.pageviews)}

Unique sessions

{formatCount(payload.stats.sessions)}

{payload.trend && payload.trend.trend.length > 0 ? ( ) : (

No traffic in this period.

)}
{tab === "pages" ? ( ) : ( )}
) : null}
); } function MetricWidget({ range, pick, }: { range: string; pick: (stats: OverviewStats) => number; }) { const { payload, error, loading } = useOverview(range); if (loading) { return (

Loading

); } if (error || !payload) { return (

{userMessage(error?.message, "Analytics could not be loaded. Check the connection in settings.")}

); } return (

{rangeLabel(payload.range)}

{formatCount(pick(payload.stats))}

); } function TrafficWidget({ range }: { range: string }) { const { payload, error, loading } = useOverview(range); const points = payload?.trend?.trend ?? []; if (!loading && !error && payload && points.length === 0) { return (

No traffic in this period.

); } return (
0 ? payload?.trend : undefined} state={loading ? "loading" : error ? "error" : "ready"} error={error ? { code: error.code, message: userMessage(error.message, "Traffic could not be loaded.") } : null} />
); } function PagesWidget({ range }: { range: string }) { const { payload, error, loading } = useOverview(range); const pages = payload?.topPages?.slice(0, 5) ?? null; return (
); } export function WidgetApp({ variant, range }: { variant: Variant; range: string }) { if (variant === "stats") return ; if (variant === "pageviews") return stats.pageviews} />; if (variant === "sessions") return stats.sessions} />; if (variant === "traffic") return ; if (variant === "pages") return ; return ; } const styles = `${componentCss}\n${adminCss}`; const mounted = new WeakSet(); function boot() { document.querySelectorAll("[data-causewise-mount]").forEach((host) => { if (mounted.has(host)) return; mounted.add(host); const variant = (host.dataset.variant || "dashboard") as Variant; const range = host.dataset.range || "7d"; const shadow = host.attachShadow({ mode: "open" }); const style = document.createElement("style"); style.textContent = styles; const mount = document.createElement("div"); shadow.append(style, mount); createRoot(mount).render(); }); } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", boot); } else { boot(); }