Self-instrumentation hook

useSkBlock gives a block its data-sk-* attributes and emits view, interact and convert events to any analytics sink already on the page (GA4 dataLayer, PostHog, Clarity, Shopify, rrweb).

hook
$npx shadcn@latest add @storekit/sk-analytics
sk-analytics
Demo pending

Live and interactive. Switch the brand in the frame header to see the same source under another token file.

Installation

$npx shadcn@latest add @storekit/sk-analytics
$pnpm dlx shadcn@latest add @storekit/sk-analytics
$yarn dlx shadcn@latest add @storekit/sk-analytics
$bunx --bun shadcn@latest add @storekit/sk-analytics

Source

components/storekit/lib/sk-analytics.ts
78 lines
"use client"

import { useEffect, useRef } from "react"

/* Self-instrumenting components.
   Every StoreKit block carries data-sk-section and data-sk-variant on its root.
   This hook fires "view" once (50% visible) and lets a block report "interact"
   and "convert" events. Events go to every sink that exists on the page:
   window.dataLayer (GA4 / GTM), posthog, clarity, Shopify analytics, rrweb
   custom events, and a window.__sk listener array for your own code.
   Nothing is sent anywhere by default; sinks are detected, not installed. */

export type SkEvent = {
  type: "view" | "interact" | "convert"
  section: string
  variant: string
  pos?: number
  exp?: string
  label?: string
  value?: number
  ts: number
}

declare global {
  interface Window {
    dataLayer?: unknown[]
    posthog?: { capture: (name: string, props?: Record<string, unknown>) => void }
    clarity?: (...args: unknown[]) => void
    Shopify?: { analytics?: { publish?: (name: string, payload?: unknown) => void } }
    rrweb?: { record?: { addCustomEvent?: (tag: string, payload: unknown) => void } }
    __sk?: Array<(e: SkEvent) => void>
  }
}

export function skEmit(e: Omit<SkEvent, "ts">) {
  if (typeof window === "undefined") return
  const ev: SkEvent = { ...e, ts: Date.now() }
  try { window.dataLayer?.push({ event: `sk_${ev.type}`, ...ev }) } catch {}
  try { window.posthog?.capture(`sk_${ev.type}`, ev) } catch {}
  try { window.clarity?.("event", `sk_${ev.type}_${ev.section}_${ev.variant}`) } catch {}
  try { window.Shopify?.analytics?.publish?.(`sk_${ev.type}`, ev) } catch {}
  try { window.rrweb?.record?.addCustomEvent?.("sk", ev) } catch {}
  try { window.__sk?.forEach((fn) => fn(ev)) } catch {}
}

/** Attach to a block's root. Returns the data attributes and an `emit` helper.
    The observer is (re)attached after every render until the root exists, so
    roots that mount later (drawers, modals) still fire their view event. */
export function useSkBlock(section: string, variant: string, opts: { pos?: number; exp?: string } = {}) {
  const ref = useRef<HTMLElement | null>(null)
  const seen = useRef(false)
  const observed = useRef<HTMLElement | null>(null)
  const io = useRef<IntersectionObserver | null>(null)
  useEffect(() => {
    const el = ref.current
    if (!el || seen.current || observed.current === el || typeof IntersectionObserver === "undefined") return
    io.current?.disconnect()
    observed.current = el
    const obs = new IntersectionObserver(
      (entries) => {
        if (entries.some((x) => x.isIntersecting) && !seen.current) {
          seen.current = true
          skEmit({ type: "view", section, variant, pos: opts.pos, exp: opts.exp })
          obs.disconnect()
        }
      },
      { threshold: 0.5 }
    )
    obs.observe(el)
    io.current = obs
  })
  useEffect(() => () => io.current?.disconnect(), [])
  const emit = (type: "interact" | "convert", label?: string, value?: number) =>
    skEmit({ type, section, variant, pos: opts.pos, exp: opts.exp, label, value })
  const attrs = { "data-sk-section": section, "data-sk-variant": variant, "data-sk-pos": opts.pos, "data-sk-exp": opts.exp } as const
  return { ref, attrs, emit }
}

Usage

const { ref, attrs, emit } = useSkBlock("pdp", "buybox"); spread attrs on the root, call emit("convert", "add_to_cart", amount) on the action.

Self-instrumentation hook, StoreKit