Developer Demo

Event Tracking

A global tracking layer: one trackEvent() wrapper fans conversion events out to Feature Experimentation, ODP, and any other destination - closing the decision-to-conversion loop for experiments.

Live demo#

The button fires a cta_click conversion through the tracking layer. The dispatch log below shows each event and which destinations received it - scroll the page and the automatic scroll_depth events appear in the same log, because they flow through the same wrapper.

Conversions fired: 0

Visitor ID: (cookie not set yet)

Live dispatch log - every trackEvent() call on this page, including the automatic mb_* events fired by AutoTracker as you scroll and navigate.

No events yet. Click the button above, or scroll the page to trigger mb_scroll_depth.

The log is fed by subscribe() from src/lib/tracking - an in-memory observer on the same fan-out that delivers to the real destinations. ODP shows skipped unless the zaius script is configured.

One wrapper, many destinations#

Analytics calls scattered through components couple every feature to every vendor. The tracking layer inverts that: components call a single trackEvent(key, tags) and the layer resolves the visitor identity once - the same optimizelyEndUserId cookie every Optimizely product keys off - then fans the event out to every registered destination. Three is the usual set: the experimentation browser client, the data platform tag, and a window.dataLayer push for whatever analytics and tag manager the business already runs. That third destination is the reason a customer's existing analytics can slice by experiment arm without a second integration: every event carries exp_variant_string, so an Optimizely variation shows up as a dimension in reports Optimizely does not own.

The destination contract and fan-out
// A destination is anything that can receive an event.

export type TrackedEvent = {
  key: string;
  tags: Record<string, string | number | boolean>;
  userId: string;      // the stable visitor id, shared with every product
  timestamp: number;
};

export type TrackingDestination = {
  name: string;
  send(event: TrackedEvent): DeliveryStatus | Promise<DeliveryStatus>;
};

// The wrapper fans a single call out to every registered destination.
// One failing sink never blocks the others - each send() is isolated
// in its own try/catch, and errors are swallowed: tracking must never
// break the page.
//
// Before building the event, wait for flag decisions to settle, then stamp
// on exp_variant_string - the variations actually rendered on this page, as
// RuleKey-VariationName, comma joined. On a page that served none, omit the
// tag entirely rather than sending it blank.

await trackEvent("cta_click", { source: "pricing-page" });
// → FX:        user.trackEvent("cta_click", tags)
// → ODP:       zaius.event("cta_click", { ...tags })
// → dataLayer: window.dataLayer.push({ event: "cta_click", ... })
//
// tags now include, where a variation was served:
//   exp_variant_string: "checkout_layout_test-single_step"
Adding a new source
// Adding a source is one object - no call sites change.
import { registerDestination } from "@/lib/tracking";

registerDestination({
  name: "Segment",
  send(event) {
    if (!window.analytics) return "skipped";
    window.analytics.track(event.key, { ...event.tags, userId: event.userId });
    return "sent";
  },
});

// Every existing trackEvent() call - CTA clicks, form submits,
// scroll depth, outbound links - now also reaches Segment.

The decision-to-conversion loop#

An A/B test needs two events tied to the same visitor: a decision event (this user saw variation B) and a conversion (this user then did the thing we care about). This project suppresses decision events at decide time with DISABLE_DECISION_EVENT - middleware and server components decide flags several times per request, and firing on every decide would double-count. Only the component that renders the variation fires the decision event. Conversions then attribute correctly because the tracking layer uses the same stable visitor ID for trackEvent that middleware used for bucketing.

The visitor ID is no longer the only thread. Every event also carries exp_variant_string, naming the variations on screen when it fired, which is what lets ODP and GA4 segment by arm rather than only FX. Getting that right needed one piece of coordination: an above-the-fold component beats the network. A hero's view event can fire within milliseconds of mount, while the SDK still has to fetch its datafile before it can decide anything - so the most valuable decision event on the page is exactly the one most likely to go out unattributed. The fix is for trackEvent to wait for decisions to settle before building the event. Keep that wait bounded and let it return early once decisions stop arriving: it should delay an event, never drop one. A page that serves no variation waits once and then sends no tag at all.

