refactor(replay): remove session replay functionality and related components to streamline codebase

This commit is contained in:
Usman Baig
2026-01-23 18:21:50 +01:00
parent b64a48cc7d
commit e75d70269f
10 changed files with 6 additions and 1588 deletions

View File

@@ -14,11 +14,7 @@ export default function FAQPage() {
},
{
question: "What data does Pulse collect?",
answer: "We collect anonymous pageview data including page path, referrer, device type, browser, and country (derived from IP at request time; the IP itself is not stored). No personal information is collected. If you enable optional session replay, see 'What about session replay?' below."
},
{
question: "What about session replay?",
answer: "Session replay is optional and off by default. When enabled, we use Anonymous Skeleton mode: all visible text is replaced with blocks, all form inputs are hidden, and we do not record canvas or fonts. We record layout, clicks, and scrolls to help you understand how visitors use your site. Replay metadata (device, browser, OS, country) is stored; we redact common PII-like URL parameters (e.g. email=, token=) before sending. Session replay uses no cookies and does not require a consent banner. We respect Do Not Track—if it is set, replay does not run."
answer: "We collect anonymous pageview data including page path, referrer, device type, browser, and country (derived from IP at request time; the IP itself is not stored). No personal information is collected."
},
{
question: "How accurate is the data?",

View File

@@ -1,439 +0,0 @@
'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 { LockClosedIcon } from '@radix-ui/react-icons'
import LoadingOverlay from '@/components/LoadingOverlay'
import ReplayPlayerControls from '@/components/ReplayPlayerControls'
import type { eventWithTime } from '@rrweb/types'
import 'rrweb-player/dist/style.css'
// Fixed player dimensions (16:9 aspect ratio)
const PLAYER_WIDTH = 880
const PLAYER_HEIGHT = 495
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<Site | null>(null)
const [replay, setReplay] = useState<SessionReplay | null>(null)
const [replayData, setReplayData] = useState<eventWithTime[] | null>(null)
const [loading, setLoading] = useState(true)
const [loadingData, setLoadingData] = useState(false)
const [playerReady, setPlayerReady] = useState(false)
const [showDeleteModal, setShowDeleteModal] = useState(false)
const [isPlaying, setIsPlaying] = useState(false)
const [skipInactive, setSkipInactive] = useState(true)
const [speed, setSpeed] = useState(1)
const [currentTimeMs, setCurrentTimeMs] = useState(0)
const [totalTimeMs, setTotalTimeMs] = useState(0)
const playerWrapperRef = useRef<HTMLDivElement>(null)
const playerContainerRef = useRef<HTMLDivElement>(null)
const playerRef = useRef<unknown>(null)
// Load site and replay info
useEffect(() => {
const init = async () => {
try {
const [siteData, replayInfo] = await Promise.all([
getSite(siteId),
getReplay(siteId, replayId)
])
setSite(siteData)
setReplay(replayInfo)
} 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 (no built-in controller; we use ReplayPlayerControls)
useEffect(() => {
if (!replayData || !playerContainerRef.current || replayData.length === 0) return
setPlayerReady(false)
setCurrentTimeMs(0)
setIsPlaying(false)
const initPlayer = async () => {
try {
const rrwebPlayer = await import('rrweb-player')
if (!playerContainerRef.current) return
playerContainerRef.current.innerHTML = ''
const player = new rrwebPlayer.default({
target: playerContainerRef.current,
props: {
events: replayData,
width: PLAYER_WIDTH,
height: PLAYER_HEIGHT,
autoPlay: false,
showController: false,
skipInactive: true,
showWarning: false,
showDebug: false,
},
})
playerRef.current = player
try {
const meta = (player as { getMetaData?: () => { totalTime: number } }).getMetaData?.()
if (meta && typeof meta.totalTime === 'number') setTotalTimeMs(meta.totalTime)
} catch {
// ignore
}
setPlayerReady(true)
} catch (error) {
console.error('Failed to initialize player:', error)
toast.error('Failed to initialize replay player')
}
}
initPlayer()
return () => {
playerRef.current = null
}
}, [replayData])
// Poll current time and detect end of replay
useEffect(() => {
if (!playerReady) return
const interval = setInterval(() => {
const p = playerRef.current as
| { getReplayer?: () => { getCurrentTime?: () => number }; getMetaData?: () => { totalTime: number }; pause?: () => void }
| null
if (!p?.getReplayer) return
try {
const t = p.getReplayer?.()?.getCurrentTime?.()
if (typeof t === 'number') setCurrentTimeMs(t)
const meta = p.getMetaData?.()
if (meta && typeof meta.totalTime === 'number' && typeof t === 'number' && t >= meta.totalTime) {
p.pause?.()
setIsPlaying(false)
}
} catch {
// ignore
}
}, 200)
return () => clearInterval(interval)
}, [playerReady])
// Trigger rrweb replayer resize when entering fullscreen so it can scale
useEffect(() => {
const onFullscreenChange = () => {
if (document.fullscreenElement === playerWrapperRef.current) {
setTimeout(() => (playerRef.current as { triggerResize?: () => void })?.triggerResize?.(), 50)
}
}
document.addEventListener('fullscreenchange', onFullscreenChange)
return () => document.removeEventListener('fullscreenchange', onFullscreenChange)
}, [])
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 <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Loading Replay" />
if (!site || !replay) return <div className="p-8">Replay not found</div>
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
{/* Header */}
<div className="mb-6">
<div className="flex items-center gap-2 mb-1">
<button
onClick={() => router.push(`/sites/${siteId}/replays`)}
className="text-sm text-neutral-500 hover:text-neutral-900 dark:hover:text-white transition-colors"
>
&larr; Back to Replays
</button>
</div>
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white">
Session Replay
</h1>
<div className="flex items-center gap-3">
<button
onClick={() => setShowDeleteModal(true)}
className="px-3 py-1.5 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-xl transition-colors"
>
Delete
</button>
</div>
</div>
</div>
<div className="flex gap-6">
{/* Player */}
<div className="flex-1 min-w-0">
<div className="border border-neutral-200 dark:border-neutral-800 rounded-xl overflow-hidden bg-white dark:bg-neutral-900 shadow-sm">
<div ref={playerWrapperRef}>
{/* Player container (rrweb-player mounts here when replayData is ready) */}
<div
ref={playerContainerRef}
className="bg-neutral-900 flex items-center justify-center"
style={{ width: '100%', minHeight: PLAYER_HEIGHT + 80 }}
>
{loadingData ? (
<div className="flex flex-col items-center gap-3">
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-brand-orange" />
<span className="text-sm text-neutral-400">Loading replay data...</span>
</div>
) : !replayData || replayData.length === 0 ? (
<div className="text-center text-neutral-400 p-8">
<div className="text-4xl mb-4">🎬</div>
<p className="text-lg font-medium mb-2">No replay data available</p>
<p className="text-sm">This session may not have recorded any events.</p>
</div>
) : null}
</div>
{/* Custom controls (Cipherabranded) */}
{playerReady && (
<ReplayPlayerControls
isPlaying={isPlaying}
onPlayPause={() => {
;(playerRef.current as { toggle?: () => void })?.toggle?.()
setIsPlaying((p) => !p)
}}
currentTimeMs={currentTimeMs}
totalTimeMs={totalTimeMs}
onSeek={(f) => {
;(playerRef.current as { goto?: (ms: number, play?: boolean) => void })?.goto?.(f * totalTimeMs, isPlaying)
}}
speed={speed}
onSpeedChange={(s) => {
;(playerRef.current as { setSpeed?: (n: number) => void })?.setSpeed?.(s)
setSpeed(s)
}}
skipInactive={skipInactive}
onSkipInactiveChange={() => {
;(playerRef.current as { toggleSkipInactive?: () => void })?.toggleSkipInactive?.()
setSkipInactive((p) => !p)
}}
onFullscreenRequest={() => {
if (document.fullscreenElement) {
document.exitFullscreen()
} else {
playerWrapperRef.current?.requestFullscreen?.()
}
}}
/>
)}
</div>
{/* Session info bar */}
{playerReady && (
<div className="p-3 border-t border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-800/50 flex items-center justify-between text-sm">
<div className="flex items-center gap-4 text-neutral-600 dark:text-neutral-400">
<span>{replay.events_count} events</span>
<span></span>
<span>{formatDuration(replay.duration_ms)}</span>
</div>
<div className="flex items-center gap-2">
{replay.is_skeleton_mode && (
<span className="inline-flex items-center gap-1.5 text-xs bg-brand-orange/10 text-brand-orange px-2 py-0.5 rounded">
<LockClosedIcon className="w-3 h-3 flex-shrink-0" />
Skeleton Mode
</span>
)}
</div>
</div>
)}
</div>
</div>
{/* Session Info Sidebar */}
<div className="w-72 flex-shrink-0">
<div className="border border-neutral-200 dark:border-neutral-800 rounded-xl overflow-hidden bg-white dark:bg-neutral-900">
<div className="p-4 border-b border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-800/50">
<h2 className="font-semibold text-neutral-900 dark:text-white">Session Details</h2>
</div>
<div className="p-4 space-y-4">
<div>
<span className="text-xs text-neutral-500 uppercase tracking-wider">Session ID</span>
<p className="font-mono text-sm text-neutral-900 dark:text-white mt-1 truncate" title={replay.session_id}>{replay.session_id}</p>
</div>
<div>
<span className="text-xs text-neutral-500 uppercase tracking-wider">Entry Page</span>
<p className="text-sm text-neutral-900 dark:text-white mt-1 break-all">{replay.entry_page}</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<span className="text-xs text-neutral-500 uppercase tracking-wider">Duration</span>
<p className="text-sm text-neutral-900 dark:text-white mt-1">{formatDuration(replay.duration_ms)}</p>
</div>
<div>
<span className="text-xs text-neutral-500 uppercase tracking-wider">Events</span>
<p className="text-sm text-neutral-900 dark:text-white mt-1">{replay.events_count}</p>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<span className="text-xs text-neutral-500 uppercase tracking-wider">Device</span>
<p className="text-sm text-neutral-900 dark:text-white mt-1 capitalize">{replay.device_type || 'Unknown'}</p>
</div>
<div>
<span className="text-xs text-neutral-500 uppercase tracking-wider">Browser</span>
<p className="text-sm text-neutral-900 dark:text-white mt-1">{replay.browser || 'Unknown'}</p>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<span className="text-xs text-neutral-500 uppercase tracking-wider">OS</span>
<p className="text-sm text-neutral-900 dark:text-white mt-1">{replay.os || 'Unknown'}</p>
</div>
<div>
<span className="text-xs text-neutral-500 uppercase tracking-wider">Location</span>
<p className="text-sm text-neutral-900 dark:text-white mt-1">{getFlagEmoji(replay.country)} {replay.country || 'Unknown'}</p>
</div>
</div>
<div>
<span className="text-xs text-neutral-500 uppercase tracking-wider">Started</span>
<p className="text-sm text-neutral-900 dark:text-white mt-1">{formatDate(replay.started_at)}</p>
</div>
{replay.ended_at && (
<div>
<span className="text-xs text-neutral-500 uppercase tracking-wider">Ended</span>
<p className="text-sm text-neutral-900 dark:text-white mt-1">{formatDate(replay.ended_at)}</p>
</div>
)}
<div className="pt-2 border-t border-neutral-200 dark:border-neutral-800">
<div className="flex items-center gap-2">
{replay.is_skeleton_mode ? (
<span className="inline-flex items-center gap-1.5 text-xs bg-brand-orange/10 text-brand-orange px-2 py-1 rounded">
<LockClosedIcon className="w-3 h-3 flex-shrink-0" />
Skeleton Mode (Anonymous)
</span>
) : replay.consent_given ? (
<span className="text-xs bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400 px-2 py-1 rounded">
Consent Given
</span>
) : (
<span className="text-xs bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400 px-2 py-1 rounded">
No Consent Record
</span>
)}
</div>
</div>
</div>
</div>
</div>
</div>
{/* Delete Confirmation Modal */}
{showDeleteModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white dark:bg-neutral-900 rounded-xl p-6 max-w-md w-full mx-4 shadow-xl">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white mb-2">Delete Replay?</h3>
<p className="text-neutral-600 dark:text-neutral-400 mb-6">
This action cannot be undone. The replay data will be permanently deleted.
</p>
<div className="flex gap-3 justify-end">
<button
onClick={() => setShowDeleteModal(false)}
className="px-4 py-2 text-sm text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-xl transition-colors"
>
Cancel
</button>
<button
onClick={handleDelete}
className="px-4 py-2 text-sm bg-red-600 text-white rounded-xl hover:bg-red-700 transition-colors"
>
Delete
</button>
</div>
</div>
</div>
)}
{/* * rrweb player frame and cursor Ciphera branding (controller is custom, not rrweb) */}
<style jsx global>{`
.rr-player {
margin: 0 auto !important;
background: #171717 !important;
border-radius: 0 !important;
box-shadow: none !important;
}
.rr-player__frame {
background: #171717 !important;
overflow: hidden !important;
}
.replayer-wrapper {
margin: 0 auto !important;
background: #0a0a0a !important;
}
/* * Replay cursor brand orange (#FD5E0F) */
.replayer-mouse:after {
background: #FD5E0F !important;
}
.replayer-mouse.touch-device.touch-active {
border-color: #FD5E0F !important;
}
`}</style>
</div>
)
}

View File

@@ -1,278 +0,0 @@
'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 { LockClosedIcon } from '@radix-ui/react-icons'
import LoadingOverlay from '@/components/LoadingOverlay'
import Select from '@/components/ui/Select'
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<Site | null>(null)
const [replays, setReplays] = useState<ReplayListItem[]>([])
const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0)
const [filters, setFilters] = useState<ReplayFilters>({
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 <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Session Replays" />
if (!site) return <div className="p-8">Site not found</div>
const currentPage = Math.floor((filters.offset || 0) / (filters.limit || 20)) + 1
const totalPages = Math.ceil(total / (filters.limit || 20))
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
<div className="mb-6">
<div className="flex items-center gap-2 mb-1">
<button
onClick={() => router.push(`/sites/${siteId}`)}
className="text-sm text-neutral-500 hover:text-neutral-900 dark:hover:text-white transition-colors"
>
&larr; Back to Dashboard
</button>
</div>
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white flex items-center gap-3">
Session Replays
<span className="text-lg font-normal text-neutral-500">
{total} recordings
</span>
</h1>
{site.replay_mode === 'disabled' && (
<div className="flex items-center gap-2 text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 px-3 py-1.5 rounded-xl text-sm">
<span></span>
<span>Session replay is disabled</span>
<button
onClick={() => router.push(`/sites/${siteId}/settings`)}
className="underline hover:no-underline"
>
Enable in settings
</button>
</div>
)}
</div>
</div>
{/* Filters */}
<div className="mb-6 flex gap-4 flex-wrap">
<Select
value={filters.device_type ?? ''}
onChange={(v) => setFilters((prev) => ({ ...prev, device_type: v || undefined, offset: 0 }))}
options={[
{ value: '', label: 'All Devices' },
{ value: 'desktop', label: 'Desktop' },
{ value: 'mobile', label: 'Mobile' },
{ value: 'tablet', label: 'Tablet' },
]}
/>
<Select
value={filters.min_duration ? String(filters.min_duration) : ''}
onChange={(v) => setFilters((prev) => ({ ...prev, min_duration: v ? parseInt(v, 10) : undefined, offset: 0 }))}
options={[
{ value: '', label: 'Any Duration' },
{ value: '5000', label: '5s+' },
{ value: '30000', label: '30s+' },
{ value: '60000', label: '1m+' },
{ value: '300000', label: '5m+' },
]}
/>
</div>
{/* Replays List */}
<div className="border border-neutral-200 dark:border-neutral-800 rounded-xl overflow-hidden bg-white dark:bg-neutral-900">
{replays.length === 0 ? (
<div className="p-12 text-center text-neutral-500">
<div className="text-4xl mb-4">🎬</div>
<p className="text-lg font-medium mb-2">No session replays yet</p>
<p className="text-sm">
{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.'}
</p>
</div>
) : (
<>
<table className="w-full">
<thead>
<tr className="border-b border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-800/50">
<th className="text-left px-4 py-3 text-sm font-semibold text-neutral-600 dark:text-neutral-400">Session</th>
<th className="text-left px-4 py-3 text-sm font-semibold text-neutral-600 dark:text-neutral-400">Entry Page</th>
<th className="text-left px-4 py-3 text-sm font-semibold text-neutral-600 dark:text-neutral-400">Duration</th>
<th className="text-left px-4 py-3 text-sm font-semibold text-neutral-600 dark:text-neutral-400">Device</th>
<th className="text-left px-4 py-3 text-sm font-semibold text-neutral-600 dark:text-neutral-400">Location</th>
<th className="text-left px-4 py-3 text-sm font-semibold text-neutral-600 dark:text-neutral-400">Date</th>
<th className="text-right px-4 py-3 text-sm font-semibold text-neutral-600 dark:text-neutral-400">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-100 dark:divide-neutral-800">
{replays.map((replay) => (
<tr
key={replay.id}
className="hover:bg-neutral-50 dark:hover:bg-neutral-800/50 transition-colors cursor-pointer"
onClick={() => router.push(`/sites/${siteId}/replays/${replay.id}`)}
>
<td className="px-4 py-4">
<div className="flex items-center gap-2">
{replay.is_skeleton_mode && (
<span className="inline-flex items-center gap-1 text-xs bg-brand-orange/10 text-brand-orange px-1.5 py-0.5 rounded">
<LockClosedIcon className="w-3 h-3 flex-shrink-0" />
Skeleton
</span>
)}
<span className="font-mono text-sm text-neutral-500">
{replay.session_id.substring(0, 8)}...
</span>
</div>
</td>
<td className="px-4 py-4">
<span className="text-sm text-neutral-900 dark:text-white truncate block max-w-[200px]" title={replay.entry_page}>
{replay.entry_page}
</span>
</td>
<td className="px-4 py-4">
<span className="text-sm text-neutral-600 dark:text-neutral-400">
{formatDuration(replay.duration_ms)}
</span>
</td>
<td className="px-4 py-4">
<div className="flex items-center gap-2 text-sm">
<span>{getDeviceEmoji(replay.device_type)}</span>
<span className="text-neutral-600 dark:text-neutral-400">
{replay.browser || 'Unknown'}
</span>
</div>
</td>
<td className="px-4 py-4">
<span className="text-sm">
{getFlagEmoji(replay.country)} {replay.country || 'Unknown'}
</span>
</td>
<td className="px-4 py-4">
<span className="text-sm text-neutral-500">
{formatDate(replay.started_at)}
</span>
</td>
<td className="px-4 py-4 text-right">
<button
onClick={(e) => {
e.stopPropagation()
router.push(`/sites/${siteId}/replays/${replay.id}`)
}}
className="text-sm text-brand-orange hover:underline"
>
Watch
</button>
</td>
</tr>
))}
</tbody>
</table>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between px-4 py-3 border-t border-neutral-200 dark:border-neutral-800">
<span className="text-sm text-neutral-500">
Showing {(filters.offset || 0) + 1} - {Math.min((filters.offset || 0) + (filters.limit || 20), total)} of {total}
</span>
<div className="flex gap-2">
<button
onClick={() => handlePageChange((filters.offset || 0) - (filters.limit || 20))}
disabled={currentPage === 1}
className="px-3 py-1 text-sm border border-neutral-200 dark:border-neutral-700 rounded-xl disabled:opacity-50 disabled:cursor-not-allowed hover:bg-neutral-50 dark:hover:bg-neutral-800"
>
Previous
</button>
<span className="px-3 py-1 text-sm text-neutral-500">
Page {currentPage} of {totalPages}
</span>
<button
onClick={() => handlePageChange((filters.offset || 0) + (filters.limit || 20))}
disabled={currentPage === totalPages}
className="px-3 py-1 text-sm border border-neutral-200 dark:border-neutral-700 rounded-xl disabled:opacity-50 disabled:cursor-not-allowed hover:bg-neutral-50 dark:hover:bg-neutral-800"
>
Next
</button>
</div>
</div>
)}
</>
)}
</div>
</div>
)
}

