diff --git a/app/sites/[id]/replays/[replayId]/page.tsx b/app/sites/[id]/replays/[replayId]/page.tsx new file mode 100644 index 0000000..1a75d47 --- /dev/null +++ b/app/sites/[id]/replays/[replayId]/page.tsx @@ -0,0 +1,352 @@ +'use client' + +import { useEffect, useState, useRef } from 'react' +import { useParams, useRouter } from 'next/navigation' +import { getSite, type Site } from '@/lib/api/sites' +import { getReplay, getReplayData, deleteReplay, formatDuration, type SessionReplay } from '@/lib/api/replays' +import { toast } from 'sonner' +import LoadingOverlay from '@/components/LoadingOverlay' + +function formatDate(dateString: string) { + const date = new Date(dateString) + return date.toLocaleDateString('en-US', { + weekday: 'short', + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit' + }) +} + +function getFlagEmoji(countryCode: string | null) { + if (!countryCode || countryCode.length !== 2) return '🌍' + const codePoints = countryCode + .toUpperCase() + .split('') + .map(char => 127397 + char.charCodeAt(0)) + return String.fromCodePoint(...codePoints) +} + +export default function ReplayViewerPage() { + const params = useParams() + const router = useRouter() + const siteId = params.id as string + const replayId = params.replayId as string + + const [site, setSite] = useState(null) + const [replay, setReplay] = useState(null) + const [replayData, setReplayData] = useState(null) + const [loading, setLoading] = useState(true) + const [loadingData, setLoadingData] = useState(false) + const [playerReady, setPlayerReady] = useState(false) + const [isPlaying, setIsPlaying] = useState(false) + const [speed, setSpeed] = useState(1) + const [showDeleteModal, setShowDeleteModal] = useState(false) + + const playerContainerRef = useRef(null) + const playerRef = useRef(null) + + // Load site and replay info + useEffect(() => { + const init = async () => { + try { + const [siteData, replayData] = await Promise.all([ + getSite(siteId), + getReplay(siteId, replayId) + ]) + setSite(siteData) + setReplay(replayData) + } catch (error: unknown) { + toast.error('Failed to load replay') + } finally { + setLoading(false) + } + } + init() + }, [siteId, replayId]) + + // Load replay data + useEffect(() => { + const loadData = async () => { + if (!replay) return + setLoadingData(true) + try { + const data = await getReplayData(siteId, replayId) + setReplayData(data) + } catch (error: unknown) { + toast.error('Failed to load replay data') + } finally { + setLoadingData(false) + } + } + loadData() + }, [replay, siteId, replayId]) + + // Initialize rrweb player when data is ready + useEffect(() => { + if (!replayData || !playerContainerRef.current || replayData.length === 0) return + + // Dynamically import rrweb-player + const initPlayer = async () => { + try { + const rrwebPlayer = await import('rrweb-player') + + // Clear previous player + if (playerContainerRef.current) { + playerContainerRef.current.innerHTML = '' + } + + // Create player + const player = new rrwebPlayer.default({ + target: playerContainerRef.current!, + props: { + events: replayData as unknown[], + width: playerContainerRef.current!.clientWidth, + height: Math.min(600, window.innerHeight - 300), + autoPlay: false, + showController: true, + speed: speed, + skipInactive: true, + showWarning: false, + showDebug: false, + }, + }) + + playerRef.current = player + + // Listen for player events + player.addEventListener('pause', () => setIsPlaying(false)) + player.addEventListener('start', () => setIsPlaying(true)) + player.addEventListener('finish', () => setIsPlaying(false)) + + setPlayerReady(true) + } catch (error) { + console.error('Failed to initialize player:', error) + toast.error('Failed to initialize replay player') + } + } + + initPlayer() + + return () => { + if (playerRef.current) { + // Cleanup player + playerRef.current = null + } + } + }, [replayData]) + + // Update speed + useEffect(() => { + if (playerRef.current && typeof (playerRef.current as { setSpeed?: (s: number) => void }).setSpeed === 'function') { + (playerRef.current as { setSpeed: (s: number) => void }).setSpeed(speed) + } + }, [speed]) + + const handleDelete = async () => { + try { + await deleteReplay(siteId, replayId) + toast.success('Replay deleted') + router.push(`/sites/${siteId}/replays`) + } catch (error: unknown) { + toast.error('Failed to delete replay') + } + } + + if (loading) return + if (!site || !replay) return
Replay not found
+ + return ( +
+ {/* Header */} +
+
+ +
+
+

+ Session Replay +

+
+ +
+
+
+ +
+ {/* Player */} +
+
+ {/* Player Container */} +
+ {loadingData ? ( +
+
+ Loading replay data... +
+ ) : !replayData || replayData.length === 0 ? ( +
+
🎬
+

No replay data available

+

This session may not have recorded any events.

+
+ ) : null} +
+ + {/* Custom Controls */} + {playerReady && ( +
+
+ Playback Speed: +
+ {[0.5, 1, 2, 4].map((s) => ( + + ))} +
+
+
+ {replay.events_count} events + + {formatDuration(replay.duration_ms)} +
+
+ )} +
+
+ + {/* Session Info Sidebar */} +
+
+
+

Session Details

+
+
+
+ Session ID +

{replay.session_id}

+
+ +
+ Entry Page +

{replay.entry_page}

+
+ +
+
+ Duration +

{formatDuration(replay.duration_ms)}

+
+
+ Events +

{replay.events_count}

+
+
+ +
+
+ Device +

{replay.device_type || 'Unknown'}

+
+
+ Browser +

{replay.browser || 'Unknown'}

+
+
+ +
+
+ OS +

{replay.os || 'Unknown'}

+
+
+ Location +

{getFlagEmoji(replay.country)} {replay.country || 'Unknown'}

+
+
+ +
+ Started +

{formatDate(replay.started_at)}

+
+ + {replay.ended_at && ( +
+ Ended +

{formatDate(replay.ended_at)}

+
+ )} + +
+
+ {replay.is_skeleton_mode ? ( + + 🔒 Skeleton Mode (Anonymous) + + ) : replay.consent_given ? ( + + ✓ Consent Given + + ) : ( + + ⚠ No Consent Record + + )} +
+
+
+
+
+
+ + {/* Delete Confirmation Modal */} + {showDeleteModal && ( +
+
+

Delete Replay?

+

+ This action cannot be undone. The replay data will be permanently deleted. +

+
+ + +
+
+
+ )} +
+ ) +} diff --git a/app/sites/[id]/replays/page.tsx b/app/sites/[id]/replays/page.tsx new file mode 100644 index 0000000..71c6bb6 --- /dev/null +++ b/app/sites/[id]/replays/page.tsx @@ -0,0 +1,275 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useParams, useRouter } from 'next/navigation' +import { getSite, type Site } from '@/lib/api/sites' +import { listReplays, formatDuration, type ReplayListItem, type ReplayFilters } from '@/lib/api/replays' +import { toast } from 'sonner' +import LoadingOverlay from '@/components/LoadingOverlay' + +function formatDate(dateString: string) { + const date = new Date(dateString) + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }) +} + +function getFlagEmoji(countryCode: string | null) { + if (!countryCode || countryCode.length !== 2) return '🌍' + const codePoints = countryCode + .toUpperCase() + .split('') + .map(char => 127397 + char.charCodeAt(0)) + return String.fromCodePoint(...codePoints) +} + +function getDeviceEmoji(deviceType: string | null) { + switch (deviceType?.toLowerCase()) { + case 'mobile': + return '📱' + case 'tablet': + return '📱' + default: + return '💻' + } +} + +export default function ReplaysPage() { + const params = useParams() + const router = useRouter() + const siteId = params.id as string + + const [site, setSite] = useState(null) + const [replays, setReplays] = useState([]) + const [loading, setLoading] = useState(true) + const [total, setTotal] = useState(0) + const [filters, setFilters] = useState({ + limit: 20, + offset: 0, + }) + + // Load site info and replays + useEffect(() => { + const init = async () => { + try { + const siteData = await getSite(siteId) + setSite(siteData) + } catch (error: unknown) { + toast.error('Failed to load site') + } finally { + setLoading(false) + } + } + init() + }, [siteId]) + + // Load replays when filters change + useEffect(() => { + const loadReplays = async () => { + try { + const response = await listReplays(siteId, filters) + setReplays(response.replays || []) + setTotal(response.total) + } catch (error: unknown) { + toast.error('Failed to load replays') + } + } + if (site) { + loadReplays() + } + }, [siteId, site, filters]) + + const handlePageChange = (newOffset: number) => { + setFilters(prev => ({ ...prev, offset: newOffset })) + } + + if (loading) return + if (!site) return
Site not found
+ + const currentPage = Math.floor((filters.offset || 0) / (filters.limit || 20)) + 1 + const totalPages = Math.ceil(total / (filters.limit || 20)) + + return ( +
+
+
+ +
+
+

+ Session Replays + + {total} recordings + +

+ {site.replay_mode === 'disabled' && ( +
+ ⚠️ + Session replay is disabled + +
+ )} +
+
+ + {/* Filters */} +
+ + + +
+ + {/* Replays List */} +
+ {replays.length === 0 ? ( +
+
🎬
+

No session replays yet

+

+ {site.replay_mode === 'disabled' + ? 'Enable session replay in settings to start recording visitor sessions.' + : 'Recordings will appear here once visitors start interacting with your site.'} +

+
+ ) : ( + <> + + + + + + + + + + + + + + {replays.map((replay) => ( + router.push(`/sites/${siteId}/replays/${replay.id}`)} + > + + + + + + + + + ))} + +
SessionEntry PageDurationDeviceLocationDateActions
+
+ {replay.is_skeleton_mode && ( + + Skeleton + + )} + + {replay.session_id.substring(0, 8)}... + +
+
+ + {replay.entry_page} + + + + {formatDuration(replay.duration_ms)} + + +
+ {getDeviceEmoji(replay.device_type)} + + {replay.browser || 'Unknown'} + +
+
+ + {getFlagEmoji(replay.country)} {replay.country || 'Unknown'} + + + + {formatDate(replay.started_at)} + + + +
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ + Showing {(filters.offset || 0) + 1} - {Math.min((filters.offset || 0) + (filters.limit || 20), total)} of {total} + +
+ + + Page {currentPage} of {totalPages} + + +
+
+ )} + + )} +
+
+ ) +} diff --git a/app/sites/[id]/settings/page.tsx b/app/sites/[id]/settings/page.tsx index 8af5a90..f56a26c 100644 --- a/app/sites/[id]/settings/page.tsx +++ b/app/sites/[id]/settings/page.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react' import { useParams, useRouter } from 'next/navigation' -import { getSite, updateSite, resetSiteData, deleteSite, type Site, type GeoDataLevel } from '@/lib/api/sites' +import { getSite, updateSite, resetSiteData, deleteSite, type Site, type GeoDataLevel, type ReplayMode } from '@/lib/api/sites' import { getRealtime } from '@/lib/api/stats' import { toast } from 'sonner' import LoadingOverlay from '@/components/LoadingOverlay' @@ -18,6 +18,7 @@ import { CopyIcon, ExclamationTriangleIcon, LightningBoltIcon, + VideoIcon, } from '@radix-ui/react-icons' const TIMEZONES = [ @@ -45,7 +46,7 @@ export default function SiteSettingsPage() { const [site, setSite] = useState(null) const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) - const [activeTab, setActiveTab] = useState<'general' | 'visibility' | 'data'>('general') + const [activeTab, setActiveTab] = useState<'general' | 'visibility' | 'data' | 'replay'>('general') const [formData, setFormData] = useState({ name: '', @@ -58,7 +59,13 @@ export default function SiteSettingsPage() { collect_referrers: true, collect_device_info: true, collect_geo_data: 'full' as GeoDataLevel, - collect_screen_resolution: true + collect_screen_resolution: true, + // Session replay settings + replay_mode: 'disabled' as ReplayMode, + replay_sampling_rate: 100, + replay_retention_days: 30, + replay_mask_all_text: false, + replay_mask_all_inputs: true }) const [scriptCopied, setScriptCopied] = useState(false) const [linkCopied, setLinkCopied] = useState(false) @@ -85,7 +92,13 @@ export default function SiteSettingsPage() { collect_referrers: data.collect_referrers ?? true, collect_device_info: data.collect_device_info ?? true, collect_geo_data: data.collect_geo_data || 'full', - collect_screen_resolution: data.collect_screen_resolution ?? true + collect_screen_resolution: data.collect_screen_resolution ?? true, + // Session replay settings + replay_mode: data.replay_mode || 'disabled', + replay_sampling_rate: data.replay_sampling_rate ?? 100, + replay_retention_days: data.replay_retention_days ?? 30, + replay_mask_all_text: data.replay_mask_all_text ?? false, + replay_mask_all_inputs: data.replay_mask_all_inputs ?? true }) if (data.has_password) { setIsPasswordEnabled(true) @@ -121,7 +134,13 @@ export default function SiteSettingsPage() { collect_referrers: formData.collect_referrers, collect_device_info: formData.collect_device_info, collect_geo_data: formData.collect_geo_data, - collect_screen_resolution: formData.collect_screen_resolution + collect_screen_resolution: formData.collect_screen_resolution, + // Session replay settings + replay_mode: formData.replay_mode, + replay_sampling_rate: formData.replay_sampling_rate, + replay_retention_days: formData.replay_retention_days, + replay_mask_all_text: formData.replay_mask_all_text, + replay_mask_all_inputs: formData.replay_mask_all_inputs }) toast.success('Site updated successfully') loadSite() @@ -235,6 +254,17 @@ export default function SiteSettingsPage() { Data & Privacy + {/* Content Area */} @@ -703,6 +733,263 @@ export default function SiteSettingsPage() { )} + + {activeTab === 'replay' && ( +
+
+
+