Decision-event suppression and conversion attribution
// 1. Bucket the visitor server-side and SUPPRESS the decision event
//    (DISABLE_DECISION_EVENT) - deciding is not seeing.
const decision = user.decide("checkout_layout");   // no event fired

// 2. Whatever actually renders the variation fires the decision event
//    client-side, with the same visitor id.
ctx.decide("checkout_layout", []);                 // empty options = fire it

// 3. The conversion fires through the tracking layer with that SAME
//    visitor id, so results can attribute it to the bucketed variation.
trackEvent("cta_click", { source: "hero" });

// Results then compares conversion rates per variation:
//   variation A: 1,204 decision events, 87 cta_click  → 7.2%
//   variation B: 1,198 decision events, 112 cta_click → 9.3%

Declarative auto-tracking#

A single listener (mounted once in the root layout) listens for clicks, toggles, submits, and scrolling with document-level listeners, so most events need no component code at all - a data-track-event attribute is enough. Because that listener calls the same wrapper, every destination gets these events too.

A declarative attribute contract
<!-- A listener turns data attributes into events - no JS per element.
     The attribute names are yours to choose; the pattern is what matters. -->

<!-- Click tracking -->
<a href="/savings" data-track-event="cta_click" data-track-tags='{"area":"hero"}'>
  Open an account
</a>

<!-- Accordion opens -->
<div data-track-toggle="faq_open">
  <details><summary>What are the fees?</summary>...</details>
</div>

<!-- Form submits -->
<div data-track-submit="contact_submit">
  <form>...</form>
</div>

<!-- Fired automatically, no attributes needed:
     outbound_click - clicks on external links
     scroll_depth   - 25 / 50 / 75 / 100% scroll marks
     time_on_page   - 30 / 60 / 180 second marks -->

FX setup: the event key must exist#

Silently dropped otherwise

Feature Experimentation only accepts events whose key is defined in the FX project - trackEvent calls with unknown keys are dropped without an error. Create the event in the FX UI (or via the Optimizely Experimentation MCP server), then attach it as a metric to a flag rule so results accumulate per variation. Like CMS Variations, this is a one-time manual setup step per project. The ODP and dataLayer destinations have no such registry - they accept any key.

Key Things to Know#

  • →Components call one wrapper, never a vendor SDK. trackEvent(key, tags) resolves identity once and fans out to all destinations - adding a vendor is one TrackingDestination object, zero call-site changes.
  • →Tracking must never break the page. Every destination send is isolated in its own try/catch; a failing or missing sink (ODP without the zaius script) reports skipped or error and the rest still deliver.
  • →Decision events and conversions must share a visitor ID. Both use the optimizelyEndUserId cookie set by middleware - a fresh UUID per request would make conversions unattributable.
  • →Every event names the variation it was fired under. exp_variant_string travels to all three destinations, so ODP and GA4 can segment by experiment arm, not just FX. Use the rule key rather than the flag key - a flag can have several rules, and the rule is what the analytics tool's experiment dimension expects.
  • →Suppress decision events at decide time, fire at render time. DISABLE_DECISION_EVENT everywhere except the component that renders the variation prevents double-counting.
  • →FX drops events with unknown keys - silently. Define the event in the FX project and attach it as a metric to a flag rule before expecting results.
  • →Prefer declarative tracking for content. data-track-event attributes work inside CMS-rendered markup where you can't add click handlers.
How this reference implementation does it7 files
src/lib/tracking/index.ts
"use client";

import { serializeVariations, whenVariationsSettled } from "./activeVariations";
import { getVisitorId } from "./cookies";
import { dataLayerDestination } from "./destinations/dataLayer";
import { fxDestination } from "./destinations/fx";
import { odpDestination } from "./destinations/odp";
import type { TrackedEvent, TrackingDestination } from "./types";

export type { TrackedEvent, TrackingDestination } from "./types";

export type DispatchResult = {
  destination: string;
  status: "sent" | "skipped" | "error";
};

export type DispatchRecord = {
  event: TrackedEvent;
  results: DispatchResult[];
};

const destinations: TrackingDestination[] = [
  fxDestination,
  odpDestination,
  dataLayerDestination,
];

type Listener = (record: DispatchRecord) => void;
const listeners = new Set<Listener>();

