From e75d70269f98f76cc7e50c598556f02c4c7de13b Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Fri, 23 Jan 2026 18:21:50 +0100 Subject: [PATCH] refactor(replay): remove session replay functionality and related components to streamline codebase --- app/faq/page.tsx | 6 +- app/sites/[id]/replays/[replayId]/page.tsx | 439 --------------------- app/sites/[id]/replays/page.tsx | 278 ------------- app/sites/[id]/settings/page.tsx | 219 +--------- components/ReplayPlayerControls.tsx | 192 --------- lib/api/replays.ts | 113 ------ lib/api/sites.ts | 13 - lib/utils/privacySnippet.ts | 2 - package.json | 3 - public/script.js | 329 --------------- 10 files changed, 6 insertions(+), 1588 deletions(-) delete mode 100644 app/sites/[id]/replays/[replayId]/page.tsx delete mode 100644 app/sites/[id]/replays/page.tsx delete mode 100644 components/ReplayPlayerControls.tsx delete mode 100644 lib/api/replays.ts diff --git a/app/faq/page.tsx b/app/faq/page.tsx index ae97b80..48cfe81 100644 --- a/app/faq/page.tsx +++ b/app/faq/page.tsx @@ -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?", diff --git a/app/sites/[id]/replays/[replayId]/page.tsx b/app/sites/[id]/replays/[replayId]/page.tsx deleted file mode 100644 index 21bc011..0000000 --- a/app/sites/[id]/replays/[replayId]/page.tsx +++ /dev/null @@ -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(null) - const [replay, setReplay] = useState(null) - const [replayData, setReplayData] = useState(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(null) - const playerContainerRef = useRef(null) - const playerRef = useRef(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 - if (!site || !replay) return
Replay not found
- - return ( -
- {/* Header */} -
-
- -
-
-

- Session Replay -

-
- -
-
-
- -
- {/* Player */} -
-
-
- {/* Player container (rrweb-player mounts here when replayData is ready) */} -
- {loadingData ? ( -
-
- Loading replay data... -
- ) : !replayData || replayData.length === 0 ? ( -
-
🎬
-

No replay data available

-

This session may not have recorded any events.

-
- ) : null} -
- - {/* Custom controls (Ciphera‑branded) */} - {playerReady && ( - { - ;(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?.() - } - }} - /> - )} -
- - {/* Session info bar */} - {playerReady && ( -
-
- {replay.events_count} events - β€’ - {formatDuration(replay.duration_ms)} -
-
- {replay.is_skeleton_mode && ( - - - Skeleton Mode - - )} -
-
- )} -
-
- - {/* Session Info Sidebar */} -
-
-
-

Session Details

-
-
-
- Session ID -

{replay.session_id}

-
- -
- Entry Page -

{replay.entry_page}

-
- -
-
- Duration -

{formatDuration(replay.duration_ms)}

-
-
- Events -

{replay.events_count}

-
-
- -
-
- Device -

{replay.device_type || 'Unknown'}

-
-
- Browser -

{replay.browser || 'Unknown'}

-
-
- -
-
- OS -

{replay.os || 'Unknown'}

-
-
- Location -

{getFlagEmoji(replay.country)} {replay.country || 'Unknown'}

-
-
- -
- Started -

{formatDate(replay.started_at)}