View File

@@ -2,8 +2,7 @@
import { useEffect, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { getSite, updateSite, resetSiteData, deleteSite, type Site, type GeoDataLevel, type ReplayMode } from '@/lib/api/sites'
import { getRealtime } from '@/lib/api/stats'
import { getSite, updateSite, resetSiteData, deleteSite, type Site, type GeoDataLevel } from '@/lib/api/sites'
import { toast } from 'sonner'
import LoadingOverlay from '@/components/LoadingOverlay'
import VerificationModal from '@/components/sites/VerificationModal'
@@ -21,19 +20,8 @@ import {
CopyIcon,
ExclamationTriangleIcon,
LightningBoltIcon,
VideoIcon,
LockClosedIcon,
} from '@radix-ui/react-icons'
/** Sampling rate options: 25, 50, 75, 100. */
const SAMPLING_RATE_OPTIONS = [25, 50, 75, 100] as const
function snapSamplingRate(v: number): number {
return (SAMPLING_RATE_OPTIONS as readonly number[]).reduce(
(best, x) => (Math.abs(x - v) < Math.abs(best - v) ? x : best)
)
}
const TIMEZONES = [
'UTC',
'America/New_York',
@@ -62,7 +50,7 @@ export default function SiteSettingsPage() {
const [site, setSite] = useState<Site | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [activeTab, setActiveTab] = useState<'general' | 'visibility' | 'data' | 'replay'>('general')
const [activeTab, setActiveTab] = useState<'general' | 'visibility' | 'data'>('general')
const [formData, setFormData] = useState({
name: '',
@@ -79,13 +67,7 @@ export default function SiteSettingsPage() {
// Performance insights setting
enable_performance_insights: false,
// Bot and noise filtering
filter_bots: 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
filter_bots: true
})
const [scriptCopied, setScriptCopied] = useState(false)
const [linkCopied, setLinkCopied] = useState(false)
@@ -117,13 +99,7 @@ export default function SiteSettingsPage() {
// Performance insights setting (default to false)
enable_performance_insights: data.enable_performance_insights ?? false,
// Bot and noise filtering (default to true)
filter_bots: data.filter_bots ?? true,
// Session replay settings (legacy consent_required from API mapped to anonymous_skeleton)
replay_mode: ((data as { replay_mode?: string }).replay_mode === 'consent_required' ? 'anonymous_skeleton' : data.replay_mode) || 'disabled',
replay_sampling_rate: snapSamplingRate(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
filter_bots: data.filter_bots ?? true
})
if (data.has_password) {
setIsPasswordEnabled(true)
@@ -163,13 +139,7 @@ export default function SiteSettingsPage() {
// Performance insights setting
enable_performance_insights: formData.enable_performance_insights,
// Bot and noise filtering
filter_bots: formData.filter_bots,
// 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
filter_bots: formData.filter_bots
})
toast.success('Site updated successfully')
loadSite()
@@ -291,17 +261,6 @@ export default function SiteSettingsPage() {
<FileTextIcon className="w-5 h-5" />
Data & Privacy
</button>
<button
onClick={() => setActiveTab('replay')}
className={`w-full flex items-center gap-3 px-4 py-3 text-sm font-medium rounded-xl transition-all duration-200 ${
activeTab === 'replay'
? 'bg-brand-orange/10 text-brand-orange'
: 'text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800'
}`}
>
<VideoIcon className="w-5 h-5" />
Session Replay
</button>
</nav>
{/* Content Area */}
@@ -857,174 +816,6 @@ export default function SiteSettingsPage() {
</form>
</div>
)}
{activeTab === 'replay' && (
<div className="space-y-12">
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<h2 className="text-xl font-semibold text-neutral-900 dark:text-white mb-1">Session Replay</h2>
<p className="text-sm text-neutral-500 dark:text-neutral-400">Record and playback visitor sessions to understand user behavior.</p>
</div>
{/* Privacy Mode Selection */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">Privacy Mode</h3>
<div className="space-y-3">
{/* Disabled */}
<label className={`block p-4 rounded-xl border-2 cursor-pointer transition-all ${
formData.replay_mode === 'disabled'
? 'border-brand-orange bg-brand-orange/5'
: 'border-neutral-200 dark:border-neutral-800 hover:border-neutral-300 dark:hover:border-neutral-700'
}`}>
<div className="flex items-start gap-3">
<input
type="radio"
name="replay_mode"
value="disabled"
checked={formData.replay_mode === 'disabled'}
onChange={(e) => setFormData({ ...formData, replay_mode: e.target.value as ReplayMode })}
className="mt-1 accent-brand-orange"
/>
<div>
<div className="font-medium text-neutral-900 dark:text-white">Disabled</div>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-0.5">
No session recording. Maximum privacy.
</p>
</div>
</div>
</label>
{/* Anonymous Skeleton */}
<label className={`block p-4 rounded-xl border-2 cursor-pointer transition-all ${
formData.replay_mode === 'anonymous_skeleton'
? 'border-brand-orange bg-brand-orange/5'
: 'border-neutral-200 dark:border-neutral-800 hover:border-neutral-300 dark:hover:border-neutral-700'
}`}>
<div className="flex items-start gap-3">
<input
type="radio"
name="replay_mode"
value="anonymous_skeleton"
checked={formData.replay_mode === 'anonymous_skeleton'}
onChange={(e) => setFormData({ ...formData, replay_mode: e.target.value as ReplayMode })}
className="mt-1 accent-brand-orange"
/>
<div>
<div className="font-medium text-neutral-900 dark:text-white flex items-center gap-2">
Anonymous Skeleton
<span className="inline-flex items-center gap-1 text-xs bg-brand-orange/10 text-brand-orange px-2 py-0.5 rounded">
<LockClosedIcon className="w-3 h-3 flex-shrink-0" />
Privacy First
</span>
</div>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-0.5">
All text replaced with blocks (), all inputs hidden. Layout and clicks preserved. No consent required.
</p>
</div>
</div>
</label>
</div>
</div>
{/* Recording Settings - Only show when not disabled */}
<AnimatePresence>
{formData.replay_mode !== 'disabled' && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="space-y-4 overflow-hidden"
>
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300 pt-4 border-t border-neutral-100 dark:border-neutral-800">Recording Settings</h3>
{/* Sampling Rate */}
<div className="p-4 bg-neutral-50 dark:bg-neutral-900/50 rounded-xl border border-neutral-100 dark:border-neutral-800">
<div className="flex items-center justify-between">
<div>
<h4 className="font-medium text-neutral-900 dark:text-white">Sampling Rate</h4>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-0.5">
Percentage of sessions to record
</p>
</div>
<Select
value={String(formData.replay_sampling_rate)}
onChange={(v) =>
setFormData({ ...formData, replay_sampling_rate: parseInt(v, 10) })
}
options={SAMPLING_RATE_OPTIONS.map((pct) => ({
value: String(pct),
label: `${pct}%`,
}))}
variant="input"
align="right"
className="min-w-[120px]"
/>
</div>
</div>
{/* Retention Period */}
<div className="p-4 bg-neutral-50 dark:bg-neutral-900/50 rounded-xl border border-neutral-100 dark:border-neutral-800">
<div className="flex items-center justify-between">
<div>
<h4 className="font-medium text-neutral-900 dark:text-white">Retention Period</h4>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-0.5">
How long to keep recordings
</p>
</div>
<Select
value={String(formData.replay_retention_days)}
onChange={(v) => setFormData({ ...formData, replay_retention_days: parseInt(v, 10) })}
options={[
{ value: '7', label: '7 days' },
{ value: '14', label: '14 days' },
{ value: '30', label: '30 days' },
{ value: '60', label: '60 days' },
{ value: '90', label: '90 days' },
]}
variant="input"
align="right"
className="min-w-[120px]"
/>
</div>
</div>
{/* View Replays Link */}
<div className="pt-4">
<a
href={`/sites/${siteId}/replays`}
className="inline-flex items-center gap-2 text-brand-orange hover:underline text-sm font-medium"
>
<VideoIcon className="w-4 h-4" />
View Session Replays
</a>
</div>
</motion.div>
)}
</AnimatePresence>
<div className="pt-4 border-t border-neutral-100 dark:border-neutral-800 flex justify-end">
{canEdit && (
<button
type="submit"
disabled={saving}
className="flex items-center gap-2 px-6 py-2.5 bg-neutral-900 dark:bg-white text-white dark:text-neutral-900 rounded-xl font-medium
hover:bg-neutral-800 dark:hover:bg-neutral-100 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200"
>
{saving ? (
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
) : (
<>
<CheckIcon className="w-4 h-4" />
Save Changes
</>
)}
</button>
)}
</div>
</form>
</div>
)}
</motion.div>
</div>
</div>

View File

@@ -1,192 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import {
PlayIcon,
PauseIcon,
EnterFullScreenIcon,
ExitFullScreenIcon,
} from '@radix-ui/react-icons'
/** Formats milliseconds as mm:ss. */
function formatTime(ms: number): string {
if (!Number.isFinite(ms) || ms < 0) return '0:00'
const s = Math.floor(ms / 1000)
const m = Math.floor(s / 60)
return `${m}:${String(s % 60).padStart(2, '0')}`
}
const SPEED_OPTIONS = [1, 2, 4, 8] as const
export type ReplayPlayerControlsProps = {
isPlaying: boolean
onPlayPause: () => void
currentTimeMs: number
totalTimeMs: number
onSeek: (fraction: number) => void
speed: number
onSpeedChange: (speed: number) => void
skipInactive: boolean
onSkipInactiveChange: () => void
onFullscreenRequest: () => void
}
/**
* Custom session replay player controls with Ciphera branding.
* Matches design: brand orange #FD5E0F, Plus Jakarta Sans, rounded-xl, neutral greys.
*/
export default function ReplayPlayerControls({
isPlaying,
onPlayPause,
currentTimeMs,
totalTimeMs,
onSeek,
speed,
onSpeedChange,
skipInactive,
onSkipInactiveChange,
onFullscreenRequest,
}: ReplayPlayerControlsProps) {
const [isFullscreen, setIsFullscreen] = useState(false)
const [isSeeking, setIsSeeking] = useState(false)
const [seekValue, setSeekValue] = useState(0)
const totalSec = totalTimeMs / 1000
const currentSec = currentTimeMs / 1000
const fraction = totalSec > 0 ? Math.min(1, Math.max(0, currentSec / totalSec)) : 0
const displayFraction = isSeeking ? seekValue : fraction
useEffect(() => {
const onFullscreenChange = () => {
setIsFullscreen(!!document.fullscreenElement)
}
document.addEventListener('fullscreenchange', onFullscreenChange)
return () => document.removeEventListener('fullscreenchange', onFullscreenChange)
}, [])
const handleSeekChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const v = parseFloat(e.target.value)
const p = Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 0
setSeekValue(p)
onSeek(p)
}
const handleSeekPointerDown = () => {
setSeekValue(fraction)
setIsSeeking(true)
}
const handleSeekPointerUp = () => setIsSeeking(false)
return (
<div
className="flex flex-col gap-3 px-4 py-3 bg-neutral-800/95 border-t border-neutral-700/80"
style={{ fontFamily: 'var(--font-plus-jakarta-sans), system-ui, sans-serif' }}
>
{/* * Progress bar / timeline */}
<div className="flex items-center gap-3">
<span className="text-neutral-400 text-xs tabular-nums w-10 text-right shrink-0">
{formatTime(currentTimeMs)}
</span>
<div className="flex-1 relative h-2 bg-neutral-600/80 rounded-full overflow-hidden group">
<div
className="absolute left-0 top-0 bottom-0 rounded-full bg-brand-orange transition-all duration-150 flex items-center justify-end"
style={{ width: `${displayFraction * 100}%` }}
>
{displayFraction > 0 && displayFraction < 1 && (
<div className="w-3 h-3 rounded-full bg-white shadow-md border border-neutral-800 -mr-1.5 flex-shrink-0" />
)}
</div>
<input
type="range"
min={0}
max={1}
step={0.001}
value={isSeeking ? seekValue : fraction}
onChange={handleSeekChange}
onMouseDown={handleSeekPointerDown}
onMouseUp={handleSeekPointerUp}
onTouchStart={handleSeekPointerDown}
onTouchEnd={handleSeekPointerUp}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
aria-label="Seek"
/>
</div>
<span className="text-neutral-400 text-xs tabular-nums w-10 shrink-0">
{formatTime(totalTimeMs)}
</span>
</div>
{/* * Buttons row */}
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-1">
{/* * Play / Pause */}
<button
type="button"
onClick={onPlayPause}
className="w-9 h-9 rounded-lg bg-brand-orange text-white flex items-center justify-center hover:bg-brand-orange/90 active:scale-95 transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-offset-2 focus:ring-offset-neutral-800"
aria-label={isPlaying ? 'Pause' : 'Play'}
>
{isPlaying ? (
<PauseIcon className="w-4 h-4" />
) : (
<PlayIcon className="w-4 h-4 ml-0.5" />
)}
</button>
{/* * Speed pills */}
<div className="flex items-center rounded-lg overflow-hidden border border-neutral-600/80">
{SPEED_OPTIONS.map((s) => (
<button
key={s}
type="button"
onClick={() => onSpeedChange(s)}
className={`px-2.5 py-1.5 text-xs font-medium transition-colors duration-200 focus:outline-none focus:ring-1 focus:ring-brand-orange focus:ring-inset ${
speed === s
? 'bg-brand-orange text-white'
: 'text-neutral-400 hover:text-neutral-200 hover:bg-neutral-600/60'
}`}
>
{s}x
</button>
))}
</div>
{/* * Skip inactive toggle */}
<button
type="button"
role="switch"
aria-checked={skipInactive}
onClick={onSkipInactiveChange}
className="flex items-center gap-2 ml-2 bg-transparent border-0 cursor-pointer select-none p-0 focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-offset-2 focus:ring-offset-neutral-800 rounded"
>
<span className="text-neutral-400 text-xs">Skip inactive</span>
<span
className={`relative inline-block w-9 h-5 rounded-full transition-colors duration-200 ${
skipInactive ? 'bg-brand-orange' : 'bg-neutral-600'
}`}
>
<span
className={`absolute top-1 w-3 h-3 rounded-full bg-white shadow transition-all duration-200 ${
skipInactive ? 'left-5' : 'left-1'
}`}
/>
</span>
</button>
</div>
{/* * Fullscreen */}
<button
type="button"
onClick={onFullscreenRequest}
className="w-9 h-9 rounded-lg text-neutral-400 hover:text-white hover:bg-neutral-600/60 flex items-center justify-center transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-offset-2 focus:ring-offset-neutral-800"
aria-label={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
>
{isFullscreen ? (
<ExitFullScreenIcon className="w-4 h-4" />
) : (
<EnterFullScreenIcon className="w-4 h-4" />
)}
</button>
</div>
</div>
)
}

View File

@@ -1,113 +0,0 @@
import apiRequest from './client'
import type { eventWithTime } from '@rrweb/types'
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<ReplayListResponse> {
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<ReplayListResponse>(url)
}
export async function getReplay(siteId: string, replayId: string): Promise<SessionReplay> {
return apiRequest<SessionReplay>(`/sites/${siteId}/replays/${replayId}`)
}
export async function getReplayData(siteId: string, replayId: string): Promise<eventWithTime[]> {
const response = await apiRequest<eventWithTime[]>(`/sites/${siteId}/replays/${replayId}/data`)
return response
}
export async function deleteReplay(siteId: string, replayId: string): Promise<void> {
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 '🌐'
}
}

View File

@@ -1,7 +1,6 @@
import apiRequest from './client'
export type GeoDataLevel = 'full' | 'country' | 'none'
export type ReplayMode = 'disabled' | 'anonymous_skeleton'
export interface Site {
id: string
@@ -22,12 +21,6 @@ export interface Site {
enable_performance_insights?: boolean
// Bot and noise filtering
filter_bots?: 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
}
@@ -54,12 +47,6 @@ export interface UpdateSiteRequest {
enable_performance_insights?: boolean
// Bot and noise filtering
filter_bots?: 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<Site[]> {

View File

@@ -21,7 +21,6 @@ export function generatePrivacySnippet(site: Site): string {
const geo = site.collect_geo_data || 'full'
const screen = site.collect_screen_resolution ?? true
const perf = site.enable_performance_insights ?? false
const replay = site.replay_mode === 'anonymous_skeleton'
const filterBots = site.filter_bots ?? true
const parts: string[] = []
@@ -32,7 +31,6 @@ export function generatePrivacySnippet(site: Site): string {
else if (geo === 'country') parts.push('country')
if (screen) parts.push('screen resolution')
if (perf) parts.push('Core Web Vitals (e.g. page load performance)')
if (replay) parts.push('anonymised session replays (e.g. clicks and layout; no text you type is stored)')
const list =
parts.length > 0

View File

@@ -12,7 +12,6 @@
"dependencies": {
"@ciphera-net/ui": "^0.0.10",
"@radix-ui/react-icons": "^1.3.2",
"@rrweb/types": "^2.0.0-alpha.18",
"axios": "^1.13.2",
"country-flag-icons": "^1.6.4",
"d3-scale": "^4.0.2",
@@ -26,8 +25,6 @@
"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": {

View File

@@ -1,7 +1,6 @@
/**
* Pulse - Privacy-First Tracking Script
* Lightweight, no cookies, GDPR compliant
* Includes optional session replay with privacy controls
*/
(function() {
@@ -28,17 +27,6 @@
let clsObserved = false;
let performanceInsightsEnabled = false;
// * 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 {
@@ -109,11 +97,6 @@
// * Send metrics when user leaves or hides the page
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
// * Flush replay immediately (page may be torn down soon)
if (replayEnabled) {
sendReplayChunk();
endReplaySession();
}
// * Delay metrics slightly so in-flight LCP/CLS callbacks can run before we send
setTimeout(sendMetrics, 150);
}
@@ -223,314 +206,9 @@
});
}
// ==========================================
// * 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;
// * Set performance insights enabled flag
performanceInsightsEnabled = replaySettings.enable_performance_insights === true;
// 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;
}
// * Load rrweb library dynamically
function loadRrweb() {
return new Promise((resolve, reject) => {
if (typeof window.rrweb !== 'undefined') {
resolve();
return;
}
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/rrweb@2.0.0-alpha.11/dist/rrweb.min.js';
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
}
// * Start recording session
async function startReplay(isSkeletonMode) {
if (replayEnabled) return;
// Load rrweb if not already loaded
try {
await loadRrweb();
} catch (e) {
console.warn('[Ciphera] Failed to load rrweb library');
return;
}
if (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;
}
}
// * Redact common PII-like URL query/fragment parameters in replay JSON before sending
function redactPiiInReplayJson(jsonStr) {
return jsonStr.replace(
/([?&])(email|token|session|auth|password|secret|api_key|apikey|access_token|refresh_token)=[^&"'\s]*/gi,
'$1$2=***'
);
}
// * 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;
let data = JSON.stringify(chunk);
data = redactPiiInReplayJson(data);
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);
let data = JSON.stringify(chunk);
data = redactPiiInReplayJson(data);
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 replay control (ciphera for backward compat, pulse for Pulse branding)
const replayApi = function(cmd) {
if (cmd === 'disableReplay') {
endReplaySession();
} else if (cmd === 'getReplayMode') {
return replayMode;
} else if (cmd === 'isReplayEnabled') {
return replayEnabled;
}
};
window.pulse = window.pulse || replayApi;
window.ciphera = window.ciphera || replayApi;
// * Track initial pageview
trackPageview();
// * Fetch replay settings (async, doesn't block pageview)
fetchReplaySettings();
// * Track SPA navigation: MutationObserver (DOM updates) and history.pushState/replaceState
// * (some SPAs change the URL without a DOM mutation we observe)
let lastUrl = location.href;
@@ -550,11 +228,4 @@
// * Track popstate (browser back/forward)
window.addEventListener('popstate', trackPageview);
// * Cleanup on page unload
window.addEventListener('beforeunload', () => {
if (replayEnabled) {
sendReplayChunk();
endReplaySession();
}
});
})();