export function registerDestination(destination: TrackingDestination): void {
  if (!destinations.some((d) => d.name === destination.name)) {
    destinations.push(destination);
  }
}

export function subscribe(listener: Listener): () => void {
  listeners.add(listener);
  return () => listeners.delete(listener);
}

export async function trackEvent(
  eventKey: string,
  tags?: Record<string, string | number | boolean | null | undefined>
): Promise<void> {
  if (typeof window === "undefined") return;

  const cleanTags: Record<string, string | number | boolean> = {};
  if (tags) {
    for (const [k, v] of Object.entries(tags)) {
      if (v === undefined || v === null) continue;
      cleanTags[k] = v;
    }
  }

  // Attribute the event to whatever variations are on screen, so FX, ODP and GA4 can
  // all segment by arm. Empty on a page that served none, and the param is then
  // omitted entirely rather than sent blank.
  //
  // The wait exists because above-the-fold components beat the datafile fetch: without
  // it, HeroBlock's view event goes out at ~11ms and the decisions land at ~229ms, so
  // the hero decision event was never attributed. Bounded, and it never drops an event.
  await whenVariationsSettled();
  const expVariantString = serializeVariations();

  const event: TrackedEvent = {
    key: eventKey,
    tags: expVariantString
      ? { ...cleanTags, exp_variant_string: expVariantString }
      : cleanTags,
    userId: getVisitorId(),
    timestamp: Date.now(),
  };

  // Every destination gets the event; one failing sink never blocks the others.
  const results = await Promise.all(
    destinations.map(async (destination): Promise<DispatchResult> => {
      try {
        const status = await destination.send(event);
        return { destination: destination.name, status };
      } catch {
        return { destination: destination.name, status: "error" };
      }
    })
  );

  for (const listener of listeners) {
    try {
      listener({ event, results });
    } catch {
      // Tracking must never break the page.
    }
  }
}
src/lib/tracking/destinations/fx.ts
import { getOptimizelyBrowserClient } from "@/lib/optimizely/browser-client";
import { browserFxAttributes } from "@/lib/optimizely/fxAttributes";
import type { TrackingDestination } from "../types";

export const fxDestination: TrackingDestination = {
  name: "Feature Experimentation",
  async send(event) {
    const client = await getOptimizelyBrowserClient();
    if (!client) return "skipped";
    await client.onReady();
    // Events carry the same attributes as decisions, so results can be segmented by them.
    const user = client.createUserContext(event.userId, browserFxAttributes());
    if (!user) return "skipped";
    user.trackEvent(event.key, event.tags);
    return "sent";
  },
};
src/lib/tracking/destinations/odp.ts
import { readCookie } from "../cookies";
import type { TrackingDestination } from "../types";
import { VISITOR_ID_COOKIE } from "@/lib/optimizely/cookieNames";

declare global {
  interface Window {
    zaius?: {
      entity: (type: string, attrs: Record<string, string>) => void;
      event: (type: string, props?: Record<string, unknown>) => void;
    };
  }
}

export const odpDestination: TrackingDestination = {
  name: "Data Platform (ODP)",
  send(event) {
    if (!window.zaius) return "skipped";
    window.zaius.event(event.key, { ...event.tags, fs_user_id: event.userId });
    return "sent";
  },
};

/**
 * Push known customer attributes to ODP, stitching them to the current
 * visitor's fs_user_id. Call this after a form submission yields an email
 * or other identifying data.
 */
/**
 * Write the visitor's content interests onto their ODP customer profile.
 *
 * The mb_content_viewed event already gives ODP a behavioural stream to build a
 * realtime segment from. This does the other half: `top_category` as a customer
 * ATTRIBUTE means a marketer can build an audience on a CMS taxonomy term from a
 * dropdown, with no engineering ticket and no event-stream rule.
 *
 * Requires `last_category` / `top_category` to exist on ODP's customers object. Most
 * accounts auto-create custom fields on first write; a strict-schema account needs
 * them added in the ODP UI first.
 */
export function recordTopCategory(topCategory: string | undefined, lastCategory: string | undefined): void {
  if (typeof window === "undefined" || !window.zaius) return;
  if (!topCategory && !lastCategory) return;
  identifyCustomer({ top_category: topCategory, last_category: lastCategory });
}

