Visitor Log + Session Replay — Build Prompt

The full specification for the visitor tracking, session recording, and replay playback system. Copy this and paste it into another site's build to recreate the feature.

# Visitor Log + Session Replay System

Build a complete visitor analytics system that tracks every site visitor's journey (pageviews, scroll depth, clicks, video engagement, unmutes, inquiries, reveal-code attempts) and records full DOM-level session replays playable back in the admin dashboard.

## Entities to create

### UserActivity
Tracks every pageview, scroll milestone (25/50/75/100%), and click on the site.
- action: enum ["click","scroll"]
- target: string (CSS selector for clicks, "25%"/"50%"/"75%"/"100%" for scroll, "pageview" for pageviews)
- path: string (current URL pathname)
- host: string
- scroll_depth: number
- session_id: string (persistent visitor ID stored in localStorage)
- details: string (JSON with click text, href, x/y coords)
- referrer: string
- campaign: string (marketing campaign code from URL/subdomain)
- user_agent: string
- ip: string
- city: string
- region: string
- country: string
Required: ["action","session_id"]

### SessionRecording
Stores uploaded rrweb DOM-mutation recordings, linked to a visitor session.
- session_id: string
- file_url: string (uploaded JSON file of rrweb events)
- started_at: string (date-time)
- event_count: number
- path: string
Required: ["session_id","file_url"]

### MusicEvent (optional — for video/audio engagement)
- action: string (e.g. "unmute","mute","play","pause")
- session_id: string
- path: string

### InquiryLog (optional — for inquiry badges)
- inquiry_type: string
- name: string
- contact: string
- subject: string
- message: string
- artwork_title: string
- channel: enum ["form","whatsapp"]
- session_id: string
Required: ["inquiry_type"]

### RevealAttempt (optional — for reveal-code badges)
- session_id: string
- result: string ("correct"/"incorrect")
- code_attempted: string

### IpTag (optional — for tagging known IPs)
- ip: string
- tag: string
Required: ["ip"]

### ArtColumnPref (optional — for persisting column visibility)
- sheet: string
- columns: array of { key, label, visible, w, numeric, format }

## Frontend Hook 1: useUserTracking (visitor tracking)

Install: rrweb (for recording), and use the base44 SDK client.

Create a hook that:
1. Generates a persistent session ID (localStorage key "activity_visitor_id", format "v_<timestamp>_<random>").
2. Detects the marketing campaign code from: URL ?campaign= param, subdomain (single letter/number), or sessionStorage fallback.
3. Auto-creates a Campaign entity if the campaign code doesn't exist yet.
4. Listens for click events (captures tag, id, class, role, innerText, href, x/y).
5. Listens for scroll events at 25/50/75/100% milestones (throttled to 350ms).
6. Records a "pageview" click event on initial load and whenever the SPA route changes (poll pathname every 1s).
7. Buffers events and flushes to a backend function "recordUserActivity" every 8 seconds, or when buffer hits 25 events, or on pagehide/visibilitychange.
8. Each event includes: action, target, path, details, session_id, referrer, host, campaign.