-
- - {replay.ended_at && ( -
- Ended -

{formatDate(replay.ended_at)}

-
- )} - -
-
- {replay.is_skeleton_mode ? ( - - - Skeleton Mode (Anonymous) - - ) : replay.consent_given ? ( - - βœ“ Consent Given - - ) : ( - - ⚠ No Consent Record - - )} -
-
-
-
-
-
- - {/* Delete Confirmation Modal */} - {showDeleteModal && ( -
-
-

Delete Replay?

-

- This action cannot be undone. The replay data will be permanently deleted. -

-
- - -
-
-
- )} - - {/* * rrweb player frame and cursor – Ciphera branding (controller is custom, not rrweb) */} - -
- ) -} diff --git a/app/sites/[id]/replays/page.tsx b/app/sites/[id]/replays/page.tsx deleted file mode 100644 index d78bba5..0000000 --- a/app/sites/[id]/replays/page.tsx +++ /dev/null @@ -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(null) - const [replays, setReplays] = useState([]) - const [loading, setLoading] = useState(true) - const [total, setTotal] = useState(0) - const [filters, setFilters] = useState({ - 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 - if (!site) return
Site not found
- - const currentPage = Math.floor((filters.offset || 0) / (filters.limit || 20)) + 1 - const totalPages = Math.ceil(total / (filters.limit || 20)) - - return ( -
-
-
- -
-
-

- Session Replays - - {total} recordings - -

- {site.replay_mode === 'disabled' && ( -
- ⚠️ - Session replay is disabled - -
- )} -
-
- - {/* Filters */} -
- 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+' }, - ]} - /> -
- - {/* Replays List */} -
- {replays.length === 0 ? ( -
-
🎬
-

No session replays yet

-

- {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.'} -

-
- ) : ( - <> - - - - - - - - - - - - - - {replays.map((replay) => ( - router.push(`/sites/${siteId}/replays/${replay.id}`)} - > - - - - - - - - - ))} - -
SessionEntry PageDurationDeviceLocationDateActions
-
- {replay.is_skeleton_mode && ( - - - Skeleton - - )} - - {replay.session_id.substring(0, 8)}... - -
-
- - {replay.entry_page} - - - - {formatDuration(replay.duration_ms)} - - -
- {getDeviceEmoji(replay.device_type)} - - {replay.browser || 'Unknown'} - -
-
- - {getFlagEmoji(replay.country)} {replay.country || 'Unknown'} - - - - {formatDate(replay.started_at)} - - - -
- - {/* Pagination */} - {totalPages > 1 && ( -
- - Showing {(filters.offset || 0) + 1} - {Math.min((filters.offset || 0) + (filters.limit || 20), total)} of {total} - -
- - - Page {currentPage} of {totalPages} - - -
-
- )} - - )} -
-
- ) -} diff --git a/app/sites/[id]/settings/page.tsx b/app/sites/[id]/settings/page.tsx index e283f2f..366983c 100644 --- a/app/sites/[id]/settings/page.tsx +++ b/app/sites/[id]/settings/page.tsx @@ -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(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() { Data & Privacy - {/* Content Area */} @@ -857,174 +816,6 @@ export default function SiteSettingsPage() {
)} - - {activeTab === 'replay' && ( -
-
-
-

Session Replay

-

Record and playback visitor sessions to understand user behavior.

-
- - {/* Privacy Mode Selection */} -
-

Privacy Mode

- -
- {/* Disabled */} - - - {/* Anonymous Skeleton */} - -
-
- - {/* Recording Settings - Only show when not disabled */} - - {formData.replay_mode !== 'disabled' && ( - -

Recording Settings

- - {/* Sampling Rate */} -
-
-
-

Sampling Rate

-

- Percentage of sessions to record -

-
- 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]" - /> -
-
- - {/* View Replays Link */} - -
- )} -
- -
- {canEdit && ( - - )} -
-
-
- )} diff --git a/components/ReplayPlayerControls.tsx b/components/ReplayPlayerControls.tsx deleted file mode 100644 index a110f35..0000000 --- a/components/ReplayPlayerControls.tsx +++ /dev/null @@ -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) => { - 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 ( -
- {/* * Progress bar / timeline */} -
- - {formatTime(currentTimeMs)} - -
-
- {displayFraction > 0 && displayFraction < 1 && ( -
- )} -
- -
- - {formatTime(totalTimeMs)} - -
- - {/* * Buttons row */} -
-
- {/* * Play / Pause */} - - - {/* * Speed pills */} -
- {SPEED_OPTIONS.map((s) => ( - - ))} -
- - {/* * Skip inactive toggle */} - -
- - {/* * Fullscreen */} - -
-
- ) -} diff --git a/lib/api/replays.ts b/lib/api/replays.ts deleted file mode 100644 index df37d93..0000000 --- a/lib/api/replays.ts +++ /dev/null @@ -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 { - 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(url) -} - -export async function getReplay(siteId: string, replayId: string): Promise { - return apiRequest(`/sites/${siteId}/replays/${replayId}`) -} - -export async function getReplayData(siteId: string, replayId: string): Promise { - const response = await apiRequest(`/sites/${siteId}/replays/${replayId}/data`) - return response -} - -export async function deleteReplay(siteId: string, replayId: string): Promise { - 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 '🌐' - } -} diff --git a/lib/api/sites.ts b/lib/api/sites.ts index 65c7e36..cbd6604 100644 --- a/lib/api/sites.ts +++ b/lib/api/sites.ts @@ -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 { diff --git a/lib/utils/privacySnippet.ts b/lib/utils/privacySnippet.ts index cb6bdb9..12ffe75 100644 --- a/lib/utils/privacySnippet.ts +++ b/lib/utils/privacySnippet.ts @@ -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 diff --git a/package.json b/package.json index 0a729fa..3e5b4ba 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/public/script.js b/public/script.js index 46e40b0..2a08297 100644 --- a/public/script.js +++ b/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(); - } - }); })();