feat: add session replay functionality with privacy controls
This commit is contained in:
352
app/sites/[id]/replays/[replayId]/page.tsx
Normal file
352
app/sites/[id]/replays/[replayId]/page.tsx
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState, useRef } from 'react'
|
||||||
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
|
import { getSite, type Site } from '@/lib/api/sites'
|
||||||
|
import { getReplay, getReplayData, deleteReplay, formatDuration, type SessionReplay } from '@/lib/api/replays'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import LoadingOverlay from '@/components/LoadingOverlay'
|
||||||
|
|
||||||
|
function formatDate(dateString: string) {
|
||||||
|
const date = new Date(dateString)
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
weekday: 'short',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFlagEmoji(countryCode: string | null) {
|
||||||
|
if (!countryCode || countryCode.length !== 2) return '🌍'
|
||||||
|
const codePoints = countryCode
|
||||||
|
.toUpperCase()
|
||||||
|
.split('')
|
||||||
|
.map(char => 127397 + char.charCodeAt(0))
|
||||||
|
return String.fromCodePoint(...codePoints)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ReplayViewerPage() {
|
||||||
|
const params = useParams()
|
||||||
|
const router = useRouter()
|
||||||
|
const siteId = params.id as string
|
||||||
|
const replayId = params.replayId as string
|
||||||
|
|
||||||
|
const [site, setSite] = useState<Site | null>(null)
|
||||||
|
const [replay, setReplay] = useState<SessionReplay | null>(null)
|
||||||
|
const [replayData, setReplayData] = useState<unknown[] | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [loadingData, setLoadingData] = useState(false)
|
||||||
|
const [playerReady, setPlayerReady] = useState(false)
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false)
|
||||||
|
const [speed, setSpeed] = useState(1)
|
||||||
|
const [showDeleteModal, setShowDeleteModal] = useState(false)
|
||||||
|
|
||||||
|
const playerContainerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const playerRef = useRef<unknown>(null)
|
||||||
|
|
||||||
|
// Load site and replay info
|
||||||
|
useEffect(() => {
|
||||||
|
const init = async () => {
|
||||||
|
try {
|
||||||
|
const [siteData, replayData] = await Promise.all([
|
||||||
|
getSite(siteId),
|
||||||
|
getReplay(siteId, replayId)
|
||||||
|
])
|
||||||
|
setSite(siteData)
|
||||||
|
setReplay(replayData)
|
||||||
|
} catch (error: unknown) {
|
||||||
|
toast.error('Failed to load replay')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
init()
|
||||||
|
}, [siteId, replayId])
|
||||||
|
|
||||||
|
// Load replay data
|
||||||
|
useEffect(() => {
|
||||||
|
const loadData = async () => {
|
||||||
|
if (!replay) return
|
||||||
|
setLoadingData(true)
|
||||||
|
try {
|
||||||
|
const data = await getReplayData(siteId, replayId)
|
||||||
|
setReplayData(data)
|
||||||
|
} catch (error: unknown) {
|
||||||
|
toast.error('Failed to load replay data')
|
||||||
|
} finally {
|
||||||
|
setLoadingData(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadData()
|
||||||
|
}, [replay, siteId, replayId])
|
||||||
|
|
||||||
|
// Initialize rrweb player when data is ready
|
||||||
|
useEffect(() => {
|
||||||
|
if (!replayData || !playerContainerRef.current || replayData.length === 0) return
|
||||||
|
|
||||||
|
// Dynamically import rrweb-player
|
||||||
|
const initPlayer = async () => {
|
||||||
|
try {
|
||||||
|
const rrwebPlayer = await import('rrweb-player')
|
||||||
|
|
||||||
|
// Clear previous player
|
||||||
|
if (playerContainerRef.current) {
|
||||||
|
playerContainerRef.current.innerHTML = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create player
|
||||||
|
const player = new rrwebPlayer.default({
|
||||||
|
target: playerContainerRef.current!,
|
||||||
|
props: {
|
||||||
|
events: replayData as unknown[],
|
||||||
|
width: playerContainerRef.current!.clientWidth,
|
||||||
|
height: Math.min(600, window.innerHeight - 300),
|
||||||
|
autoPlay: false,
|
||||||
|
showController: true,
|
||||||
|
speed: speed,
|
||||||
|
skipInactive: true,
|
||||||
|
showWarning: false,
|
||||||
|
showDebug: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
playerRef.current = player
|
||||||
|
|
||||||
|
// Listen for player events
|
||||||
|
player.addEventListener('pause', () => setIsPlaying(false))
|
||||||
|
player.addEventListener('start', () => setIsPlaying(true))
|
||||||
|
player.addEventListener('finish', () => setIsPlaying(false))
|
||||||
|
|
||||||
|
setPlayerReady(true)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to initialize player:', error)
|
||||||
|
toast.error('Failed to initialize replay player')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
initPlayer()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (playerRef.current) {
|
||||||
|
// Cleanup player
|
||||||
|
playerRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [replayData])
|
||||||
|
|
||||||
|
// Update speed
|
||||||
|
useEffect(() => {
|
||||||
|
if (playerRef.current && typeof (playerRef.current as { setSpeed?: (s: number) => void }).setSpeed === 'function') {
|
||||||
|
(playerRef.current as { setSpeed: (s: number) => void }).setSpeed(speed)
|
||||||
|
}
|
||||||
|
}, [speed])
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
try {
|
||||||
|
await deleteReplay(siteId, replayId)
|
||||||
|
toast.success('Replay deleted')
|
||||||
|
router.push(`/sites/${siteId}/replays`)
|
||||||
|
} catch (error: unknown) {
|
||||||
|
toast.error('Failed to delete replay')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return <LoadingOverlay logoSrc="/ciphera_icon_no_margins.png" title="Loading Replay" />
|
||||||
|
if (!site || !replay) return <div className="p-8">Replay not found</div>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full max-w-7xl mx-auto px-4 sm:px-6 py-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<button
|
||||||
|
onClick={() => router.push(`/sites/${siteId}/replays`)}
|
||||||
|
className="text-sm text-neutral-500 hover:text-neutral-900 dark:hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
← Back to Replays
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white">
|
||||||
|
Session Replay
|
||||||
|
</h1>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDeleteModal(true)}
|
||||||
|
className="px-3 py-1.5 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-6">
|
||||||
|
{/* Player */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="border border-neutral-200 dark:border-neutral-800 rounded-xl overflow-hidden bg-white dark:bg-neutral-900">
|
||||||
|
{/* Player Container */}
|
||||||
|
<div
|
||||||
|
ref={playerContainerRef}
|
||||||
|
className="w-full bg-neutral-100 dark:bg-neutral-800 min-h-[400px] flex items-center justify-center"
|
||||||
|
>
|
||||||
|
{loadingData ? (
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-neutral-900 dark:border-white"></div>
|
||||||
|
<span className="text-sm text-neutral-500">Loading replay data...</span>
|
||||||
|
</div>
|
||||||
|
) : !replayData || replayData.length === 0 ? (
|
||||||
|
<div className="text-center text-neutral-500 p-8">
|
||||||
|
<div className="text-4xl mb-4">🎬</div>
|
||||||
|
<p className="text-lg font-medium mb-2">No replay data available</p>
|
||||||
|
<p className="text-sm">This session may not have recorded any events.</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Custom Controls */}
|
||||||
|
{playerReady && (
|
||||||
|
<div className="p-4 border-t border-neutral-200 dark:border-neutral-800 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="text-sm text-neutral-500">Playback Speed:</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{[0.5, 1, 2, 4].map((s) => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
onClick={() => setSpeed(s)}
|
||||||
|
className={`px-2 py-1 text-sm rounded ${
|
||||||
|
speed === s
|
||||||
|
? 'bg-brand-orange text-white'
|
||||||
|
: 'bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-200 dark:hover:bg-neutral-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{s}x
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-neutral-500">
|
||||||
|
<span>{replay.events_count} events</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{formatDuration(replay.duration_ms)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Session Info Sidebar */}
|
||||||
|
<div className="w-80 flex-shrink-0">
|
||||||
|
<div className="border border-neutral-200 dark:border-neutral-800 rounded-xl overflow-hidden bg-white dark:bg-neutral-900">
|
||||||
|
<div className="p-4 border-b border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-800/50">
|
||||||
|
<h2 className="font-semibold text-neutral-900 dark:text-white">Session Details</h2>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 space-y-4">
|
||||||
|
<div>
|
||||||
|
<span className="text-xs text-neutral-500 uppercase tracking-wider">Session ID</span>
|
||||||
|
<p className="font-mono text-sm text-neutral-900 dark:text-white mt-1">{replay.session_id}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span className="text-xs text-neutral-500 uppercase tracking-wider">Entry Page</span>
|
||||||
|
<p className="text-sm text-neutral-900 dark:text-white mt-1 break-all">{replay.entry_page}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<span className="text-xs text-neutral-500 uppercase tracking-wider">Duration</span>
|
||||||
|
<p className="text-sm text-neutral-900 dark:text-white mt-1">{formatDuration(replay.duration_ms)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-xs text-neutral-500 uppercase tracking-wider">Events</span>
|
||||||
|
<p className="text-sm text-neutral-900 dark:text-white mt-1">{replay.events_count}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<span className="text-xs text-neutral-500 uppercase tracking-wider">Device</span>
|
||||||
|
<p className="text-sm text-neutral-900 dark:text-white mt-1 capitalize">{replay.device_type || 'Unknown'}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-xs text-neutral-500 uppercase tracking-wider">Browser</span>
|
||||||
|
<p className="text-sm text-neutral-900 dark:text-white mt-1">{replay.browser || 'Unknown'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<span className="text-xs text-neutral-500 uppercase tracking-wider">OS</span>
|
||||||
|
<p className="text-sm text-neutral-900 dark:text-white mt-1">{replay.os || 'Unknown'}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-xs text-neutral-500 uppercase tracking-wider">Location</span>
|
||||||
|
<p className="text-sm text-neutral-900 dark:text-white mt-1">{getFlagEmoji(replay.country)} {replay.country || 'Unknown'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span className="text-xs text-neutral-500 uppercase tracking-wider">Started</span>
|
||||||
|
<p className="text-sm text-neutral-900 dark:text-white mt-1">{formatDate(replay.started_at)}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{replay.ended_at && (
|
||||||
|
<div>
|
||||||
|
<span className="text-xs text-neutral-500 uppercase tracking-wider">Ended</span>
|
||||||
|
<p className="text-sm text-neutral-900 dark:text-white mt-1">{formatDate(replay.ended_at)}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="pt-2 border-t border-neutral-200 dark:border-neutral-800">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{replay.is_skeleton_mode ? (
|
||||||
|
<span className="text-xs bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400 px-2 py-1 rounded">
|
||||||
|
🔒 Skeleton Mode (Anonymous)
|
||||||
|
</span>
|
||||||
|
) : replay.consent_given ? (
|
||||||
|
<span className="text-xs bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400 px-2 py-1 rounded">
|
||||||
|
✓ Consent Given
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400 px-2 py-1 rounded">
|
||||||
|
⚠ No Consent Record
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Modal */}
|
||||||
|
{showDeleteModal && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
<div className="bg-white dark:bg-neutral-900 rounded-xl p-6 max-w-md w-full mx-4 shadow-xl">
|
||||||
|
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white mb-2">Delete Replay?</h3>
|
||||||
|
<p className="text-neutral-600 dark:text-neutral-400 mb-6">
|
||||||
|
This action cannot be undone. The replay data will be permanently deleted.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-3 justify-end">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDeleteModal(false)}
|
||||||
|
className="px-4 py-2 text-sm text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="px-4 py-2 text-sm bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
275
app/sites/[id]/replays/page.tsx
Normal file
275
app/sites/[id]/replays/page.tsx
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
|
import { getSite, type Site } from '@/lib/api/sites'
|
||||||
|
import { listReplays, formatDuration, type ReplayListItem, type ReplayFilters } from '@/lib/api/replays'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import LoadingOverlay from '@/components/LoadingOverlay'
|
||||||
|
|
||||||
|
function formatDate(dateString: string) {
|
||||||
|
const date = new Date(dateString)
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFlagEmoji(countryCode: string | null) {
|
||||||
|
if (!countryCode || countryCode.length !== 2) return '🌍'
|
||||||
|
const codePoints = countryCode
|
||||||
|
.toUpperCase()
|
||||||
|
.split('')
|
||||||
|
.map(char => 127397 + char.charCodeAt(0))
|
||||||
|
return String.fromCodePoint(...codePoints)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDeviceEmoji(deviceType: string | null) {
|
||||||
|
switch (deviceType?.toLowerCase()) {
|
||||||
|
case 'mobile':
|
||||||
|
return '📱'
|
||||||
|
case 'tablet':
|
||||||
|
return '📱'
|
||||||
|
default:
|
||||||
|
return '💻'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ReplaysPage() {
|
||||||
|
const params = useParams()
|
||||||
|
const router = useRouter()
|
||||||
|
const siteId = params.id as string
|
||||||
|
|
||||||
|
const [site, setSite] = useState<Site | null>(null)
|
||||||
|
const [replays, setReplays] = useState<ReplayListItem[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
const [filters, setFilters] = useState<ReplayFilters>({
|
||||||
|
limit: 20,
|
||||||
|
offset: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Load site info and replays
|
||||||
|
useEffect(() => {
|
||||||
|
const init = async () => {
|
||||||
|
try {
|
||||||
|
const siteData = await getSite(siteId)
|
||||||
|
setSite(siteData)
|
||||||
|
} catch (error: unknown) {
|
||||||
|
toast.error('Failed to load site')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
init()
|
||||||
|
}, [siteId])
|
||||||
|
|
||||||
|
// Load replays when filters change
|
||||||
|
useEffect(() => {
|
||||||
|
const loadReplays = async () => {
|
||||||
|
try {
|
||||||
|
const response = await listReplays(siteId, filters)
|
||||||
|
setReplays(response.replays || [])
|
||||||
|
setTotal(response.total)
|
||||||
|
} catch (error: unknown) {
|
||||||
|
toast.error('Failed to load replays')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (site) {
|
||||||
|
loadReplays()
|
||||||
|
}
|
||||||
|
}, [siteId, site, filters])
|
||||||
|
|
||||||
|
const handlePageChange = (newOffset: number) => {
|
||||||
|
setFilters(prev => ({ ...prev, offset: newOffset }))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return <LoadingOverlay logoSrc="/ciphera_icon_no_margins.png" title="Session Replays" />
|
||||||
|
if (!site) return <div className="p-8">Site not found</div>
|
||||||
|
|
||||||
|
const currentPage = Math.floor((filters.offset || 0) / (filters.limit || 20)) + 1
|
||||||
|
const totalPages = Math.ceil(total / (filters.limit || 20))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full max-w-7xl mx-auto px-4 sm:px-6 py-8">
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<button
|
||||||
|
onClick={() => router.push(`/sites/${siteId}`)}
|
||||||
|
className="text-sm text-neutral-500 hover:text-neutral-900 dark:hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
← Back to Dashboard
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white flex items-center gap-3">
|
||||||
|
Session Replays
|
||||||
|
<span className="text-lg font-normal text-neutral-500">
|
||||||
|
{total} recordings
|
||||||
|
</span>
|
||||||
|
</h1>
|
||||||
|
{site.replay_mode === 'disabled' && (
|
||||||
|
<div className="flex items-center gap-2 text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 px-3 py-1.5 rounded-lg text-sm">
|
||||||
|
<span>⚠️</span>
|
||||||
|
<span>Session replay is disabled</span>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push(`/sites/${siteId}/settings`)}
|
||||||
|
className="underline hover:no-underline"
|
||||||
|
>
|
||||||
|
Enable in settings
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="mb-6 flex gap-4 flex-wrap">
|
||||||
|
<select
|
||||||
|
className="px-3 py-2 border border-neutral-200 dark:border-neutral-700 rounded-lg bg-white dark:bg-neutral-800 text-sm"
|
||||||
|
value={filters.device_type || ''}
|
||||||
|
onChange={(e) => setFilters(prev => ({ ...prev, device_type: e.target.value || undefined, offset: 0 }))}
|
||||||
|
>
|
||||||
|
<option value="">All Devices</option>
|
||||||
|
<option value="desktop">Desktop</option>
|
||||||
|
<option value="mobile">Mobile</option>
|
||||||
|
<option value="tablet">Tablet</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
className="px-3 py-2 border border-neutral-200 dark:border-neutral-700 rounded-lg bg-white dark:bg-neutral-800 text-sm"
|
||||||
|
value={filters.min_duration || ''}
|
||||||
|
onChange={(e) => setFilters(prev => ({ ...prev, min_duration: e.target.value ? parseInt(e.target.value) : undefined, offset: 0 }))}
|
||||||
|
>
|
||||||
|
<option value="">Any Duration</option>
|
||||||
|
<option value="5000">5s+</option>
|
||||||
|
<option value="30000">30s+</option>
|
||||||
|
<option value="60000">1m+</option>
|
||||||
|
<option value="300000">5m+</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Replays List */}
|
||||||
|
<div className="border border-neutral-200 dark:border-neutral-800 rounded-xl overflow-hidden bg-white dark:bg-neutral-900">
|
||||||
|
{replays.length === 0 ? (
|
||||||
|
<div className="p-12 text-center text-neutral-500">
|
||||||
|
<div className="text-4xl mb-4">🎬</div>
|
||||||
|
<p className="text-lg font-medium mb-2">No session replays yet</p>
|
||||||
|
<p className="text-sm">
|
||||||
|
{site.replay_mode === 'disabled'
|
||||||
|
? 'Enable session replay in settings to start recording visitor sessions.'
|
||||||
|
: 'Recordings will appear here once visitors start interacting with your site.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-800/50">
|
||||||
|
<th className="text-left px-4 py-3 text-sm font-semibold text-neutral-600 dark:text-neutral-400">Session</th>
|
||||||
|
<th className="text-left px-4 py-3 text-sm font-semibold text-neutral-600 dark:text-neutral-400">Entry Page</th>
|
||||||
|
<th className="text-left px-4 py-3 text-sm font-semibold text-neutral-600 dark:text-neutral-400">Duration</th>
|
||||||
|
<th className="text-left px-4 py-3 text-sm font-semibold text-neutral-600 dark:text-neutral-400">Device</th>
|
||||||
|
<th className="text-left px-4 py-3 text-sm font-semibold text-neutral-600 dark:text-neutral-400">Location</th>
|
||||||
|
<th className="text-left px-4 py-3 text-sm font-semibold text-neutral-600 dark:text-neutral-400">Date</th>
|
||||||
|
<th className="text-right px-4 py-3 text-sm font-semibold text-neutral-600 dark:text-neutral-400">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-neutral-100 dark:divide-neutral-800">
|
||||||
|
{replays.map((replay) => (
|
||||||
|
<tr
|
||||||
|
key={replay.id}
|
||||||
|
className="hover:bg-neutral-50 dark:hover:bg-neutral-800/50 transition-colors cursor-pointer"
|
||||||
|
onClick={() => router.push(`/sites/${siteId}/replays/${replay.id}`)}
|
||||||
|
>
|
||||||
|
<td className="px-4 py-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{replay.is_skeleton_mode && (
|
||||||
|
<span className="text-xs bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400 px-1.5 py-0.5 rounded">
|
||||||
|
Skeleton
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="font-mono text-sm text-neutral-500">
|
||||||
|
{replay.session_id.substring(0, 8)}...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-4">
|
||||||
|
<span className="text-sm text-neutral-900 dark:text-white truncate block max-w-[200px]" title={replay.entry_page}>
|
||||||
|
{replay.entry_page}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-4">
|
||||||
|
<span className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||||
|
{formatDuration(replay.duration_ms)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-4">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<span>{getDeviceEmoji(replay.device_type)}</span>
|
||||||
|
<span className="text-neutral-600 dark:text-neutral-400">
|
||||||
|
{replay.browser || 'Unknown'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-4">
|
||||||
|
<span className="text-sm">
|
||||||
|
{getFlagEmoji(replay.country)} {replay.country || 'Unknown'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-4">
|
||||||
|
<span className="text-sm text-neutral-500">
|
||||||
|
{formatDate(replay.started_at)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-4 text-right">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
router.push(`/sites/${siteId}/replays/${replay.id}`)
|
||||||
|
}}
|
||||||
|
className="text-sm text-brand-orange hover:underline"
|
||||||
|
>
|
||||||
|
Watch →
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-t border-neutral-200 dark:border-neutral-800">
|
||||||
|
<span className="text-sm text-neutral-500">
|
||||||
|
Showing {(filters.offset || 0) + 1} - {Math.min((filters.offset || 0) + (filters.limit || 20), total)} of {total}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handlePageChange((filters.offset || 0) - (filters.limit || 20))}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
className="px-3 py-1 text-sm border border-neutral-200 dark:border-neutral-700 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-neutral-50 dark:hover:bg-neutral-800"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<span className="px-3 py-1 text-sm text-neutral-500">
|
||||||
|
Page {currentPage} of {totalPages}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => handlePageChange((filters.offset || 0) + (filters.limit || 20))}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
className="px-3 py-1 text-sm border border-neutral-200 dark:border-neutral-700 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-neutral-50 dark:hover:bg-neutral-800"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useParams, useRouter } from 'next/navigation'
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
import { getSite, updateSite, resetSiteData, deleteSite, type Site, type GeoDataLevel } from '@/lib/api/sites'
|
import { getSite, updateSite, resetSiteData, deleteSite, type Site, type GeoDataLevel, type ReplayMode } from '@/lib/api/sites'
|
||||||
import { getRealtime } from '@/lib/api/stats'
|
import { getRealtime } from '@/lib/api/stats'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import LoadingOverlay from '@/components/LoadingOverlay'
|
import LoadingOverlay from '@/components/LoadingOverlay'
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
CopyIcon,
|
CopyIcon,
|
||||||
ExclamationTriangleIcon,
|
ExclamationTriangleIcon,
|
||||||
LightningBoltIcon,
|
LightningBoltIcon,
|
||||||
|
VideoIcon,
|
||||||
} from '@radix-ui/react-icons'
|
} from '@radix-ui/react-icons'
|
||||||
|
|
||||||
const TIMEZONES = [
|
const TIMEZONES = [
|
||||||
@@ -45,7 +46,7 @@ export default function SiteSettingsPage() {
|
|||||||
const [site, setSite] = useState<Site | null>(null)
|
const [site, setSite] = useState<Site | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [activeTab, setActiveTab] = useState<'general' | 'visibility' | 'data'>('general')
|
const [activeTab, setActiveTab] = useState<'general' | 'visibility' | 'data' | 'replay'>('general')
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
@@ -58,7 +59,13 @@ export default function SiteSettingsPage() {
|
|||||||
collect_referrers: true,
|
collect_referrers: true,
|
||||||
collect_device_info: true,
|
collect_device_info: true,
|
||||||
collect_geo_data: 'full' as GeoDataLevel,
|
collect_geo_data: 'full' as GeoDataLevel,
|
||||||
collect_screen_resolution: true
|
collect_screen_resolution: true,
|
||||||
|
// Session replay settings
|
||||||
|
replay_mode: 'disabled' as ReplayMode,
|
||||||
|
replay_sampling_rate: 100,
|
||||||
|
replay_retention_days: 30,
|
||||||
|
replay_mask_all_text: false,
|
||||||
|
replay_mask_all_inputs: true
|
||||||
})
|
})
|
||||||
const [scriptCopied, setScriptCopied] = useState(false)
|
const [scriptCopied, setScriptCopied] = useState(false)
|
||||||
const [linkCopied, setLinkCopied] = useState(false)
|
const [linkCopied, setLinkCopied] = useState(false)
|
||||||
@@ -85,7 +92,13 @@ export default function SiteSettingsPage() {
|
|||||||
collect_referrers: data.collect_referrers ?? true,
|
collect_referrers: data.collect_referrers ?? true,
|
||||||
collect_device_info: data.collect_device_info ?? true,
|
collect_device_info: data.collect_device_info ?? true,
|
||||||
collect_geo_data: data.collect_geo_data || 'full',
|
collect_geo_data: data.collect_geo_data || 'full',
|
||||||
collect_screen_resolution: data.collect_screen_resolution ?? true
|
collect_screen_resolution: data.collect_screen_resolution ?? true,
|
||||||
|
// Session replay settings
|
||||||
|
replay_mode: data.replay_mode || 'disabled',
|
||||||
|
replay_sampling_rate: data.replay_sampling_rate ?? 100,
|
||||||
|
replay_retention_days: data.replay_retention_days ?? 30,
|
||||||
|
replay_mask_all_text: data.replay_mask_all_text ?? false,
|
||||||
|
replay_mask_all_inputs: data.replay_mask_all_inputs ?? true
|
||||||
})
|
})
|
||||||
if (data.has_password) {
|
if (data.has_password) {
|
||||||
setIsPasswordEnabled(true)
|
setIsPasswordEnabled(true)
|
||||||
@@ -121,7 +134,13 @@ export default function SiteSettingsPage() {
|
|||||||
collect_referrers: formData.collect_referrers,
|
collect_referrers: formData.collect_referrers,
|
||||||
collect_device_info: formData.collect_device_info,
|
collect_device_info: formData.collect_device_info,
|
||||||
collect_geo_data: formData.collect_geo_data,
|
collect_geo_data: formData.collect_geo_data,
|
||||||
collect_screen_resolution: formData.collect_screen_resolution
|
collect_screen_resolution: formData.collect_screen_resolution,
|
||||||
|
// Session replay settings
|
||||||
|
replay_mode: formData.replay_mode,
|
||||||
|
replay_sampling_rate: formData.replay_sampling_rate,
|
||||||
|
replay_retention_days: formData.replay_retention_days,
|
||||||
|
replay_mask_all_text: formData.replay_mask_all_text,
|
||||||
|
replay_mask_all_inputs: formData.replay_mask_all_inputs
|
||||||
})
|
})
|
||||||
toast.success('Site updated successfully')
|
toast.success('Site updated successfully')
|
||||||
loadSite()
|
loadSite()
|
||||||
@@ -235,6 +254,17 @@ export default function SiteSettingsPage() {
|
|||||||
<FileTextIcon className="w-5 h-5" />
|
<FileTextIcon className="w-5 h-5" />
|
||||||
Data & Privacy
|
Data & Privacy
|
||||||
</button>
|
</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>
|
</nav>
|
||||||
|
|
||||||
{/* Content Area */}
|
{/* Content Area */}
|
||||||
@@ -703,6 +733,263 @@ export default function SiteSettingsPage() {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
|
{/* Consent Required */}
|
||||||
|
<label className={`block p-4 rounded-xl border-2 cursor-pointer transition-all ${
|
||||||
|
formData.replay_mode === 'consent_required'
|
||||||
|
? '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="consent_required"
|
||||||
|
checked={formData.replay_mode === 'consent_required'}
|
||||||
|
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">
|
||||||
|
Consent Required
|
||||||
|
<span className="text-xs bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400 px-2 py-0.5 rounded">GDPR Safe</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-0.5">
|
||||||
|
Only records after visitor explicitly consents. Use with your cookie consent banner.
|
||||||
|
</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="text-xs bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400 px-2 py-0.5 rounded">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 mb-3">
|
||||||
|
<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>
|
||||||
|
<span className="text-lg font-semibold text-brand-orange">{formData.replay_sampling_rate}%</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="1"
|
||||||
|
max="100"
|
||||||
|
value={formData.replay_sampling_rate}
|
||||||
|
onChange={(e) => setFormData({ ...formData, replay_sampling_rate: parseInt(e.target.value) })}
|
||||||
|
className="w-full h-2 bg-neutral-200 dark:bg-neutral-700 rounded-lg appearance-none cursor-pointer accent-brand-orange"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between text-xs text-neutral-500 mt-1">
|
||||||
|
<span>1%</span>
|
||||||
|
<span>50%</span>
|
||||||
|
<span>100%</span>
|
||||||
|
</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>
|
||||||
|
<div className="relative">
|
||||||
|
<select
|
||||||
|
value={formData.replay_retention_days}
|
||||||
|
onChange={(e) => setFormData({ ...formData, replay_retention_days: parseInt(e.target.value) })}
|
||||||
|
className="px-4 py-2 border border-neutral-200 dark:border-neutral-700 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-white text-sm font-medium appearance-none pr-10 focus:outline-none focus:ring-2 focus:ring-brand-orange/20 focus:border-brand-orange"
|
||||||
|
>
|
||||||
|
<option value={7}>7 days</option>
|
||||||
|
<option value={14}>14 days</option>
|
||||||
|
<option value={30}>30 days</option>
|
||||||
|
<option value={60}>60 days</option>
|
||||||
|
<option value={90}>90 days</option>
|
||||||
|
</select>
|
||||||
|
<div className="absolute inset-y-0 right-0 flex items-center px-3 pointer-events-none">
|
||||||
|
<svg className="w-4 h-4 text-neutral-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Masking Options - Only for consent mode */}
|
||||||
|
{formData.replay_mode === 'consent_required' && (
|
||||||
|
<>
|
||||||
|
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300 pt-4">Privacy Masking</h3>
|
||||||
|
|
||||||
|
{/* Mask All Text */}
|
||||||
|
<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">Mask All Text</h4>
|
||||||
|
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-0.5">
|
||||||
|
Replace all text content with asterisks
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label className="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.replay_mask_all_text}
|
||||||
|
onChange={(e) => setFormData({ ...formData, replay_mask_all_text: e.target.checked })}
|
||||||
|
className="sr-only peer"
|
||||||
|
/>
|
||||||
|
<div className="w-11 h-6 bg-neutral-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-brand-orange/20 dark:peer-focus:ring-brand-orange/20 rounded-full peer dark:bg-neutral-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-brand-orange"></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mask All Inputs */}
|
||||||
|
<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">Mask All Inputs</h4>
|
||||||
|
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-0.5">
|
||||||
|
Hide all form input values (recommended)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label className="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.replay_mask_all_inputs}
|
||||||
|
onChange={(e) => setFormData({ ...formData, replay_mask_all_inputs: e.target.checked })}
|
||||||
|
className="sr-only peer"
|
||||||
|
/>
|
||||||
|
<div className="w-11 h-6 bg-neutral-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-brand-orange/20 dark:peer-focus:ring-brand-orange/20 rounded-full peer dark:bg-neutral-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-brand-orange"></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Integration Guide */}
|
||||||
|
{formData.replay_mode === 'consent_required' && (
|
||||||
|
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-xl border border-blue-200 dark:border-blue-800">
|
||||||
|
<h4 className="font-medium text-blue-900 dark:text-blue-100 mb-2">Integration with Consent Managers</h4>
|
||||||
|
<p className="text-sm text-blue-700 dark:text-blue-300 mb-3">
|
||||||
|
Call this function when the user grants consent for analytics/functional cookies:
|
||||||
|
</p>
|
||||||
|
<code className="block bg-blue-100 dark:bg-blue-900/50 text-blue-800 dark:text-blue-200 p-3 rounded-lg text-sm font-mono">
|
||||||
|
window.ciphera('enableReplay')
|
||||||
|
</code>
|
||||||
|
</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">
|
||||||
|
<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>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
112
lib/api/replays.ts
Normal file
112
lib/api/replays.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import apiRequest from './client'
|
||||||
|
|
||||||
|
export interface ReplayListItem {
|
||||||
|
id: string
|
||||||
|
session_id: string
|
||||||
|
started_at: string
|
||||||
|
ended_at: string | null
|
||||||
|
duration_ms: number
|
||||||
|
events_count: number
|
||||||
|
device_type: string | null
|
||||||
|
browser: string | null
|
||||||
|
os: string | null
|
||||||
|
country: string | null
|
||||||
|
entry_page: string
|
||||||
|
is_skeleton_mode: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReplayFilters {
|
||||||
|
device_type?: string
|
||||||
|
country?: string
|
||||||
|
min_duration?: number
|
||||||
|
limit?: number
|
||||||
|
offset?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReplayListResponse {
|
||||||
|
replays: ReplayListItem[]
|
||||||
|
total: number
|
||||||
|
limit: number
|
||||||
|
offset: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionReplay extends ReplayListItem {
|
||||||
|
site_id: string
|
||||||
|
consent_given: boolean
|
||||||
|
created_at: string
|
||||||
|
expires_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listReplays(
|
||||||
|
siteId: string,
|
||||||
|
filters?: ReplayFilters
|
||||||
|
): Promise<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<unknown[]> {
|
||||||
|
const response = await apiRequest<unknown[]>(`/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,6 +1,7 @@
|
|||||||
import apiRequest from './client'
|
import apiRequest from './client'
|
||||||
|
|
||||||
export type GeoDataLevel = 'full' | 'country' | 'none'
|
export type GeoDataLevel = 'full' | 'country' | 'none'
|
||||||
|
export type ReplayMode = 'disabled' | 'consent_required' | 'anonymous_skeleton'
|
||||||
|
|
||||||
export interface Site {
|
export interface Site {
|
||||||
id: string
|
id: string
|
||||||
@@ -17,6 +18,12 @@ export interface Site {
|
|||||||
collect_device_info?: boolean
|
collect_device_info?: boolean
|
||||||
collect_geo_data?: GeoDataLevel
|
collect_geo_data?: GeoDataLevel
|
||||||
collect_screen_resolution?: boolean
|
collect_screen_resolution?: boolean
|
||||||
|
// Session replay settings
|
||||||
|
replay_mode?: ReplayMode
|
||||||
|
replay_sampling_rate?: number
|
||||||
|
replay_retention_days?: number
|
||||||
|
replay_mask_all_text?: boolean
|
||||||
|
replay_mask_all_inputs?: boolean
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
@@ -39,6 +46,12 @@ export interface UpdateSiteRequest {
|
|||||||
collect_device_info?: boolean
|
collect_device_info?: boolean
|
||||||
collect_geo_data?: GeoDataLevel
|
collect_geo_data?: GeoDataLevel
|
||||||
collect_screen_resolution?: boolean
|
collect_screen_resolution?: boolean
|
||||||
|
// Session replay settings
|
||||||
|
replay_mode?: ReplayMode
|
||||||
|
replay_sampling_rate?: number
|
||||||
|
replay_retention_days?: number
|
||||||
|
replay_mask_all_text?: boolean
|
||||||
|
replay_mask_all_inputs?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listSites(): Promise<Site[]> {
|
export async function listSites(): Promise<Site[]> {
|
||||||
|
|||||||
88
package-lock.json
generated
88
package-lock.json
generated
@@ -23,6 +23,8 @@
|
|||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
"react-simple-maps": "^3.0.0",
|
"react-simple-maps": "^3.0.0",
|
||||||
"recharts": "^2.15.0",
|
"recharts": "^2.15.0",
|
||||||
|
"rrweb": "^2.0.0-alpha.4",
|
||||||
|
"rrweb-player": "^1.0.0-alpha.4",
|
||||||
"sonner": "^2.0.7"
|
"sonner": "^2.0.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -1278,6 +1280,12 @@
|
|||||||
"react": "^16.x || ^17.x || ^18.x || ^19.0.0 || ^19.0.0-rc"
|
"react": "^16.x || ^17.x || ^18.x || ^19.0.0 || ^19.0.0-rc"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@rrweb/types": {
|
||||||
|
"version": "2.0.0-alpha.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rrweb/types/-/types-2.0.0-alpha.18.tgz",
|
||||||
|
"integrity": "sha512-iMH3amHthJZ9x3gGmBPmdfim7wLGygC2GciIkw2A6SO8giSn8PHYtRT8OKNH4V+k3SZ6RSnYHcTQxBA7pSWZ3Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@rtsao/scc": {
|
"node_modules/@rtsao/scc": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
||||||
@@ -1307,6 +1315,12 @@
|
|||||||
"tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1"
|
"tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tsconfig/svelte": {
|
||||||
|
"version": "1.0.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tsconfig/svelte/-/svelte-1.0.13.tgz",
|
||||||
|
"integrity": "sha512-5lYJP45Xllo4yE/RUBccBT32eBlRDbqN8r1/MIvQbKxW3aFqaYPCNgm8D5V20X4ShHcwvYWNlKg3liDh1MlBoA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@tybys/wasm-util": {
|
"node_modules/@tybys/wasm-util": {
|
||||||
"version": "0.10.1",
|
"version": "0.10.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
||||||
@@ -1318,6 +1332,12 @@
|
|||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/css-font-loading-module": {
|
||||||
|
"version": "0.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/css-font-loading-module/-/css-font-loading-module-0.0.7.tgz",
|
||||||
|
"integrity": "sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/d3-array": {
|
"node_modules/@types/d3-array": {
|
||||||
"version": "3.2.2",
|
"version": "3.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||||
@@ -2045,6 +2065,12 @@
|
|||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@xstate/fsm": {
|
||||||
|
"version": "1.6.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xstate/fsm/-/fsm-1.6.5.tgz",
|
||||||
|
"integrity": "sha512-b5o1I6aLNeYlU/3CPlj/Z91ybk1gUsKT+5NAJI+2W4UjvS5KLG28K9v5UvNoFVjHV8PajVZ00RH3vnjyQO7ZAw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/acorn": {
|
"node_modules/acorn": {
|
||||||
"version": "8.15.0",
|
"version": "8.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||||
@@ -2421,6 +2447,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/base64-arraybuffer": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.9.14",
|
"version": "2.9.14",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.14.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.14.tgz",
|
||||||
@@ -3912,6 +3947,12 @@
|
|||||||
"reusify": "^1.0.4"
|
"reusify": "^1.0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fflate": {
|
||||||
|
"version": "0.4.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.8.tgz",
|
||||||
|
"integrity": "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/file-entry-cache": {
|
"node_modules/file-entry-cache": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
||||||
@@ -5198,6 +5239,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mitt": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/motion-dom": {
|
"node_modules/motion-dom": {
|
||||||
"version": "12.26.2",
|
"version": "12.26.2",
|
||||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.26.2.tgz",
|
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.26.2.tgz",
|
||||||
@@ -6151,6 +6198,47 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/rrdom": {
|
||||||
|
"version": "0.1.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/rrdom/-/rrdom-0.1.7.tgz",
|
||||||
|
"integrity": "sha512-ZLd8f14z9pUy2Hk9y636cNv5Y2BMnNEY99wxzW9tD2BLDfe1xFxtLjB4q/xCBYo6HRe0wofzKzjm4JojmpBfFw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"rrweb-snapshot": "^2.0.0-alpha.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/rrweb": {
|
||||||
|
"version": "2.0.0-alpha.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/rrweb/-/rrweb-2.0.0-alpha.4.tgz",
|
||||||
|
"integrity": "sha512-wEHUILbxDPcNwkM3m4qgPgXAiBJyqCbbOHyVoNEVBJzHszWEFYyTbrZqUdeb1EfmTRC2PsumCIkVcomJ/xcOzA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@rrweb/types": "^2.0.0-alpha.4",
|
||||||
|
"@types/css-font-loading-module": "0.0.7",
|
||||||
|
"@xstate/fsm": "^1.4.0",
|
||||||
|
"base64-arraybuffer": "^1.0.1",
|
||||||
|
"fflate": "^0.4.4",
|
||||||
|
"mitt": "^3.0.0",
|
||||||
|
"rrdom": "^0.1.7",
|
||||||
|
"rrweb-snapshot": "^2.0.0-alpha.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/rrweb-player": {
|
||||||
|
"version": "1.0.0-alpha.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/rrweb-player/-/rrweb-player-1.0.0-alpha.4.tgz",
|
||||||
|
"integrity": "sha512-Wlmn9GZ5Fdqa37vd3TzsYdLl/JWEvXNUrLCrYpnOwEgmY409HwVIvvA5aIo7k582LoKgdRCsB87N+f0oWAR0Kg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tsconfig/svelte": "^1.0.0",
|
||||||
|
"rrweb": "^2.0.0-alpha.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/rrweb-snapshot": {
|
||||||
|
"version": "2.0.0-alpha.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/rrweb-snapshot/-/rrweb-snapshot-2.0.0-alpha.4.tgz",
|
||||||
|
"integrity": "sha512-KQ2OtPpXO5jLYqg1OnXS/Hf+EzqnZyP5A+XPqBCjYpj3XIje/Od4gdUwjbFo3cVuWq5Cw5Y1d3/xwgIS7/XpQQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/run-parallel": {
|
"node_modules/run-parallel": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
||||||
|
|||||||
@@ -25,6 +25,8 @@
|
|||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
"react-simple-maps": "^3.0.0",
|
"react-simple-maps": "^3.0.0",
|
||||||
"recharts": "^2.15.0",
|
"recharts": "^2.15.0",
|
||||||
|
"rrweb": "^2.0.0-alpha.4",
|
||||||
|
"rrweb-player": "^1.0.0-alpha.4",
|
||||||
"sonner": "^2.0.7"
|
"sonner": "^2.0.7"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
|
|||||||
307
public/script.js
307
public/script.js
@@ -1,6 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Ciphera Analytics - Privacy-First Tracking Script
|
* Ciphera Analytics - Privacy-First Tracking Script
|
||||||
* Lightweight, no cookies, GDPR compliant
|
* Lightweight, no cookies, GDPR compliant
|
||||||
|
* Includes optional session replay with privacy controls
|
||||||
*/
|
*/
|
||||||
|
|
||||||
(function() {
|
(function() {
|
||||||
@@ -19,11 +20,22 @@
|
|||||||
|
|
||||||
const domain = script.getAttribute('data-domain');
|
const domain = script.getAttribute('data-domain');
|
||||||
const apiUrl = script.getAttribute('data-api') || 'https://analytics-api.ciphera.net';
|
const apiUrl = script.getAttribute('data-api') || 'https://analytics-api.ciphera.net';
|
||||||
|
|
||||||
// * Performance Monitoring (Core Web Vitals) State
|
// * Performance Monitoring (Core Web Vitals) State
|
||||||
let currentEventId = null;
|
let currentEventId = null;
|
||||||
let metrics = { lcp: 0, cls: 0, inp: 0 };
|
let metrics = { lcp: 0, cls: 0, inp: 0 };
|
||||||
|
|
||||||
|
// * Session Replay State
|
||||||
|
let replayEnabled = false;
|
||||||
|
let replayMode = 'disabled';
|
||||||
|
let replayId = null;
|
||||||
|
let replaySettings = null;
|
||||||
|
let rrwebStopFn = null;
|
||||||
|
let replayEvents = [];
|
||||||
|
let chunkInterval = null;
|
||||||
|
const CHUNK_SIZE = 50;
|
||||||
|
const CHUNK_INTERVAL_MS = 10000;
|
||||||
|
|
||||||
// * Minimal Web Vitals Observer
|
// * Minimal Web Vitals Observer
|
||||||
function observeMetrics() {
|
function observeMetrics() {
|
||||||
try {
|
try {
|
||||||
@@ -55,7 +67,7 @@
|
|||||||
if (entry.duration > metrics.inp) metrics.inp = entry.duration;
|
if (entry.duration > metrics.inp) metrics.inp = entry.duration;
|
||||||
}
|
}
|
||||||
}).observe({ type: 'event', buffered: true, durationThreshold: 16 });
|
}).observe({ type: 'event', buffered: true, durationThreshold: 16 });
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// * Browser doesn't support PerformanceObserver or specific entry types
|
// * Browser doesn't support PerformanceObserver or specific entry types
|
||||||
}
|
}
|
||||||
@@ -91,6 +103,11 @@
|
|||||||
document.addEventListener('visibilitychange', () => {
|
document.addEventListener('visibilitychange', () => {
|
||||||
if (document.visibilityState === 'hidden') {
|
if (document.visibilityState === 'hidden') {
|
||||||
sendMetrics();
|
sendMetrics();
|
||||||
|
// Also flush replay data
|
||||||
|
if (replayEnabled) {
|
||||||
|
sendReplayChunk();
|
||||||
|
endReplaySession();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -107,11 +124,11 @@
|
|||||||
const key = 'ciphera_session_id';
|
const key = 'ciphera_session_id';
|
||||||
// * Legacy key support for migration (strip whitespace just in case)
|
// * Legacy key support for migration (strip whitespace just in case)
|
||||||
const legacyKey = 'plausible_session_' + (domain ? domain.trim() : '');
|
const legacyKey = 'plausible_session_' + (domain ? domain.trim() : '');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// * Try to get existing session ID
|
// * Try to get existing session ID
|
||||||
cachedSessionId = sessionStorage.getItem(key);
|
cachedSessionId = sessionStorage.getItem(key);
|
||||||
|
|
||||||
// * If not found in new key, try legacy key and migrate
|
// * If not found in new key, try legacy key and migrate
|
||||||
if (!cachedSessionId && legacyKey) {
|
if (!cachedSessionId && legacyKey) {
|
||||||
cachedSessionId = sessionStorage.getItem(legacyKey);
|
cachedSessionId = sessionStorage.getItem(legacyKey);
|
||||||
@@ -139,9 +156,9 @@
|
|||||||
function trackPageview() {
|
function trackPageview() {
|
||||||
// * Reset metrics for new pageview (SPA navigation)
|
// * Reset metrics for new pageview (SPA navigation)
|
||||||
// * We don't reset immediately on the first run, but for subsequent calls we should
|
// * We don't reset immediately on the first run, but for subsequent calls we should
|
||||||
// * However, for the very first call, metrics are already 0.
|
// * However, for the very first call, metrics are already 0.
|
||||||
// * The issue is if we reset metrics here, we might lose early captured metrics (e.g. LCP) if this runs late?
|
// * The issue is if we reset metrics here, we might lose early captured metrics (e.g. LCP) if this runs late?
|
||||||
// * No, trackPageview runs early.
|
// * No, trackPageview runs early.
|
||||||
// * BUT for SPA navigation, we want to reset.
|
// * BUT for SPA navigation, we want to reset.
|
||||||
if (currentEventId) {
|
if (currentEventId) {
|
||||||
// If we already had an event ID, it means this is a subsequent navigation
|
// If we already had an event ID, it means this is a subsequent navigation
|
||||||
@@ -149,7 +166,7 @@
|
|||||||
// Ideally visibilitychange handles this, but for SPA nav it might not trigger visibilitychange.
|
// Ideally visibilitychange handles this, but for SPA nav it might not trigger visibilitychange.
|
||||||
sendMetrics();
|
sendMetrics();
|
||||||
}
|
}
|
||||||
|
|
||||||
metrics = { lcp: 0, cls: 0, inp: 0 };
|
metrics = { lcp: 0, cls: 0, inp: 0 };
|
||||||
currentEventId = null;
|
currentEventId = null;
|
||||||
|
|
||||||
@@ -186,9 +203,277 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// * SESSION REPLAY FUNCTIONALITY
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
// * Fetch replay settings from API
|
||||||
|
async function fetchReplaySettings() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(apiUrl + '/api/v1/replay-settings/' + encodeURIComponent(domain));
|
||||||
|
if (res.ok) {
|
||||||
|
replaySettings = await res.json();
|
||||||
|
replayMode = replaySettings.replay_mode;
|
||||||
|
|
||||||
|
// Check sampling rate
|
||||||
|
if (replayMode !== 'disabled') {
|
||||||
|
const shouldRecord = Math.random() * 100 < replaySettings.replay_sampling_rate;
|
||||||
|
if (!shouldRecord) {
|
||||||
|
replayMode = 'disabled';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-start for anonymous_skeleton mode (no consent needed)
|
||||||
|
if (replayMode === 'anonymous_skeleton') {
|
||||||
|
startReplay(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Silent fail - replay not critical
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// * Initialize replay session on server
|
||||||
|
async function initReplaySession(isSkeletonMode) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(apiUrl + '/api/v1/replays', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
domain: domain,
|
||||||
|
session_id: getSessionId(),
|
||||||
|
entry_page: window.location.pathname,
|
||||||
|
is_skeleton_mode: isSkeletonMode,
|
||||||
|
consent_given: !isSkeletonMode,
|
||||||
|
device_type: detectDeviceType(),
|
||||||
|
browser: detectBrowser(),
|
||||||
|
os: detectOS()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
replayId = data.id;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Silent fail
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// * Start recording session
|
||||||
|
async function startReplay(isSkeletonMode) {
|
||||||
|
if (replayEnabled || typeof window.rrweb === 'undefined') return;
|
||||||
|
|
||||||
|
// Initialize session on server first
|
||||||
|
const initialized = await initReplaySession(isSkeletonMode);
|
||||||
|
if (!initialized) return;
|
||||||
|
|
||||||
|
replayEnabled = true;
|
||||||
|
|
||||||
|
// Configure masking based on mode and settings
|
||||||
|
const maskConfig = {
|
||||||
|
// Always mask sensitive inputs
|
||||||
|
maskInputOptions: {
|
||||||
|
password: true,
|
||||||
|
email: true,
|
||||||
|
tel: true,
|
||||||
|
// In skeleton mode, mask all text inputs
|
||||||
|
text: isSkeletonMode,
|
||||||
|
textarea: isSkeletonMode,
|
||||||
|
select: isSkeletonMode
|
||||||
|
},
|
||||||
|
// Mask all text in skeleton mode
|
||||||
|
maskAllText: isSkeletonMode || (replaySettings && replaySettings.replay_mask_all_text),
|
||||||
|
// Mask all inputs by default (can be overridden in settings)
|
||||||
|
maskAllInputs: replaySettings ? replaySettings.replay_mask_all_inputs : true,
|
||||||
|
// Custom classes for masking
|
||||||
|
maskTextClass: 'ciphera-mask',
|
||||||
|
blockClass: 'ciphera-block',
|
||||||
|
// Mask elements with data-ciphera-mask attribute
|
||||||
|
maskTextSelector: '[data-ciphera-mask]',
|
||||||
|
// Block elements with data-ciphera-block attribute
|
||||||
|
blockSelector: '[data-ciphera-block]',
|
||||||
|
// Custom input masking function for credit cards
|
||||||
|
maskInputFn: (text, element) => {
|
||||||
|
// Mask credit card patterns
|
||||||
|
if (/^\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}$/.test(text)) {
|
||||||
|
return '****-****-****-****';
|
||||||
|
}
|
||||||
|
// Mask email patterns
|
||||||
|
if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(text)) {
|
||||||
|
return '***@***.***';
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
rrwebStopFn = window.rrweb.record({
|
||||||
|
emit(event) {
|
||||||
|
replayEvents.push(event);
|
||||||
|
|
||||||
|
// Send chunk when threshold reached
|
||||||
|
if (replayEvents.length >= CHUNK_SIZE) {
|
||||||
|
sendReplayChunk();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
...maskConfig,
|
||||||
|
// Privacy: Don't record external resources
|
||||||
|
recordCanvas: false,
|
||||||
|
collectFonts: false,
|
||||||
|
// Sampling for mouse movement (reduce data)
|
||||||
|
sampling: {
|
||||||
|
mousemove: true,
|
||||||
|
mouseInteraction: true,
|
||||||
|
scroll: 150,
|
||||||
|
input: 'last'
|
||||||
|
},
|
||||||
|
// Inline styles for replay accuracy
|
||||||
|
inlineStylesheet: true,
|
||||||
|
// Slim snapshot to reduce size
|
||||||
|
slimDOMOptions: {
|
||||||
|
script: true,
|
||||||
|
comment: true,
|
||||||
|
headFavicon: true,
|
||||||
|
headWhitespace: true,
|
||||||
|
headMetaDescKeywords: true,
|
||||||
|
headMetaSocial: true,
|
||||||
|
headMetaRobots: true,
|
||||||
|
headMetaHttpEquiv: true,
|
||||||
|
headMetaAuthorship: true,
|
||||||
|
headMetaVerification: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set up periodic chunk sending
|
||||||
|
chunkInterval = setInterval(sendReplayChunk, CHUNK_INTERVAL_MS);
|
||||||
|
} catch (e) {
|
||||||
|
replayEnabled = false;
|
||||||
|
replayId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// * Send chunk of events to server
|
||||||
|
async function sendReplayChunk() {
|
||||||
|
if (!replayId || replayEvents.length === 0) return;
|
||||||
|
|
||||||
|
const chunk = replayEvents.splice(0, CHUNK_SIZE);
|
||||||
|
const eventsCount = chunk.length;
|
||||||
|
const data = JSON.stringify(chunk);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try to compress if available
|
||||||
|
let body;
|
||||||
|
let headers = { 'X-Events-Count': eventsCount.toString() };
|
||||||
|
|
||||||
|
if (typeof CompressionStream !== 'undefined') {
|
||||||
|
const blob = new Blob([data]);
|
||||||
|
const stream = blob.stream().pipeThrough(new CompressionStream('gzip'));
|
||||||
|
body = await new Response(stream).blob();
|
||||||
|
headers['Content-Encoding'] = 'gzip';
|
||||||
|
headers['Content-Type'] = 'application/octet-stream';
|
||||||
|
} else {
|
||||||
|
body = new Blob([data], { type: 'application/json' });
|
||||||
|
headers['Content-Type'] = 'application/json';
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetch(apiUrl + '/api/v1/replays/' + replayId + '/chunks', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: headers,
|
||||||
|
body: body,
|
||||||
|
keepalive: true
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// Re-queue events on failure
|
||||||
|
replayEvents.unshift(...chunk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// * End replay session
|
||||||
|
function endReplaySession() {
|
||||||
|
if (!replayEnabled || !replayId) return;
|
||||||
|
|
||||||
|
// Clear interval
|
||||||
|
if (chunkInterval) {
|
||||||
|
clearInterval(chunkInterval);
|
||||||
|
chunkInterval = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop recording
|
||||||
|
if (rrwebStopFn) {
|
||||||
|
rrwebStopFn();
|
||||||
|
rrwebStopFn = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send remaining events
|
||||||
|
if (replayEvents.length > 0) {
|
||||||
|
const chunk = replayEvents.splice(0);
|
||||||
|
const data = JSON.stringify(chunk);
|
||||||
|
navigator.sendBeacon(
|
||||||
|
apiUrl + '/api/v1/replays/' + replayId + '/chunks',
|
||||||
|
new Blob([data], { type: 'application/json' })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark session as ended
|
||||||
|
navigator.sendBeacon(apiUrl + '/api/v1/replays/' + replayId + '/end');
|
||||||
|
|
||||||
|
replayEnabled = false;
|
||||||
|
replayId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// * Device detection helpers
|
||||||
|
function detectDeviceType() {
|
||||||
|
const ua = navigator.userAgent.toLowerCase();
|
||||||
|
if (/mobile|android|iphone|ipod/.test(ua)) return 'mobile';
|
||||||
|
if (/tablet|ipad/.test(ua)) return 'tablet';
|
||||||
|
return 'desktop';
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectBrowser() {
|
||||||
|
const ua = navigator.userAgent.toLowerCase();
|
||||||
|
if (ua.includes('chrome') && !ua.includes('edg')) return 'Chrome';
|
||||||
|
if (ua.includes('firefox')) return 'Firefox';
|
||||||
|
if (ua.includes('safari') && !ua.includes('chrome')) return 'Safari';
|
||||||
|
if (ua.includes('edg')) return 'Edge';
|
||||||
|
if (ua.includes('opera')) return 'Opera';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectOS() {
|
||||||
|
const ua = navigator.userAgent.toLowerCase();
|
||||||
|
if (ua.includes('windows')) return 'Windows';
|
||||||
|
if (ua.includes('mac os') || ua.includes('macos')) return 'macOS';
|
||||||
|
if (ua.includes('linux')) return 'Linux';
|
||||||
|
if (ua.includes('android')) return 'Android';
|
||||||
|
if (ua.includes('ios') || ua.includes('iphone') || ua.includes('ipad')) return 'iOS';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// * Public API for consent-based activation
|
||||||
|
window.ciphera = window.ciphera || function(cmd) {
|
||||||
|
if (cmd === 'enableReplay') {
|
||||||
|
if (replayMode === 'consent_required' && !replayEnabled) {
|
||||||
|
startReplay(false);
|
||||||
|
}
|
||||||
|
} else if (cmd === 'disableReplay') {
|
||||||
|
endReplaySession();
|
||||||
|
} else if (cmd === 'getReplayMode') {
|
||||||
|
return replayMode;
|
||||||
|
} else if (cmd === 'isReplayEnabled') {
|
||||||
|
return replayEnabled;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// * Track initial pageview
|
// * Track initial pageview
|
||||||
trackPageview();
|
trackPageview();
|
||||||
|
|
||||||
|
// * Fetch replay settings (async, doesn't block pageview)
|
||||||
|
fetchReplaySettings();
|
||||||
|
|
||||||
// * Track SPA navigation (history API)
|
// * Track SPA navigation (history API)
|
||||||
let lastUrl = location.href;
|
let lastUrl = location.href;
|
||||||
new MutationObserver(() => {
|
new MutationObserver(() => {
|
||||||
@@ -201,4 +486,12 @@
|
|||||||
|
|
||||||
// * Track popstate (browser back/forward)
|
// * Track popstate (browser back/forward)
|
||||||
window.addEventListener('popstate', trackPageview);
|
window.addEventListener('popstate', trackPageview);
|
||||||
|
|
||||||
|
// * Cleanup on page unload
|
||||||
|
window.addEventListener('beforeunload', () => {
|
||||||
|
if (replayEnabled) {
|
||||||
|
sendReplayChunk();
|
||||||
|
endReplaySession();
|
||||||
|
}
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
Reference in New Issue
Block a user