```js
import { useEffect, useRef } from 'react';
import { base44 } from '@/api/base44Client';

export function getActivitySessionId() {
  let id = localStorage.getItem('activity_visitor_id');
  if (!id) {
    id = 'v_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8);
    localStorage.setItem('activity_visitor_id', id);
  }
  return id;
}

export default function useUserTracking() {
  const buffer = useRef([]);
  const flushTimer = useRef(null);
  const scrollTimer = useRef(null);
  const scrollMilestones = useRef(new Set());
  const lastPath = useRef('');

  const flush = async () => {
    if (buffer.current.length === 0) return;
    const batch = buffer.current.splice(0, buffer.current.length);
    try {
      await base44.functions.invoke('recordUserActivity', { events: batch });
    } catch (e) { /* drop on failure */ }
  };

  useEffect(() => {
    const sessionId = getActivitySessionId();
    const referrer = document.referrer || '';
    const host = window.location.hostname || '';
    const campaign = (() => {
      try {
        const params = new URLSearchParams(window.location.search);
        const fromUrl = params.get('campaign');
        if (fromUrl) { sessionStorage.setItem('activity_campaign', fromUrl); return fromUrl; }
        const parts = (window.location.hostname || '').split('.');
        if (parts.length >= 3 && parts[0].toLowerCase() !== 'www') {
          const sub = parts[0].toLowerCase();
          if (/^([0-9]+|[a-z0-9]{1,2})$/i.test(sub)) { sessionStorage.setItem('activity_campaign', sub); return sub; }
        }
        return sessionStorage.getItem('activity_campaign') || '';
      } catch { return ''; }
    })();

    if (campaign) {
      base44.entities.Campaign.filter({ campaign_number: campaign })
        .then((existing) => {
          if (!existing || existing.length === 0) {
            return base44.entities.Campaign.create({ campaign_number: campaign, name: '#' + campaign, status: 'active' });
          }
        }).catch(() => {});
    }

    const push = (event, eventReferrer) => {
      buffer.current.push({ ...event, session_id: sessionId, referrer: eventReferrer !== undefined ? eventReferrer : referrer, host, ...(campaign ? { campaign } : {}) });
      if (buffer.current.length >= 25) flush();
    };

    const describeTarget = (el) => {
      if (!el || !el.tagName) return 'unknown';
      const tag = el.tagName.toLowerCase();
      const id = el.id ? '#' + el.id : '';
      const cls = typeof el.className === 'string' ? el.className.trim().split(/\s+/).slice(0, 2).map((c) => '.' + c).join('') : '';
      const role = el.getAttribute?.('data-track') || el.getAttribute?.('role') || '';
      return [tag + id, cls, role && '[' + role + ']'].filter(Boolean).join(' ') || tag;
    };

    const handleClick = (e) => {
      const el = e.target;
      if (el.tagName && el.tagName.toLowerCase() === 'div') return;
      const text = (el.innerText || el.textContent || '').trim().slice(0, 80);
      const href = el.getAttribute?.('href') || '';
      push({ action: 'click', target: describeTarget(el), path: window.location.pathname, details: JSON.stringify({ text, href, x: Math.round(e.clientX), y: Math.round(e.clientY) }) });
    };

    const handleScroll = () => {
      const path = window.location.pathname;
      if (path !== lastPath.current) { lastPath.current = path; scrollMilestones.current = new Set(); }
      const scrollTop = window.scrollY;
      const docHeight = document.documentElement.scrollHeight - window.innerHeight;
      const depth = docHeight > 0 ? Math.round((scrollTop / docHeight) * 100) : 0;
      [25, 50, 75, 100].forEach((m) => {
        if (depth >= m && !scrollMilestones.current.has(m)) {
          scrollMilestones.current.add(m);
          push({ action: 'scroll', target: m + '%', path, scroll_depth: m });
        }
      });
    };

    const throttledScroll = () => {
      if (scrollTimer.current) return;
      scrollTimer.current = setTimeout(() => { handleScroll(); scrollTimer.current = null; }, 350);
    };

    document.addEventListener('click', handleClick, true);
    window.addEventListener('scroll', throttledScroll, { passive: true });
    flushTimer.current = setInterval(flush, 8000);
    push({ action: 'click', target: 'pageview', path: window.location.pathname });
    lastPath.current = window.location.pathname;
    const pathWatcher = setInterval(() => {
      const p = window.location.pathname;
      if (p !== lastPath.current) {
        const internalRef = lastPath.current ? window.location.origin + lastPath.current : referrer;
        lastPath.current = p;
        scrollMilestones.current = new Set();
        push({ action: 'click', target: 'pageview', path: p }, internalRef);
      }
    }, 1000);

    const onVisibility = () => { if (document.visibilityState === 'hidden') flush(); };
    document.addEventListener('visibilitychange', onVisibility);
    window.addEventListener('pagehide', flush);

    return () => {
      flush();
      document.removeEventListener('click', handleClick, true);
      window.removeEventListener('scroll', throttledScroll);
      document.removeEventListener('visibilitychange', onVisibility);
      window.removeEventListener('pagehide', flush);
      if (flushTimer.current) clearInterval(flushTimer.current);
      if (scrollTimer.current) clearTimeout(scrollTimer.current);
      clearInterval(pathWatcher);
    };
  }, []);
}
```

## Frontend Hook 2: useSessionRecorder (rrweb DOM recording)

Records DOM mutations using rrweb, buffers them, and uploads as a JSON file every 60 seconds (or on tab close). Links the recording to the same session_id used by the tracker. Excludes internal/admin routes.

