import { createElement, useEffect, useMemo, useState } from "react"; import { createRoot, type Root } from "react-dom/client"; import { CauseWiseDashboard, CauseWiseGoals, CauseWiseRecentEvents, CauseWiseStats, CauseWiseTopPages, CauseWiseTrafficChart, dashboardLayout, parseStoredDashboardLayout, } from "@razorsoft/causewise-react"; import type { DashboardLayoutStore, DashboardSectionKey, FunnelsSection, GoalsSection, OverviewStats, RecentEvent, ReportingPeriod, StatusNotice, TopPage, TrafficSeries, } from "@razorsoft/causewise-react"; import componentCss from "@razorsoft/causewise-react/styles.css"; import { PRIVATE_NOTE, dashboardHeading, sanitizeEmbedKind, sanitizeEmbedLimit, sanitizeEmbedRange, sanitizeEmbedTheme, sanitizeEmbedTitle, type EmbedKind, type EmbedTheme, } from "./embed-config"; import { Referrers } from "./referrers"; export type EmbedPayload = { ok?: boolean; site?: { id: string; name: string; domain: string } | null; advancedUrl?: string; period?: ReportingPeriod | null; stats?: OverviewStats | null; trend?: TrafficSeries | null; topPages?: TopPage[]; referrers?: { label: string; count: number }[]; goals?: GoalsSection | null; funnels?: FunnelsSection | null; recentEvents?: RecentEvent[]; overviewError?: StatusNotice | null; summaryError?: StatusNotice | null; }; export type EmbedModel = | { state: "loading" } | { state: "login" } | { state: "error"; error: StatusNotice } | { state: "ready"; payload: EmbedPayload }; const LOAD_ERROR = "Analytics could not be loaded."; function take(rows: T[], limit: number): T[] { return rows.slice(0, sanitizeEmbedLimit(limit)); } function safeMessage(message: unknown): string { const text = typeof message === "string" ? message.trim() : ""; if (!text || text.includes("cw_api_") || /authorization:\s*bearer/i.test(text)) { return LOAD_ERROR; } return text; } function noticeText(message: unknown): string { const text = typeof message === "string" ? message.trim() : ""; if (!text || text.includes("cw_api_") || /authorization:\s*bearer/i.test(text)) return ""; return text; } function asSite(value: unknown): EmbedPayload["site"] { if (!value || typeof value !== "object") return null; const site = value as { id?: unknown; name?: unknown; domain?: unknown }; return { id: typeof site.id === "string" ? site.id : "", name: typeof site.name === "string" ? site.name : "", domain: typeof site.domain === "string" ? site.domain : "", }; } function asPeriod(value: unknown): ReportingPeriod | null { if (!value || typeof value !== "object") return null; const period = value as ReportingPeriod; if (typeof period.range !== "string") return null; return { range: period.range, start: typeof period.start === "string" ? period.start : "", end: typeof period.end === "string" ? period.end : "", retentionDays: typeof period.retentionDays === "number" ? period.retentionDays : undefined, }; } function asAdvanced(value: unknown): string | undefined { if (typeof value !== "string") return undefined; try { const parsed = new URL(value); if (parsed.protocol === "https:" && parsed.hostname === "causewise.io") return parsed.toString(); } catch { /* Ignore anything that is not a CauseWise link. */ } return undefined; } function asNotice(value: unknown): StatusNotice | null { if (!value || typeof value !== "object") return null; const notice = value as StatusNotice; const message = noticeText(notice.message); if (!message) return null; return { code: typeof notice.code === "string" ? notice.code : undefined, message }; } function asFunnels(value: unknown): FunnelsSection | null { if (!value || typeof value !== "object") return null; const section = value as FunnelsSection; if (section.available === false) { const reason = typeof section.reason === "string" ? section.reason.trim() : ""; return { available: false, reason: reason || "Funnels are not available." }; } if (section.available !== true || !Array.isArray(section.funnels)) return null; return { available: true, truncated: typeof section.truncated === "boolean" ? section.truncated : undefined, funnels: section.funnels.map((funnel) => ({ id: typeof funnel.id === "string" ? funnel.id : "", name: typeof funnel.name === "string" ? funnel.name : "", isActive: typeof funnel.isActive === "boolean" ? funnel.isActive : undefined, displayState: typeof funnel.displayState === "string" ? funnel.displayState : undefined, entryVisitors: typeof funnel.entryVisitors === "number" ? funnel.entryVisitors : 0, completionCount: typeof funnel.completionCount === "number" ? funnel.completionCount : 0, overallConversionRate: funnel.overallConversionRate === null || typeof funnel.overallConversionRate === "number" ? funnel.overallConversionRate : null, conversionWindow: typeof funnel.conversionWindow === "number" ? funnel.conversionWindow : undefined, largestDropOff: funnel.largestDropOff ?? null, medianConversionTime: funnel.medianConversionTime ?? null, steps: Array.isArray(funnel.steps) ? funnel.steps.map((step) => ({ stepId: step.stepId, name: step.name, position: step.position, visitors: step.visitors, continuedPct: step.continuedPct, dropoffCount: step.dropoffCount, dropoffPct: step.dropoffPct, })) : undefined, })), }; } function asStats(value: unknown): OverviewStats | null { if (!value || typeof value !== "object") return null; const stats = value as OverviewStats; if (typeof stats.pageviews !== "number" || typeof stats.sessions !== "number") return null; return { pageviews: stats.pageviews, sessions: stats.sessions, sessionsNote: typeof stats.sessionsNote === "string" ? stats.sessionsNote : undefined, visitors: stats.visitors === null || typeof stats.visitors === "number" ? stats.visitors : null, visitorsNote: typeof stats.visitorsNote === "string" ? stats.visitorsNote : undefined, }; } function asTrend(value: unknown): TrafficSeries | null { if (!value || typeof value !== "object") return null; const series = value as TrafficSeries; if (!Array.isArray(series.trend)) return null; const trend = series.trend .filter((point) => point && typeof point.label === "string" && typeof point.count === "number") .map((point) => ({ label: point.label, count: point.count })); return { range: typeof series.range === "string" ? series.range : undefined, start: typeof series.start === "string" ? series.start : undefined, end: typeof series.end === "string" ? series.end : undefined, seriesLabel: typeof series.seriesLabel === "string" ? series.seriesLabel : undefined, trend, }; } function asPages(value: unknown, limit: number): TopPage[] { if (!Array.isArray(value)) return []; const pages = value .filter((page) => page && typeof page.path === "string" && typeof page.count === "number") .map((page) => ({ path: page.path, count: page.count })); return take(pages, limit); } function asReferrers(value: unknown, limit: number): { label: string; count: number }[] { if (!Array.isArray(value)) return []; const rows = value .filter((row) => row && typeof row.label === "string" && typeof row.count === "number") .map((row) => ({ label: row.label, count: row.count })); return take(rows, limit); } function asGoals(value: unknown): GoalsSection | null { if (!value || typeof value !== "object") return null; const section = value as GoalsSection; if (section.available === false) { return { available: false, reason: typeof section.reason === "string" && section.reason.trim() ? section.reason : "Goals are not available.", }; } if (section.available !== true || !Array.isArray(section.goals)) return null; return { available: true, activeGoalCount: typeof section.activeGoalCount === "number" ? section.activeGoalCount : undefined, totalConversions: typeof section.totalConversions === "number" ? section.totalConversions : undefined, conversionRate: section.conversionRate === null || typeof section.conversionRate === "number" ? section.conversionRate : undefined, truncated: typeof section.truncated === "boolean" ? section.truncated : undefined, goals: section.goals.map((goal) => ({ id: goal.id, name: goal.name, eventName: goal.eventName, conversions: goal.conversions, trackingStatus: goal.trackingStatus, lastMatchingAt: goal.lastMatchingAt ?? null, })), }; } export function publicEvents(events: unknown, limit: number): RecentEvent[] { if (!Array.isArray(events)) return []; const rows = events .filter((event) => event && typeof event === "object") .map((event) => { const row = event as RecentEvent; return { id: typeof row.id === "string" ? row.id : "", type: typeof row.type === "string" ? row.type : "", path: typeof row.path === "string" ? row.path : "", timestamp: typeof row.timestamp === "string" ? row.timestamp : "", }; }); return take(rows, limit); } export function cleanEmbedPayload(value: unknown, limit: number): EmbedPayload { const record = value && typeof value === "object" ? (value as Record) : {}; const events = Array.isArray(record.recentEvents) ? record.recentEvents : record.events; return { ok: true, site: asSite(record.site), advancedUrl: asAdvanced(record.advancedUrl), period: asPeriod(record.period), stats: asStats(record.stats), trend: asTrend(record.trend), topPages: asPages(record.topPages, limit), referrers: asReferrers(record.referrers, limit), goals: asGoals(record.goals), funnels: asFunnels(record.funnels), recentEvents: publicEvents(events, limit), overviewError: asNotice(record.overviewError), summaryError: asNotice(record.summaryError), }; } export function interpretEmbedResponse(status: number, body: unknown, limit = 8): Exclude { if (status === 401 || status === 403) return { state: "login" }; if (!body || typeof body !== "object") return { state: "error", error: { message: LOAD_ERROR } }; const record = body as Record; if (record.ok !== true) { return { state: "error", error: { code: typeof record.code === "string" ? record.code : undefined, message: safeMessage(record.message), }, }; } return { state: "ready", payload: cleanEmbedPayload(record, limit) }; } export function EmbedView({ kind, title, theme = "light", limit = 8, sections, range = "7d", onRangeChange, state, error, payload, layoutScope, }: { kind: EmbedKind; title?: string; theme?: EmbedTheme; limit?: number; sections?: Partial> | string; range?: "today" | "7d" | "30d"; onRangeChange?: (range: "today" | "7d" | "30d") => void; state: EmbedModel["state"]; error?: StatusNotice | null; payload?: EmbedPayload | null; layoutScope?: { site: string; userId: string | number; instance: string; storage?: Storage | null; layoutStore?: DashboardLayoutStore; }; }) { if (state === "login") { return

{PRIVATE_NOTE}

; } const caption = kind === "dashboard" || !title?.trim() ? null :

{title.trim()}

; const notice = state === "error" ? { code: error?.code, message: safeMessage(error?.message) } : undefined; const data = payload ?? {}; let body = null; if (kind === "dashboard") { const layout = dashboardLayout({ range, sections }); const summary = noticeText(data.summaryError?.message); body = ( ); } else if (kind === "stats") { body = ; } else if (kind === "traffic") { body = ; } else if (kind === "top-pages") { body = ; } else if (kind === "referrers") { body = ( ); } else if (kind === "goals") { const goals = data.goals; const configured = goals?.available === true ? goals.goals : null; if (state === "ready" && configured && configured.length === 0) { body = (

Goals

No goals have been configured yet.

); } else { body = ; } } else { body = ( ); } return (
{caption} {body}
); } const styledDocuments = new WeakSet(); export function ensureEmbedStyles(target: Document) { if (!target.head || styledDocuments.has(target)) return; styledDocuments.add(target); const style = target.createElement("style"); style.setAttribute("data-causewise-components", ""); style.textContent = componentCss; target.head.appendChild(style); } export function readEmbedNode(node: HTMLElement): { kind: EmbedKind; range: "today" | "7d" | "30d"; title: string; limit: number; theme: EmbedTheme; sections?: string; instance?: string; } | null { const kind = sanitizeEmbedKind(node.dataset.causewiseEmbed); if (!kind) return null; return { kind, range: sanitizeEmbedRange(node.dataset.range), title: sanitizeEmbedTitle(node.dataset.title), limit: sanitizeEmbedLimit(node.dataset.limit), theme: sanitizeEmbedTheme(node.dataset.theme), sections: node.hasAttribute("data-sections") ? (node.dataset.sections ?? "") : undefined, instance: node.dataset.instance, }; } const roots = new WeakMap(); export function renderEmbed(node: HTMLElement, model: Exclude & { kind: EmbedKind; title?: string; theme?: EmbedTheme; limit?: number }) { ensureEmbedStyles(node.ownerDocument); let root = roots.get(node); if (!root) { root = createRoot(node); roots.set(node, root); } root.render( createElement(EmbedView, { kind: model.kind, title: model.title, theme: model.theme, limit: model.limit, state: model.state, error: model.state === "error" ? model.error : undefined, payload: model.state === "ready" ? model.payload : undefined, }), ); } function dashboardLayoutStore(layoutUrl: string, nonce: string, instance: string, fetchImpl: typeof fetch): DashboardLayoutStore { const headers = { Accept: "application/json", "X-WP-Nonce": nonce }; return { async load() { const url = new URL(layoutUrl, window.location.origin); url.searchParams.set("instance", instance); const response = await fetchImpl(url.toString(), { method: "GET", credentials: "same-origin", cache: "no-store", headers }); if (!response.ok) throw new Error("The saved layout could not be loaded."); const body = (await response.json().catch(() => null)) as { layout?: unknown } | null; if (!body || typeof body !== "object" || !("layout" in body)) throw new Error("The saved layout could not be loaded."); if (body.layout == null) return null; const parsed = parseStoredDashboardLayout(JSON.stringify(body.layout)); if (!parsed) throw new Error("The saved layout could not be loaded."); return parsed; }, async save(layout) { const response = await fetchImpl(layoutUrl, { method: "POST", credentials: "same-origin", cache: "no-store", headers: { ...headers, "Content-Type": "application/json" }, body: JSON.stringify({ instance, layout }), }); if (!response.ok) throw new Error("The layout could not be saved."); }, async reset() { const url = new URL(layoutUrl, window.location.origin); url.searchParams.set("instance", instance); const response = await fetchImpl(url.toString(), { method: "DELETE", credentials: "same-origin", cache: "no-store", headers }); if (!response.ok) throw new Error("The layout could not be reset."); }, }; } function DashboardLive({ embed, config, fetchImpl, }: { embed: NonNullable>; config: { restUrl: string; nonce: string; version?: string; siteHost?: string; userId?: string | number; layoutUrl?: string; storage?: Storage | null }; fetchImpl: typeof fetch; }) { const [range, setRange] = useState(embed.range); const layoutStore = useMemo( () => (config.layoutUrl && embed.instance ? dashboardLayoutStore(config.layoutUrl, config.nonce, embed.instance, fetchImpl) : undefined), [config.layoutUrl, config.nonce, embed.instance, fetchImpl], ); const [model, setModel] = useState | { state: "loading" }>({ state: "loading" }); useEffect(() => { let cancelled = false; const url = new URL(config.restUrl, window.location.origin); url.searchParams.set("range", range); url.searchParams.set("section", "dashboard"); url.searchParams.set("limit", String(embed.limit)); fetchImpl(url.toString(), { credentials: "same-origin", cache: "no-store", headers: { Accept: "application/json", "X-WP-Nonce": config.nonce, }, }) .then(async (response) => { const body = await response.json().catch(() => null); if (!cancelled) setModel(interpretEmbedResponse(response.status, body, embed.limit)); }) .catch(() => { if (!cancelled) setModel({ state: "error", error: { message: LOAD_ERROR } }); }); return () => { cancelled = true; }; }, [config.nonce, config.restUrl, embed.limit, fetchImpl, range]); return ( ); } export async function mountEmbedNode( node: HTMLElement, config: { restUrl: string; nonce: string; version?: string; siteHost?: string; userId?: string | number; layoutUrl?: string; storage?: Storage | null }, fetchImpl: typeof fetch = fetch, ) { const embed = readEmbedNode(node); if (!embed || !config.restUrl || !config.nonce) return; if (embed.kind === "dashboard") { ensureEmbedStyles(node.ownerDocument); let root = roots.get(node); if (!root) { root = createRoot(node); roots.set(node, root); } root.render(createElement(DashboardLive, { embed, config, fetchImpl })); return; } renderEmbed(node, { ...embed, state: "loading" }); const url = new URL(config.restUrl, window.location.origin); url.searchParams.set("range", embed.range); url.searchParams.set("section", embed.kind); url.searchParams.set("limit", String(embed.limit)); try { const response = await fetchImpl(url.toString(), { credentials: "same-origin", cache: "no-store", headers: { Accept: "application/json", "X-WP-Nonce": config.nonce, }, }); const body = await response.json().catch(() => null); const model = interpretEmbedResponse(response.status, body, embed.limit); renderEmbed(node, { ...embed, ...model }); } catch { renderEmbed(node, { ...embed, state: "error", error: { message: LOAD_ERROR } }); } } export function startEmbeds( doc: Document, config: { restUrl?: string; nonce?: string; version?: string; siteHost?: string; userId?: string | number; layoutUrl?: string; storage?: Storage | null } | undefined, fetchImpl: typeof fetch = fetch, ) { if (!config?.restUrl || !config.nonce) return; const nodes = doc.querySelectorAll("[data-causewise-embed]"); nodes.forEach((node) => { if (node.dataset.causewiseMounted === "1") return; node.dataset.causewiseMounted = "1"; void mountEmbedNode( node, { restUrl: config.restUrl ?? "", nonce: config.nonce ?? "", version: config.version, siteHost: config.siteHost, userId: config.userId, layoutUrl: config.layoutUrl, storage: config.storage, }, fetchImpl, ); }); }