export function identifyCustomer(attrs: Record<string, string | undefined>): void {
  if (typeof window === "undefined" || !window.zaius) return;
  const fsUserId = readCookie(VISITOR_ID_COOKIE);
  const payload: Record<string, string> = {};
  for (const [k, v] of Object.entries(attrs)) {
    if (v) payload[k] = v;
  }
  if (fsUserId) payload.fs_user_id = fsUserId;
  window.zaius.entity("customer", payload);
}
src/lib/tracking/destinations/dataLayer.ts
import type { TrackingDestination } from "../types";

declare global {
  interface Window {
    dataLayer?: Record<string, unknown>[];
  }
}

export const dataLayerDestination: TrackingDestination = {
  name: "dataLayer",
  send(event) {
    window.dataLayer = window.dataLayer ?? [];
    window.dataLayer.push({ event: event.key, userId: event.userId, ...event.tags });
    return "sent";
  },
};
src/lib/tracking/activeVariations.ts
"use client";

// Variations the visitor is looking at right now, keyed by FX rule key.
//
// Written only by <FxBucketingEvent>, which mounts exclusively where a component
// has already rendered a variation and fired its decision event, so this map can never
// claim a variation that was not served. Nothing here calls decide() - it only
// reports a decision that already happened.
//
// Entries are dropped on unmount. That is what stops a flag served on one page from
// tagging events on the next one after a client-side navigation.
const active = new Map<string, string>();

// When the map last changed, used to detect that decisions have stopped arriving.
let lastRecordedAt = 0;

export function recordVariation(ruleKey: string, variationKey: string): void {
  active.set(ruleKey, variationKey);
  lastRecordedAt = Date.now();
}

export function clearVariation(ruleKey: string): void {
  active.delete(ruleKey);
}

export function getActiveVariations(): Record<string, string> {
  return Object.fromEntries(active);
}

// GA4's multi-experiment convention, matching scripts/send-ga4-events.mjs
// (pickExpVariantString) exactly: RuleKey-VariationName, comma-joined. Seeded
// history and runtime events therefore share one vocabulary and one custom
// dimension. Sorted so the value is stable no matter which component mounted first.
//
// Returns "" when no variation is served. Callers must omit the param entirely in
// that case rather than send it blank, which is what the seeder does too.
export function serializeVariations(): string {
  return [...active.entries()]
    .sort(([a], [b]) => a.localeCompare(b))
    .map(([ruleKey, variationKey]) => `${ruleKey}-${variationKey}`)
    .join(",");
}

// Hard ceiling on how long an event waits for FX. A blocked datafile CDN must never
// hold events indefinitely.
const SETTLE_TIMEOUT_MS = 1500;
// After the client is ready, how long to keep accepting late decisions. Covers the
// full mount chain below, which is several ticks, not one.
const GRACE_MS = 250;
// Once decisions are arriving, treat a gap this long as "that was all of them".
const QUIET_MS = 80;

const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));

let clientReady: Promise<void> | null = null;

/**
 * Resolves once FX decisions have had their chance to land.
 *
 * Needed because above-the-fold components race the network. Measured on the homepage:
 * HeroBlock's IntersectionObserver fires mb_feature_viewed at ~11ms, while the browser
 * SDK cannot decide until it has fetched the datafile (~229ms cold). So the hero
 * decision event, the one whose variation matters most, was the single event going out
 * unattributed.
 *
 * Waiting on the SDK client alone is NOT enough, and that mistake is easy to repeat:
 * <FxBucketingEvent> does not mount when the client is ready, it mounts after
 * useFxDecision resolves and re-renders its parent, which is a couple of ticks later
 * still. So the chain is client ready -> useFxDecision setState -> re-render ->
 * FxBucketingEvent effect -> decide() -> recordVariation(). Hence a grace window
 * rather than a microtask hop.
 *
 * Returns as soon as decisions stop arriving (QUIET_MS of no change), so the common
 * path costs far less than the full window. A page that serves no variation at all
 * waits GRACE_MS once and then proceeds untagged, which is correct.
 *
 * Never rejects and never drops an event: the worst case is a bounded delay.
 */