```js
import { useEffect, useRef } from 'react';
import { record } from 'rrweb';
import { base44 } from '@/api/base44Client';
import { getActivitySessionId } from './useUserTracking';

const EXCLUDED_PREFIXES = ['/admin','/database','/wa','/m','/letterhead','/aiandi','/authenticate','/reset-password','/login','/register','/forgot-password'];
const isExcluded = (p) => EXCLUDED_PREFIXES.some((pre) => p === pre || p.startsWith(pre + '/'));

export default function useSessionRecorder() {
  const events = useRef([]);
  const lastFlushCount = useRef(0);

  const flush = async () => {
    if (events.current.length === 0) return;
    if (events.current.length === lastFlushCount.current) return;
    const sessionId = getActivitySessionId();
    const blob = new Blob([JSON.stringify(events.current)], { type: 'application/json' });
    const file = new File([blob], 'session-' + sessionId + '.json', { type: 'application/json' });
    try {
      const { file_url } = await base44.integrations.Core.UploadFile({ file });
      const existing = await base44.entities.SessionRecording.filter({ session_id: sessionId }, '-created_date', 5);
      if (existing && existing.length) {
        await base44.entities.SessionRecording.update(existing[0].id, { file_url, event_count: events.current.length });
      } else {
        await base44.entities.SessionRecording.create({ session_id: sessionId, file_url, started_at: new Date().toISOString(), event_count: events.current.length, path: window.location.pathname });
      }
      lastFlushCount.current = events.current.length;
    } catch { /* best-effort */ }
  };

  useEffect(() => {
    if (isExcluded(window.location.pathname)) return;
    let stopFn;
    try {
      stopFn = record({ emit(event) { events.current.push(event); }, maskAllInputs: true, maskInputOptions: { password: true, text: true, email: true, search: true, tel: true, url: true }, blockClass: 'rr-block' });
    } catch { return; }
    const interval = setInterval(flush, 60000);
    const onVisibility = () => { if (document.visibilityState === 'hidden') flush(); };
    const onHide = () => flush();
    document.addEventListener('visibilitychange', onVisibility);
    window.addEventListener('pagehide', onHide);
    return () => { flush(); if (stopFn) stopFn(); clearInterval(interval); document.removeEventListener('visibilitychange', onVisibility); window.removeEventListener('pagehide', onHide); };
  }, []);
}
```

## Backend Function 1: recordUserActivity

Receives a batch of events, enriches each with IP + geolocation (use ip-api.com with geojs fallback), truncates strings to schema limits, and bulk-creates UserActivity records.

```ts
import { createClientFromRequest } from 'npm:@base44/sdk@0.8.38';

Deno.serve(async (req) => {
  try {
    const base44 = createClientFromRequest(req);
    const body = await req.json();
    const events = body?.events || [];
    const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || req.headers.get('x-real-ip') || '';
    const ua = req.headers.get('user-agent') || '';
    let geo = { city: '', region: '', country: '' };
    if (ip) {
      try {
        const r = await fetch('http://ip-api.com/json/' + ip + '?fields=city,regionName,countryName');
        if (r.ok) { const j = await r.json(); geo = { city: j.city || '', region: j.regionName || '', country: j.countryName || '' }; }
      } catch {}
    }
    const clean = events.filter(e => e && e.action && e.session_id).map(e => ({
      ...e,
      target: String(e.target || '').slice(0, 200),
      path: String(e.path || '').slice(0, 200),
      details: String(e.details || '').slice(0, 1000),
      referrer: String(e.referrer || '').slice(0, 500),
      host: String(e.host || '').slice(0, 200),
      campaign: String(e.campaign || '').slice(0, 50),
      user_agent: ua.slice(0, 500),
      ip,
      city: geo.city, region: geo.region, country: geo.country,
    }));
    await base44.asServiceRole.entities.UserActivity.bulkCreate(clean);
    return Response.json({ ok: true, saved: clean.length });
  } catch (error) { return Response.json({ error: error.message }, { status: 500 }); }
});
```

## Backend Function 2: getVisitorLog

Fetches UserActivity, MusicEvent, InquiryLog, and RevealAttempt records for a time window, groups them by session_id, and builds one row per visitor session with: arrival time, referrer, device/OS/browser (parsed from UA), geo, landing page, time on landing, next page, scroll depth, video watched, unmuted, total clicks, page count, total duration, inquiry/reveal badges, and a full per-page journey (path, time, scroll, clicks per page).

Key details:
- Paginate UserActivity newest-first, stop when events predate the window.
- Group events by session_id, sort chronologically.
- Build a deduped page sequence from all events with a path.
- Per-page: max scroll depth, list of clicks (text + target).
- Session-level: max scroll across all pages, total clicks, total duration (last event ts - first event ts).
- "watchedVideo" = scrolled on "/" (home).
- "unmuted" = session_id exists in MusicEvent unmute set.
- Truncate session_id to 14 chars for the row's sid (this is the key the frontend recording map uses).
- Return { rows (max 500), total, eventsScanned }.

