refactor(replay): remove session replay functionality and related components to streamline codebase
This commit is contained in:
@@ -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?",
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
← 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 (Ciphera‑branded) */}
|
||||
{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>
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 '🌐'
|
||||
}
|
||||
}
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
329
public/script.js
329
public/script.js
@@ -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();
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user