feat: add session replay functionality with privacy controls

This commit is contained in:
Usman Baig
2026-01-19 12:43:02 +01:00
parent c8695ff964
commit a740b0d703
8 changed files with 1434 additions and 12 deletions

View File

@@ -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<Site | null>(null)
const [replay, setReplay] = useState<SessionReplay | null>(null)
const [replayData, setReplayData] = useState<unknown[] | null>(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<HTMLDivElement>(null)
const playerRef = useRef<unknown>(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 <LoadingOverlay logoSrc="/ciphera_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-7xl 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-lg transition-colors"
>
Delete
</button>
</div>
</div>
</div>
<div className="flex gap-6">
{/* Player */}
<div className="flex-1">
<div className="border border-neutral-200 dark:border-neutral-800 rounded-xl overflow-hidden bg-white dark:bg-neutral-900">
{/* Player Container */}
<div
ref={playerContainerRef}
className="w-full bg-neutral-100 dark:bg-neutral-800 min-h-[400px] flex items-center justify-center"
>
{loadingData ? (
<div className="flex flex-col items-center gap-3">
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-neutral-900 dark:border-white"></div>
<span className="text-sm text-neutral-500">Loading replay data...</span>
</div>
) : !replayData || replayData.length === 0 ? (
<div className="text-center text-neutral-500 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 */}
{playerReady && (
<div className="p-4 border-t border-neutral-200 dark:border-neutral-800 flex items-center justify-between">
<div className="flex items-center gap-4">
<span className="text-sm text-neutral-500">Playback Speed:</span>
<div className="flex gap-1">
{[0.5, 1, 2, 4].map((s) => (
<button
key={s}
onClick={() => setSpeed(s)}
className={`px-2 py-1 text-sm rounded ${
speed === s
? 'bg-brand-orange text-white'
: 'bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-200 dark:hover:bg-neutral-700'
}`}
>
{s}x
</button>
))}
</div>
</div>
<div className="flex items-center gap-2 text-sm text-neutral-500">
<span>{replay.events_count} events</span>
<span></span>
<span>{formatDuration(replay.duration_ms)}</span>
</div>
</div>
)}
</div>
</div>
{/* Session Info Sidebar */}
<div className="w-80 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">{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="text-xs bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400 px-2 py-1 rounded">
🔒 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-lg transition-colors"
>
Cancel
</button>
<button
onClick={handleDelete}
className="px-4 py-2 text-sm bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
>
Delete
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -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<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="/ciphera_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-7xl 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-lg 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
className="px-3 py-2 border border-neutral-200 dark:border-neutral-700 rounded-lg bg-white dark:bg-neutral-800 text-sm"
value={filters.device_type || ''}
onChange={(e) => setFilters(prev => ({ ...prev, device_type: e.target.value || undefined, offset: 0 }))}
>
<option value="">All Devices</option>
<option value="desktop">Desktop</option>
<option value="mobile">Mobile</option>
<option value="tablet">Tablet</option>
</select>
<select
className="px-3 py-2 border border-neutral-200 dark:border-neutral-700 rounded-lg bg-white dark:bg-neutral-800 text-sm"
value={filters.min_duration || ''}
onChange={(e) => setFilters(prev => ({ ...prev, min_duration: e.target.value ? parseInt(e.target.value) : undefined, offset: 0 }))}
>
<option value="">Any Duration</option>
<option value="5000">5s+</option>
<option value="30000">30s+</option>
<option value="60000">1m+</option>
<option value="300000">5m+</option>
</select>
</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="text-xs bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400 px-1.5 py-0.5 rounded">
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-lg 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-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-neutral-50 dark:hover:bg-neutral-800"
>
Next
</button>
</div>
</div>
)}
</>
)}
</div>
</div>
)
}