## Frontend: VisitorLogTable component

A spreadsheet-style admin table showing one row per visitor session. Features:
- Date range picker (1/7/30/90 days or custom range).
- Search across sessions, referrers, pages, locations, IPs.
- Filters: unmuted only, watched video only, by campaign.
- Expandable rows showing the full page-by-page journey (page, time, scroll %, clicks).
- Campaign grouping pills with session/click counts.
- IP tagging (click any IP to add/edit a tag, persisted to IpTag entity).
- Column visibility toggle (persisted to ArtColumnPref entity).
- CSV export with up to 20 pages of detail.
- Replay column: shows a play button when a SessionRecording exists for that session's 14-char sid prefix.

The recording map is built by loading SessionRecording entities and keying by the first 14 chars of session_id:
```js
const recs = await base44.entities.SessionRecording.list('-created_date', 500);
const map = {};
for (const r of recs) { if (r.session_id && r.file_url) map[String(r.session_id).slice(0, 14)] = r.file_url; }
setRecordingByUrl(map);
```
Then in the replay cell: `const url = recordingByUrl[r.sid];` — if url exists, show a play button that opens the SessionReplayer.

## Frontend: SessionReplayer component

A modal that fetches the rrweb JSON file, dynamically imports rrweb-player + its CSS, and mounts the player. CRITICAL: the mount div must always be in the DOM (not conditionally rendered), with the loading spinner overlaid on top — otherwise the player can't attach and loading hangs forever.

```jsx
import React, { useEffect, useRef, useState } from 'react';
import { Loader2, X } from 'lucide-react';

export default function SessionReplayer({ fileUrl, onClose }) {
  const mountRef = useRef(null);
  const playerRef = useRef(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let cancelled = false;
    (async () => {
      try {
        const res = await fetch(fileUrl);
        if (!res.ok) throw new Error('fetch failed');
        const events = await res.json();
        if (cancelled) return;
        if (!Array.isArray(events) || !events.length) throw new Error('empty recording');
        const RRWebPlayer = (await import('rrweb-player')).default;
        await import('rrweb-player/dist/style.css');
        if (cancelled || !mountRef.current) return;
        mountRef.current.innerHTML = '';
        playerRef.current = new RRWebPlayer({ target: mountRef.current, props: { events, width: 900, height: 560, autoPlay: false } });
        setLoading(false);
      } catch (e) { if (!cancelled) { setError('Could not load recording'); setLoading(false); } }
    })();
    return () => { cancelled = true; try { if (playerRef.current?.$destroy) playerRef.current.$destroy(); } catch {} };
  }, [fileUrl]);

  return (
    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={onClose}>
      <div className="bg-white rounded-lg shadow-xl w-full max-w-4xl max-h-[90vh] overflow-auto" onClick={(e) => e.stopPropagation()}>
        <div className="flex items-center justify-between px-4 py-2.5 border-b border-border">
          <span className="text-sm font-semibold">Session replay</span>
          <button onClick={onClose} className="text-zinc-400 hover:text-foreground"><X size={16} /></button>
        </div>
        <div className="p-4 flex items-center justify-center min-h-[400px] relative">
          <div ref={mountRef} className="rrweb-replay" />
          {loading && <div className="absolute inset-0 flex items-center justify-center bg-white/80"><div className="flex items-center gap-2 text-zinc-500 text-sm"><Loader2 size={16} className="animate-spin" /> Loading recording…</div></div>}
          {error && !loading && <div className="absolute inset-0 flex items-center justify-center bg-white"><div className="text-sm text-red-600">{error}</div></div>}
        </div>
      </div>
    </div>
  );
}
```

## Wiring it up

In your app's root component (inside the auth provider), call both hooks:
```js
import useUserTracking from '@/hooks/useUserTracking';
import useSessionRecorder from '@/hooks/useSessionRecorder';
// ...
useUserTracking();
useSessionRecorder();
```

## npm packages required
- rrweb (DOM recording)
- rrweb-player (replay player)
- lucide-react (icons)

## Notes
- Session replays only work for real browser sessions (bots/crawlers don't execute JS, so they'll never have recordings — this is expected).
- The recorder flushes every 60 seconds, so very short visits (<60s with no pagehide) may not upload.
- The 14-char session_id prefix is the join key between the visitor log rows and the recording map — keep them consistent on both sides.
- maskAllInputs is enabled to avoid recording sensitive form data.