export function whenVariationsSettled(): Promise<void> {
  clientReady ??= import("@/lib/optimizely/browser-client")
    .then(({ getOptimizelyBrowserClient }) => getOptimizelyBrowserClient())
    .then(() => undefined)
    .catch(() => undefined);

  return Promise.race([wait(SETTLE_TIMEOUT_MS), settle()]);
}

async function settle(): Promise<void> {
  await clientReady;
  const deadline = Date.now() + GRACE_MS;
  for (;;) {
    const now = Date.now();
    if (now >= deadline) return;
    if (lastRecordedAt > 0 && now - lastRecordedAt >= QUIET_MS) return;
    await wait(Math.min(20, deadline - now));
  }
}
src/components/AutoTracker.tsx
"use client";

import { useEffect, useRef } from "react";
import { usePathname } from "next/navigation";
import { trackEvent } from "@/lib/tracking";
import { personaFromPath, readSegment, reconcileSegment, writeSegment } from "@/lib/segment";
import { isInactiveVariant } from "@/lib/optimizely/wxVariation";
import { cleanPathname } from "@/lib/optimizely/variationPath";

function parseTags(raw: string | null): Record<string, unknown> {
  if (!raw) return {};
  try {
    return JSON.parse(raw);
  } catch {
    return {};
  }
}

function isOutbound(href: string): boolean {
  if (!href || href.startsWith("#") || href.startsWith("/")) return false;
  try {
    const url = new URL(href, window.location.origin);
    return url.host !== window.location.host;
  } catch {
    return false;
  }
}