Session Replay

+

Record and playback visitor sessions to understand user behavior.

+
+ + {/* Privacy Mode Selection */} +
+

Privacy Mode

+ +
+ {/* Disabled */} + + + {/* Consent Required */} + + + {/* Anonymous Skeleton */} + +
+
+ + {/* Recording Settings - Only show when not disabled */} + + {formData.replay_mode !== 'disabled' && ( + +

Recording Settings

+ + {/* Sampling Rate */} +
+
+
+

Sampling Rate

+

+ Percentage of sessions to record +

+
+ {formData.replay_sampling_rate}% +
+ setFormData({ ...formData, replay_sampling_rate: parseInt(e.target.value) })} + className="w-full h-2 bg-neutral-200 dark:bg-neutral-700 rounded-lg appearance-none cursor-pointer accent-brand-orange" + /> +
+ 1% + 50% + 100% +
+
+ + {/* Retention Period */} +
+
+
+

Retention Period

+

+ How long to keep recordings +

+
+
+ +
+ + + +
+
+
+
+ + {/* Masking Options - Only for consent mode */} + {formData.replay_mode === 'consent_required' && ( + <> +

Privacy Masking

+ + {/* Mask All Text */} +
+
+
+

Mask All Text

+

+ Replace all text content with asterisks +

+
+ +
+
+ + {/* Mask All Inputs */} +
+
+
+

Mask All Inputs

+

+ Hide all form input values (recommended) +

+
+ +
+
+ + )} + + {/* Integration Guide */} + {formData.replay_mode === 'consent_required' && ( +
+

Integration with Consent Managers

+

+ Call this function when the user grants consent for analytics/functional cookies: +

+ + window.ciphera('enableReplay') + +
+ )} + + {/* View Replays Link */} + +
+ )} +
+ +
+ +
+
+
+ )} diff --git a/lib/api/replays.ts b/lib/api/replays.ts new file mode 100644 index 0000000..7fd5a1a --- /dev/null +++ b/lib/api/replays.ts @@ -0,0 +1,112 @@ +import apiRequest from './client' + +export interface ReplayListItem { + id: string + session_id: string + started_at: string + ended_at: string | null + duration_ms: number + events_count: number + device_type: string | null + browser: string | null + os: string | null + country: string | null + entry_page: string + is_skeleton_mode: boolean +} + +export interface ReplayFilters { + device_type?: string + country?: string + min_duration?: number + limit?: number + offset?: number +} + +export interface ReplayListResponse { + replays: ReplayListItem[] + total: number + limit: number + offset: number +} + +export interface SessionReplay extends ReplayListItem { + site_id: string + consent_given: boolean + created_at: string + expires_at: string +} + +export async function listReplays( + siteId: string, + filters?: ReplayFilters +): Promise { + const params = new URLSearchParams() + if (filters?.device_type) params.set('device_type', filters.device_type) + if (filters?.country) params.set('country', filters.country) + if (filters?.min_duration) params.set('min_duration', filters.min_duration.toString()) + if (filters?.limit) params.set('limit', filters.limit.toString()) + if (filters?.offset) params.set('offset', filters.offset.toString()) + + const queryString = params.toString() + const url = `/sites/${siteId}/replays${queryString ? `?${queryString}` : ''}` + + return apiRequest(url) +} + +export async function getReplay(siteId: string, replayId: string): Promise { + return apiRequest(`/sites/${siteId}/replays/${replayId}`) +} + +export async function getReplayData(siteId: string, replayId: string): Promise { + const response = await apiRequest(`/sites/${siteId}/replays/${replayId}/data`) + return response +} + +export async function deleteReplay(siteId: string, replayId: string): Promise { + await apiRequest(`/sites/${siteId}/replays/${replayId}`, { + method: 'DELETE', + }) +} + +// Utility function to format replay duration +export function formatDuration(ms: number): string { + if (ms < 1000) return `${ms}ms` + const seconds = Math.floor(ms / 1000) + if (seconds < 60) return `${seconds}s` + const minutes = Math.floor(seconds / 60) + const remainingSeconds = seconds % 60 + if (minutes < 60) return `${minutes}m ${remainingSeconds}s` + const hours = Math.floor(minutes / 60) + const remainingMinutes = minutes % 60 + return `${hours}h ${remainingMinutes}m` +} + +// Utility function to get device icon +export function getDeviceIcon(deviceType: string | null): string { + switch (deviceType?.toLowerCase()) { + case 'mobile': + return '📱' + case 'tablet': + return '📱' + case 'desktop': + default: + return '💻' + } +} + +// Utility function to get browser icon +export function getBrowserIcon(browser: string | null): string { + switch (browser?.toLowerCase()) { + case 'chrome': + return '🌐' + case 'firefox': + return '🦊' + case 'safari': + return '🧭' + case 'edge': + return '🌀' + default: + return '🌐' + } +} diff --git a/lib/api/sites.ts b/lib/api/sites.ts index 4d8390f..6c7a08e 100644 --- a/lib/api/sites.ts +++ b/lib/api/sites.ts @@ -1,6 +1,7 @@ import apiRequest from './client' export type GeoDataLevel = 'full' | 'country' | 'none' +export type ReplayMode = 'disabled' | 'consent_required' | 'anonymous_skeleton' export interface Site { id: string @@ -17,6 +18,12 @@ export interface Site { collect_device_info?: boolean collect_geo_data?: GeoDataLevel collect_screen_resolution?: boolean + // Session replay settings + replay_mode?: ReplayMode + replay_sampling_rate?: number + replay_retention_days?: number + replay_mask_all_text?: boolean + replay_mask_all_inputs?: boolean created_at: string updated_at: string } @@ -39,6 +46,12 @@ export interface UpdateSiteRequest { collect_device_info?: boolean collect_geo_data?: GeoDataLevel collect_screen_resolution?: boolean + // Session replay settings + replay_mode?: ReplayMode + replay_sampling_rate?: number + replay_retention_days?: number + replay_mask_all_text?: boolean + replay_mask_all_inputs?: boolean } export async function listSites(): Promise { diff --git a/package-lock.json b/package-lock.json index 2062463..e7ac58f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,8 @@ "react-icons": "^5.5.0", "react-simple-maps": "^3.0.0", "recharts": "^2.15.0", + "rrweb": "^2.0.0-alpha.4", + "rrweb-player": "^1.0.0-alpha.4", "sonner": "^2.0.7" }, "devDependencies": { @@ -1278,6 +1280,12 @@ "react": "^16.x || ^17.x || ^18.x || ^19.0.0 || ^19.0.0-rc" } }, + "node_modules/@rrweb/types": { + "version": "2.0.0-alpha.18", + "resolved": "https://registry.npmjs.org/@rrweb/types/-/types-2.0.0-alpha.18.tgz", + "integrity": "sha512-iMH3amHthJZ9x3gGmBPmdfim7wLGygC2GciIkw2A6SO8giSn8PHYtRT8OKNH4V+k3SZ6RSnYHcTQxBA7pSWZ3Q==", + "license": "MIT" + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -1307,6 +1315,12 @@ "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, + "node_modules/@tsconfig/svelte": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@tsconfig/svelte/-/svelte-1.0.13.tgz", + "integrity": "sha512-5lYJP45Xllo4yE/RUBccBT32eBlRDbqN8r1/MIvQbKxW3aFqaYPCNgm8D5V20X4ShHcwvYWNlKg3liDh1MlBoA==", + "license": "MIT" + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -1318,6 +1332,12 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/css-font-loading-module": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/@types/css-font-loading-module/-/css-font-loading-module-0.0.7.tgz", + "integrity": "sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q==", + "license": "MIT" + }, "node_modules/@types/d3-array": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", @@ -2045,6 +2065,12 @@ "win32" ] }, + "node_modules/@xstate/fsm": { + "version": "1.6.5", + "resolved": "https://registry.npmjs.org/@xstate/fsm/-/fsm-1.6.5.tgz", + "integrity": "sha512-b5o1I6aLNeYlU/3CPlj/Z91ybk1gUsKT+5NAJI+2W4UjvS5KLG28K9v5UvNoFVjHV8PajVZ00RH3vnjyQO7ZAw==", + "license": "MIT" + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -2421,6 +2447,15 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.9.14", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.14.tgz", @@ -3912,6 +3947,12 @@ "reusify": "^1.0.4" } }, + "node_modules/fflate": { + "version": "0.4.8", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.8.tgz", + "integrity": "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -5198,6 +5239,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, "node_modules/motion-dom": { "version": "12.26.2", "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.26.2.tgz", @@ -6151,6 +6198,47 @@ "node": ">=0.10.0" } }, + "node_modules/rrdom": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/rrdom/-/rrdom-0.1.7.tgz", + "integrity": "sha512-ZLd8f14z9pUy2Hk9y636cNv5Y2BMnNEY99wxzW9tD2BLDfe1xFxtLjB4q/xCBYo6HRe0wofzKzjm4JojmpBfFw==", + "license": "MIT", + "dependencies": { + "rrweb-snapshot": "^2.0.0-alpha.4" + } + }, + "node_modules/rrweb": { + "version": "2.0.0-alpha.4", + "resolved": "https://registry.npmjs.org/rrweb/-/rrweb-2.0.0-alpha.4.tgz", + "integrity": "sha512-wEHUILbxDPcNwkM3m4qgPgXAiBJyqCbbOHyVoNEVBJzHszWEFYyTbrZqUdeb1EfmTRC2PsumCIkVcomJ/xcOzA==", + "license": "MIT", + "dependencies": { + "@rrweb/types": "^2.0.0-alpha.4", + "@types/css-font-loading-module": "0.0.7", + "@xstate/fsm": "^1.4.0", + "base64-arraybuffer": "^1.0.1", + "fflate": "^0.4.4", + "mitt": "^3.0.0", + "rrdom": "^0.1.7", + "rrweb-snapshot": "^2.0.0-alpha.4" + } + }, + "node_modules/rrweb-player": { + "version": "1.0.0-alpha.4", + "resolved": "https://registry.npmjs.org/rrweb-player/-/rrweb-player-1.0.0-alpha.4.tgz", + "integrity": "sha512-Wlmn9GZ5Fdqa37vd3TzsYdLl/JWEvXNUrLCrYpnOwEgmY409HwVIvvA5aIo7k582LoKgdRCsB87N+f0oWAR0Kg==", + "license": "MIT", + "dependencies": { + "@tsconfig/svelte": "^1.0.0", + "rrweb": "^2.0.0-alpha.4" + } + }, + "node_modules/rrweb-snapshot": { + "version": "2.0.0-alpha.4", + "resolved": "https://registry.npmjs.org/rrweb-snapshot/-/rrweb-snapshot-2.0.0-alpha.4.tgz", + "integrity": "sha512-KQ2OtPpXO5jLYqg1OnXS/Hf+EzqnZyP5A+XPqBCjYpj3XIje/Od4gdUwjbFo3cVuWq5Cw5Y1d3/xwgIS7/XpQQ==", + "license": "MIT" + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", diff --git a/package.json b/package.json index d46e057..2828843 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,8 @@ "react-icons": "^5.5.0", "react-simple-maps": "^3.0.0", "recharts": "^2.15.0", + "rrweb": "^2.0.0-alpha.4", + "rrweb-player": "^1.0.0-alpha.4", "sonner": "^2.0.7" }, "overrides": { diff --git a/public/script.js b/public/script.js index ba28a42..d1ffd72 100644 --- a/public/script.js +++ b/public/script.js @@ -1,6 +1,7 @@ /** * Ciphera Analytics - Privacy-First Tracking Script * Lightweight, no cookies, GDPR compliant + * Includes optional session replay with privacy controls */ (function() { @@ -19,11 +20,22 @@ const domain = script.getAttribute('data-domain'); const apiUrl = script.getAttribute('data-api') || 'https://analytics-api.ciphera.net'; - + // * Performance Monitoring (Core Web Vitals) State let currentEventId = null; let metrics = { lcp: 0, cls: 0, inp: 0 }; + // * Session Replay State + let replayEnabled = false; + let replayMode = 'disabled'; + let replayId = null; + let replaySettings = null; + let rrwebStopFn = null; + let replayEvents = []; + let chunkInterval = null; + const CHUNK_SIZE = 50; + const CHUNK_INTERVAL_MS = 10000; + // * Minimal Web Vitals Observer function observeMetrics() { try { @@ -55,7 +67,7 @@ if (entry.duration > metrics.inp) metrics.inp = entry.duration; } }).observe({ type: 'event', buffered: true, durationThreshold: 16 }); - + } catch (e) { // * Browser doesn't support PerformanceObserver or specific entry types } @@ -91,6 +103,11 @@ document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') { sendMetrics(); + // Also flush replay data + if (replayEnabled) { + sendReplayChunk(); + endReplaySession(); + } } }); @@ -107,11 +124,11 @@ const key = 'ciphera_session_id'; // * Legacy key support for migration (strip whitespace just in case) const legacyKey = 'plausible_session_' + (domain ? domain.trim() : ''); - + try { // * Try to get existing session ID cachedSessionId = sessionStorage.getItem(key); - + // * If not found in new key, try legacy key and migrate if (!cachedSessionId && legacyKey) { cachedSessionId = sessionStorage.getItem(legacyKey); @@ -139,9 +156,9 @@ function trackPageview() { // * Reset metrics for new pageview (SPA navigation) // * We don't reset immediately on the first run, but for subsequent calls we should - // * However, for the very first call, metrics are already 0. + // * However, for the very first call, metrics are already 0. // * The issue is if we reset metrics here, we might lose early captured metrics (e.g. LCP) if this runs late? - // * No, trackPageview runs early. + // * No, trackPageview runs early. // * BUT for SPA navigation, we want to reset. if (currentEventId) { // If we already had an event ID, it means this is a subsequent navigation @@ -149,7 +166,7 @@ // Ideally visibilitychange handles this, but for SPA nav it might not trigger visibilitychange. sendMetrics(); } - + metrics = { lcp: 0, cls: 0, inp: 0 }; currentEventId = null; @@ -186,9 +203,277 @@ }); } + // ========================================== + // * SESSION REPLAY FUNCTIONALITY + // ========================================== + + // * Fetch replay settings from API + async function fetchReplaySettings() { + try { + const res = await fetch(apiUrl + '/api/v1/replay-settings/' + encodeURIComponent(domain)); + if (res.ok) { + replaySettings = await res.json(); + replayMode = replaySettings.replay_mode; + + // Check sampling rate + if (replayMode !== 'disabled') { + const shouldRecord = Math.random() * 100 < replaySettings.replay_sampling_rate; + if (!shouldRecord) { + replayMode = 'disabled'; + return; + } + } + + // Auto-start for anonymous_skeleton mode (no consent needed) + if (replayMode === 'anonymous_skeleton') { + startReplay(true); + } + } + } catch (e) { + // Silent fail - replay not critical + } + } + + // * Initialize replay session on server + async function initReplaySession(isSkeletonMode) { + try { + const res = await fetch(apiUrl + '/api/v1/replays', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + domain: domain, + session_id: getSessionId(), + entry_page: window.location.pathname, + is_skeleton_mode: isSkeletonMode, + consent_given: !isSkeletonMode, + device_type: detectDeviceType(), + browser: detectBrowser(), + os: detectOS() + }) + }); + + if (res.ok) { + const data = await res.json(); + replayId = data.id; + return true; + } + } catch (e) { + // Silent fail + } + return false; + } + + // * Start recording session + async function startReplay(isSkeletonMode) { + if (replayEnabled || typeof window.rrweb === 'undefined') return; + + // Initialize session on server first + const initialized = await initReplaySession(isSkeletonMode); + if (!initialized) return; + + replayEnabled = true; + + // Configure masking based on mode and settings + const maskConfig = { + // Always mask sensitive inputs + maskInputOptions: { + password: true, + email: true, + tel: true, + // In skeleton mode, mask all text inputs + text: isSkeletonMode, + textarea: isSkeletonMode, + select: isSkeletonMode + }, + // Mask all text in skeleton mode + maskAllText: isSkeletonMode || (replaySettings && replaySettings.replay_mask_all_text), + // Mask all inputs by default (can be overridden in settings) + maskAllInputs: replaySettings ? replaySettings.replay_mask_all_inputs : true, + // Custom classes for masking + maskTextClass: 'ciphera-mask', + blockClass: 'ciphera-block', + // Mask elements with data-ciphera-mask attribute + maskTextSelector: '[data-ciphera-mask]', + // Block elements with data-ciphera-block attribute + blockSelector: '[data-ciphera-block]', + // Custom input masking function for credit cards + maskInputFn: (text, element) => { + // Mask credit card patterns + if (/^\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}$/.test(text)) { + return '****-****-****-****'; + } + // Mask email patterns + if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(text)) { + return '***@***.***'; + } + return text; + } + }; + + try { + rrwebStopFn = window.rrweb.record({ + emit(event) { + replayEvents.push(event); + + // Send chunk when threshold reached + if (replayEvents.length >= CHUNK_SIZE) { + sendReplayChunk(); + } + }, + ...maskConfig, + // Privacy: Don't record external resources + recordCanvas: false, + collectFonts: false, + // Sampling for mouse movement (reduce data) + sampling: { + mousemove: true, + mouseInteraction: true, + scroll: 150, + input: 'last' + }, + // Inline styles for replay accuracy + inlineStylesheet: true, + // Slim snapshot to reduce size + slimDOMOptions: { + script: true, + comment: true, + headFavicon: true, + headWhitespace: true, + headMetaDescKeywords: true, + headMetaSocial: true, + headMetaRobots: true, + headMetaHttpEquiv: true, + headMetaAuthorship: true, + headMetaVerification: true + } + }); + + // Set up periodic chunk sending + chunkInterval = setInterval(sendReplayChunk, CHUNK_INTERVAL_MS); + } catch (e) { + replayEnabled = false; + replayId = null; + } + } + + // * Send chunk of events to server + async function sendReplayChunk() { + if (!replayId || replayEvents.length === 0) return; + + const chunk = replayEvents.splice(0, CHUNK_SIZE); + const eventsCount = chunk.length; + const data = JSON.stringify(chunk); + + try { + // Try to compress if available + let body; + let headers = { 'X-Events-Count': eventsCount.toString() }; + + if (typeof CompressionStream !== 'undefined') { + const blob = new Blob([data]); + const stream = blob.stream().pipeThrough(new CompressionStream('gzip')); + body = await new Response(stream).blob(); + headers['Content-Encoding'] = 'gzip'; + headers['Content-Type'] = 'application/octet-stream'; + } else { + body = new Blob([data], { type: 'application/json' }); + headers['Content-Type'] = 'application/json'; + } + + await fetch(apiUrl + '/api/v1/replays/' + replayId + '/chunks', { + method: 'POST', + headers: headers, + body: body, + keepalive: true + }); + } catch (e) { + // Re-queue events on failure + replayEvents.unshift(...chunk); + } + } + + // * End replay session + function endReplaySession() { + if (!replayEnabled || !replayId) return; + + // Clear interval + if (chunkInterval) { + clearInterval(chunkInterval); + chunkInterval = null; + } + + // Stop recording + if (rrwebStopFn) { + rrwebStopFn(); + rrwebStopFn = null; + } + + // Send remaining events + if (replayEvents.length > 0) { + const chunk = replayEvents.splice(0); + const data = JSON.stringify(chunk); + navigator.sendBeacon( + apiUrl + '/api/v1/replays/' + replayId + '/chunks', + new Blob([data], { type: 'application/json' }) + ); + } + + // Mark session as ended + navigator.sendBeacon(apiUrl + '/api/v1/replays/' + replayId + '/end'); + + replayEnabled = false; + replayId = null; + } + + // * Device detection helpers + function detectDeviceType() { + const ua = navigator.userAgent.toLowerCase(); + if (/mobile|android|iphone|ipod/.test(ua)) return 'mobile'; + if (/tablet|ipad/.test(ua)) return 'tablet'; + return 'desktop'; + } + + function detectBrowser() { + const ua = navigator.userAgent.toLowerCase(); + if (ua.includes('chrome') && !ua.includes('edg')) return 'Chrome'; + if (ua.includes('firefox')) return 'Firefox'; + if (ua.includes('safari') && !ua.includes('chrome')) return 'Safari'; + if (ua.includes('edg')) return 'Edge'; + if (ua.includes('opera')) return 'Opera'; + return null; + } + + function detectOS() { + const ua = navigator.userAgent.toLowerCase(); + if (ua.includes('windows')) return 'Windows'; + if (ua.includes('mac os') || ua.includes('macos')) return 'macOS'; + if (ua.includes('linux')) return 'Linux'; + if (ua.includes('android')) return 'Android'; + if (ua.includes('ios') || ua.includes('iphone') || ua.includes('ipad')) return 'iOS'; + return null; + } + + // * Public API for consent-based activation + window.ciphera = window.ciphera || function(cmd) { + if (cmd === 'enableReplay') { + if (replayMode === 'consent_required' && !replayEnabled) { + startReplay(false); + } + } else if (cmd === 'disableReplay') { + endReplaySession(); + } else if (cmd === 'getReplayMode') { + return replayMode; + } else if (cmd === 'isReplayEnabled') { + return replayEnabled; + } + }; + // * Track initial pageview trackPageview(); + // * Fetch replay settings (async, doesn't block pageview) + fetchReplaySettings(); + // * Track SPA navigation (history API) let lastUrl = location.href; new MutationObserver(() => { @@ -201,4 +486,12 @@ // * Track popstate (browser back/forward) window.addEventListener('popstate', trackPageview); + + // * Cleanup on page unload + window.addEventListener('beforeunload', () => { + if (replayEnabled) { + sendReplayChunk(); + endReplaySession(); + } + }); })();