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

@@ -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>