export default function AutoTracker() {
  const pathname = usePathname();
  // A WX variation route is the same page as its clean path, so page identity - and
  // therefore every dedupe set below - must ignore the __v_ segment. Otherwise the
  // soft-nav swap reads as a navigation and re-fires the view events.
  const page = cleanPathname(pathname);
  const scrollMarks = useRef(new Set<number>());
  const timeMarks = useRef(new Set<number>());
  // Set when the page view starts (see the pathname effect); Date.now() during render is impure.
  const pageStart = useRef(0);
  const lastPath = useRef<string | null>(null);
  // Tracks "elementKey:depthPct" pairs already fired to prevent repeat events.
  const viewedMarks = useRef(new Set<string>());

  // Reconcile the per-tab segment with the demo_persona cookie once on mount so a
  // tab opened mid-session inherits the browser-session segment.
  useEffect(() => {
    reconcileSegment();
  }, []);

  useEffect(() => {
    function onClick(e: MouseEvent) {
      const target = e.target as Element | null;
      if (!target) return;
      const anchor = target.closest("a") as HTMLAnchorElement | null;
      const trackEl = target.closest("[data-track-event]") as HTMLElement | null;

      if (trackEl) {
        const key = trackEl.getAttribute("data-track-event")!;
        const tags = parseTags(trackEl.getAttribute("data-track-tags"));
        if (anchor && !("href" in tags)) (tags as Record<string, unknown>).href = anchor.getAttribute("href") ?? "";
        trackEvent(key, tags as Record<string, string | number | boolean>);
      }

      if (anchor) {
        const href = anchor.getAttribute("href") ?? "";
        if (isOutbound(href)) {
          trackEvent("mb_outbound_click", { href, label: anchor.textContent?.trim().slice(0, 120) ?? "" });
        }

        // Nav click tracking: delegate from NavItems wrapper without modifying
        // individual link elements in NavigationHeader.
        const navWrapper = anchor.closest("[data-component='NavItems']");
        if (navWrapper && !trackEl) {
          const label = anchor.textContent?.trim().slice(0, 100) ?? "";
          if (label && href && !href.startsWith("#")) {
            trackEvent("mb_navigation_click", { nav_item: label, href });
          }
        }
      }
    }

    function onToggle(e: Event) {
      const el = e.target as HTMLElement | null;
      if (!el || !(el instanceof HTMLDetailsElement)) return;
      const wrap = el.closest("[data-track-toggle]") as HTMLElement | null;
      if (!wrap) return;
      if (!el.open) return;
      const key = wrap.getAttribute("data-track-toggle")!;
      const tags = parseTags(wrap.getAttribute("data-track-tags"));
      const summary = el.querySelector("summary")?.textContent?.trim().slice(0, 200);
      if (summary && !("label" in tags)) (tags as Record<string, unknown>).label = summary;
      trackEvent(key, tags as Record<string, string | number | boolean>);
    }

    function onSubmit(e: SubmitEvent) {
      const form = e.target as HTMLElement | null;
      if (!form) return;
      const wrap = form.closest("[data-track-submit]") as HTMLElement | null;
      if (!wrap) return;
      const key = wrap.getAttribute("data-track-submit")!;
      const tags = parseTags(wrap.getAttribute("data-track-tags"));
      trackEvent(key, tags as Record<string, string | number | boolean>);
    }

    function onScroll() {
      const doc = document.documentElement;
      const max = doc.scrollHeight - doc.clientHeight;
      if (max <= 0) return;
      const pct = Math.round((window.scrollY / max) * 100);
      for (const mark of [25, 50, 75, 100]) {
        if (pct >= mark && !scrollMarks.current.has(mark)) {
          scrollMarks.current.add(mark);
          trackEvent("mb_scroll_depth", { depth: mark, path: cleanPathname(window.location.pathname) });
        }
      }
    }

    document.addEventListener("click", onClick, true);
    document.addEventListener("toggle", onToggle, true);
    document.addEventListener("submit", onSubmit, true);
    window.addEventListener("scroll", onScroll, { passive: true });

    return () => {
      document.removeEventListener("click", onClick, true);
      document.removeEventListener("toggle", onToggle, true);
      document.removeEventListener("submit", onSubmit, true);
      window.removeEventListener("scroll", onScroll);
    };
  }, []);

  // Component view tracking via IntersectionObserver on [data-track-view].
  // Fires mb_feature_viewed at 50% and 100% visibility, at most once per
  // threshold per element per page load. Uses a single observer with two
  // thresholds — the browser batches these natively with no scroll overhead.
  useEffect(() => {
    const marks = viewedMarks.current;
    // Skip blocks inside an inactive WX variant subtree. display:none already stops them
    // intersecting, so this is belt-and-braces for the moment between hydration and the
    // reveal, and it also keeps the index below stable against the hidden copy.
    const elements = Array.from(
      document.querySelectorAll<HTMLElement>("[data-track-view]")
    ).filter((el) => !isInactiveVariant(el));
    if (!elements.length) return;

    const observer = new IntersectionObserver(
      (entries) => {
        for (const entry of entries) {
          const el = entry.target as HTMLElement;
          const component = el.getAttribute("data-track-view") ?? "unknown";
          const label = el.getAttribute("data-track-label") ?? component;
          // Use a stable key: component name + position in document.
          const idx = Array.from(elements).indexOf(el);
          const base = `${component}:${idx}`;

          if (entry.intersectionRatio >= 0.5 && !marks.has(`${base}:50`)) {
            marks.add(`${base}:50`);
            trackEvent("mb_feature_viewed", { component, label, depth: 50 });
          }
          if (entry.intersectionRatio >= 1.0 && !marks.has(`${base}:100`)) {
            marks.add(`${base}:100`);
            trackEvent("mb_feature_viewed", { component, label, depth: 100 });
          }
        }
      },
      { threshold: [0.5, 1.0] }
    );

    elements.forEach((el) => observer.observe(el));
    return () => observer.disconnect();
  }, [page]);

  useEffect(() => {
    if (lastPath.current === page) return;
    lastPath.current = page;
    scrollMarks.current = new Set();
    timeMarks.current = new Set();
    viewedMarks.current = new Set();
    pageStart.current = Date.now();

    if (page.startsWith("/demo")) {
      trackEvent("mb_demo_page_view", { path: page });
    }

    // Realtime audience: qualify the visitor for a segment based on the section
    // they are browsing. writeSegment mirrors it to sessionStorage + the
    // demo_persona cookie so FX serves the matching homepage variation on the
    // next request. Only fire when the derived persona actually changed.
    const persona = personaFromPath(page);
    if (persona && persona !== readSegment() && writeSegment(persona)) {
      trackEvent("mb_segment_qualified", { segment: persona });
    }

    const timers = [30, 60, 180].map((secs) =>
      window.setTimeout(() => {
        if (timeMarks.current.has(secs)) return;
        timeMarks.current.add(secs);
        trackEvent("mb_time_on_page", { seconds: secs, path: page });
      }, secs * 1000)
    );
    return () => timers.forEach((t) => window.clearTimeout(t));
  }, [page]);

  return null;
}
src/app/demo/event-tracking/ConversionDemo.tsx
"use client";
import { useEffect, useState } from "react";
import { trackEvent, subscribe, type DispatchRecord } from "@/lib/tracking";
import { readCookie } from "@/lib/tracking/cookies";
import { VISITOR_ID_COOKIE } from "@/lib/optimizely/cookieNames";
import { useIsClient } from "@/lib/useIsClient";

const MAX_LOG_ENTRIES = 8;

const STATUS_STYLES: Record<string, string> = {
  sent:    "bg-brand/10 text-brand border-brand/30",
  skipped: "bg-error/10 text-error border-error/30",
  error:   "bg-error/10 text-error border-error/30",
};

export default function ConversionDemo() {
  // Read after hydration only, so the server render and first client render agree.
  const userId = useIsClient() ? readCookie(VISITOR_ID_COOKIE) : "";
  const [clicks, setClicks] = useState(0);
  const [log, setLog] = useState<DispatchRecord[]>([]);

  useEffect(() => {
    return subscribe((record) => {
      setLog((prev) => [record, ...prev].slice(0, MAX_LOG_ENTRIES));
    });
  }, []);

  return (
    <div data-component="ConversionDemo" className="bg-surface-lowest border border-ghost-border rounded-2xl p-6 space-y-5">
      <div className="flex flex-wrap items-center gap-4">
        <button
          type="button"
          onClick={() => {
            setClicks((c) => c + 1);
            void trackEvent("demo_cta_click", { source: "demo-page", clickNumber: clicks + 1 });
          }}
          className="bg-gradient-brand text-on-brand text-sm font-semibold px-6 py-3 rounded-xl hover-lift transition-all"
        >
          Open a savings account
        </button>
        <div className="text-xs text-on-surface-variant space-y-1">
          <p>
            Conversions fired: <strong className="text-on-surface font-mono">{clicks}</strong>
          </p>
          <p>
            Visitor ID:{" "}
            <code className="bg-surface px-1 rounded font-mono">{userId || "(cookie not set yet)"}</code>
          </p>
        </div>
      </div>

      <div>
        <p className="text-xs font-medium text-on-surface-variant mb-2">
          Live dispatch log - every trackEvent() call on this page, including the automatic{" "}
          <code className="bg-surface px-1 rounded font-mono">mb_*</code> events fired by AutoTracker
          as you scroll and navigate.
        </p>
        {log.length === 0 ? (
          <p className="text-xs text-on-surface-variant italic">
            No events yet. Click the button above, or scroll the page to trigger{" "}
            <code className="bg-surface px-1 rounded font-mono">mb_scroll_depth</code>.
          </p>
        ) : (
          <ul className="space-y-2">
            {log.map((record) => (
              <li
                key={`${record.event.key}-${record.event.timestamp}`}
                className="bg-surface rounded-xl px-4 py-3 text-xs flex flex-wrap items-center gap-2"
              >
                <span className="font-mono font-semibold text-on-surface">{record.event.key}</span>
                {Object.keys(record.event.tags).length > 0 && (
                  <span className="font-mono text-on-surface-variant truncate max-w-60">
                    {JSON.stringify(record.event.tags)}
                  </span>
                )}
                <span className="ml-auto flex gap-1.5">
                  {record.results.map((r) => (
                    <span
                      key={r.destination}
                      className={`px-2 py-0.5 rounded-full border font-medium ${STATUS_STYLES[r.status]}`}
                    >
                      {r.destination}: {r.status}
                    </span>
                  ))}
                </span>
              </li>
            ))}
          </ul>
        )}
      </div>

      <p className="text-xs text-on-surface-variant border-t border-ghost-border pt-3">
        The log is fed by <code className="bg-surface px-1 rounded font-mono">subscribe()</code> from{" "}
        <code className="bg-surface px-1 rounded font-mono">src/lib/tracking</code> - an in-memory
        observer on the same fan-out that delivers to the real destinations. ODP shows{" "}
        <em>skipped</em> unless the zaius script is configured.
      </p>
    </div>
  );
}