Built the Sentinel Tab

This commit is contained in:
Sagnik
2026-04-12 02:02:58 +05:30
parent fb656d1443
commit 075ab280ad
526 changed files with 17646 additions and 70931 deletions

View File

@@ -0,0 +1,309 @@
/**
* NotificationCenter — Active Notification Center (Top-Right Bar)
*
* A fixed Bell icon with animated unread count badge. Clicking opens a
* glassmorphic dropdown panel showing up to 20 recent Velocity events:
* - 🚨 Velocity Link Opened (WS_ASSET_OPENED)
* - 📈 QD Score Spike (QD_UPDATED >= 75)
* - 🏷️ Lead Tagged (LEAD_TAGGED)
*
* WebSocket events are consumed by useVelocitySocket which writes to the
* Zustand NotificationState slice. This component is display-only.
*/
import { useState, useRef, useEffect, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Bell, BellRing, Check, X, Trash2 } from 'lucide-react';
import { useStore } from '@/store/useStore';
import { useVelocitySocket } from '@/hooks/useVelocitySocket';
import { cn } from '@/lib/utils';
import type { VelocityNotification } from '@/types';
// ── Icon per notification type ────────────────────────────────────────────────
function NotifIcon({ type }: { type: VelocityNotification['type'] }) {
switch (type) {
case 'velocity_link_opened': return <span className="text-base">🚨</span>;
case 'qd_spike': return <span className="text-base">📈</span>;
case 'lead_tagged': return <span className="text-base">🏷</span>;
default: return <span className="text-base">🔔</span>;
}
}
// ── Individual notification row ───────────────────────────────────────────────
function NotifRow({
notif,
onRead,
}: {
notif: VelocityNotification;
onRead: (id: string) => void;
}) {
const elapsed = Math.round((Date.now() - new Date(notif.timestamp).getTime()) / 1000);
const timeLabel =
elapsed < 60
? `${elapsed}s ago`
: elapsed < 3600
? `${Math.floor(elapsed / 60)}m ago`
: `${Math.floor(elapsed / 3600)}h ago`;
return (
<motion.div
layout
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20, height: 0 }}
transition={{ duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
className={cn(
'flex gap-3 items-start px-4 py-3 rounded-xl transition-colors cursor-default',
notif.isRead
? 'opacity-50'
: 'bg-white/[0.03] hover:bg-white/[0.06]',
)}
onClick={() => !notif.isRead && onRead(notif.id)}
>
{/* Icon */}
<div
className="flex-shrink-0 w-8 h-8 rounded-lg flex items-center justify-center"
style={{ background: 'rgba(59,130,246,0.12)', border: '1px solid rgba(59,130,246,0.2)' }}
>
<NotifIcon type={notif.type} />
</div>
{/* Body */}
<div className="flex-1 min-w-0">
<p className="text-[13px] font-semibold text-white leading-tight truncate">
{notif.title}
</p>
<p className="text-[12px] text-zinc-400 mt-0.5 leading-snug line-clamp-2">
{notif.body}
</p>
{notif.type === 'qd_spike' && notif.qdScore !== undefined && (
<div className="mt-1.5 flex items-center gap-1.5">
<div className="h-1 w-16 rounded-full bg-white/10 overflow-hidden">
<motion.div
className="h-full rounded-full"
style={{ background: 'linear-gradient(90deg, #3b82f6, #06b6d4)' }}
initial={{ width: 0 }}
animate={{ width: `${notif.qdScore}%` }}
transition={{ duration: 0.6, ease: 'easeOut' }}
/>
</div>
<span className="text-[11px] text-blue-400 font-medium">{notif.qdScore}/100</span>
</div>
)}
{notif.type === 'lead_tagged' && notif.tags?.length && (
<div className="flex gap-1 mt-1.5 flex-wrap">
{notif.tags.map((tag) => (
<span
key={tag}
className="text-[10px] px-1.5 py-0.5 rounded-md font-medium bg-amber-500/15 text-amber-400 border border-amber-500/20"
>
{tag}
</span>
))}
</div>
)}
</div>
{/* Timestamp + unread dot */}
<div className="flex flex-col items-end gap-1.5 flex-shrink-0">
<span className="text-[11px] text-zinc-500">{timeLabel}</span>
{!notif.isRead && (
<div className="w-2 h-2 rounded-full bg-blue-500" style={{ boxShadow: '0 0 6px rgba(59,130,246,0.8)' }} />
)}
</div>
</motion.div>
);
}
// ── Main component ────────────────────────────────────────────────────────────
export function NotificationCenter() {
const { notifications, unreadCount, markAllRead, markAsRead, clearNotifications } = useStore();
const [isOpen, setIsOpen] = useState(false);
const panelRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
// Connect WebSocket — this populates the store on every mount of MainLayout
useVelocitySocket({ channel: 'notifications' });
// Close on outside click
useEffect(() => {
function handleClick(e: MouseEvent) {
if (
panelRef.current &&
!panelRef.current.contains(e.target as Node) &&
!buttonRef.current?.contains(e.target as Node)
) {
setIsOpen(false);
}
}
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, []);
// Auto-open on new notification
const prevUnread = useRef(unreadCount);
useEffect(() => {
if (unreadCount > prevUnread.current) {
setIsOpen(true);
}
prevUnread.current = unreadCount;
}, [unreadCount]);
const handleToggle = useCallback(() => {
setIsOpen((v) => !v);
}, []);
return (
<div className="relative">
{/* Bell Button */}
<motion.button
ref={buttonRef}
id="notification-bell"
aria-label="Open notification center"
aria-expanded={isOpen}
onClick={handleToggle}
whileTap={{ scale: 0.9 }}
className="relative h-9 w-9 flex items-center justify-center rounded-xl text-zinc-400 hover:text-white hover:bg-white/5 transition-colors outline-none"
>
<AnimatePresence mode="wait">
{unreadCount > 0 ? (
<motion.span key="ring" initial={{ scale: 0.5 }} animate={{ scale: 1 }}>
<BellRing className="w-5 h-5 text-blue-400" />
</motion.span>
) : (
<motion.span key="bell" initial={{ scale: 0.5 }} animate={{ scale: 1 }}>
<Bell className="w-5 h-5" />
</motion.span>
)}
</AnimatePresence>
{/* Unread badge */}
<AnimatePresence>
{unreadCount > 0 && (
<motion.span
key="badge"
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0, opacity: 0 }}
transition={{ type: 'spring', stiffness: 500, damping: 25 }}
className="absolute -top-1 -right-1 min-w-[18px] h-[18px] px-1 rounded-full text-[10px] font-bold flex items-center justify-center text-white"
style={{ background: 'linear-gradient(135deg, #f59e0b, #ef4444)' }}
>
{unreadCount > 9 ? '9+' : unreadCount}
</motion.span>
)}
</AnimatePresence>
</motion.button>
{/* Dropdown Panel */}
<AnimatePresence>
{isOpen && (
<motion.div
ref={panelRef}
id="notification-panel"
role="dialog"
aria-label="Notifications"
initial={{ opacity: 0, y: -8, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -8, scale: 0.96 }}
transition={{ duration: 0.18, ease: [0.4, 0, 0.2, 1] }}
className="absolute right-0 top-12 w-[380px] max-h-[520px] rounded-2xl overflow-hidden z-50 flex flex-col"
style={{
background: 'rgba(9, 9, 15, 0.92)',
backdropFilter: 'blur(24px)',
border: '1px solid rgba(255,255,255,0.08)',
boxShadow: '0 20px 60px rgba(0,0,0,0.6), 0 0 0 1px rgba(255,255,255,0.04)',
}}
>
{/* Header */}
<div
className="flex items-center justify-between px-4 py-3 flex-shrink-0"
style={{ borderBottom: '1px solid rgba(255,255,255,0.06)' }}
>
<div>
<h2 className="text-sm font-semibold text-white">Notifications</h2>
<p className="text-[11px] text-zinc-500 mt-0.5">
{unreadCount > 0 ? `${unreadCount} unread` : 'All caught up'}
</p>
</div>
<div className="flex items-center gap-1">
{unreadCount > 0 && (
<button
onClick={markAllRead}
className="flex items-center gap-1 text-[11px] text-blue-400 hover:text-blue-300 px-2 py-1 rounded-lg hover:bg-blue-500/10 transition-colors"
aria-label="Mark all as read"
>
<Check className="w-3 h-3" />
Mark all read
</button>
)}
{notifications.length > 0 && (
<button
onClick={clearNotifications}
className="p-1.5 rounded-lg text-zinc-500 hover:text-red-400 hover:bg-red-500/10 transition-colors"
aria-label="Clear all notifications"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
<button
onClick={() => setIsOpen(false)}
className="p-1.5 rounded-lg text-zinc-500 hover:text-white hover:bg-white/5 transition-colors"
aria-label="Close notifications"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
</div>
{/* Feed */}
<div className="overflow-y-auto flex-1 py-2 px-1 custom-scrollbar">
<AnimatePresence initial={false}>
{notifications.length === 0 ? (
<motion.div
key="empty"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex flex-col items-center justify-center py-12 gap-3"
>
<div
className="w-12 h-12 rounded-2xl flex items-center justify-center"
style={{ background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.08)' }}
>
<Bell className="w-5 h-5 text-zinc-600" />
</div>
<div className="text-center">
<p className="text-sm text-zinc-400 font-medium">No notifications yet</p>
<p className="text-[12px] text-zinc-600 mt-1">
Velocity Link opens and QD spikes will appear here.
</p>
</div>
</motion.div>
) : (
notifications.map((notif) => (
<NotifRow key={notif.id} notif={notif} onRead={markAsRead} />
))
)}
</AnimatePresence>
</div>
{/* Footer */}
{notifications.length > 0 && (
<div
className="px-4 py-2.5 flex-shrink-0 text-center"
style={{ borderTop: '1px solid rgba(255,255,255,0.06)' }}
>
<p className="text-[11px] text-zinc-600">
Showing {notifications.length} of {notifications.length} events
</p>
</div>
)}
</motion.div>
)}
</AnimatePresence>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,756 @@
/**
* PerceptionPlayer - The Stimulus Viewport
*
* Dual-stream component that simultaneously:
* 1. Plays the marketing video (stimulus)
* 2. Captures the prospect's webcam feed (response)
* 3. Processes facial landmarks via MediaPipe FaceLandmarker (in-browser WASM)
* 4. Emits compact biometric packets every 500ms to the FastAPI WebSocket
* 5. Displays a real-time QD Score badge updated via WebSocket from NemoClaw
*
* Controls are overlaid INSIDE the root div so they render in fullscreen too.
* Auto-hide behaviour mirrors YouTube: controls appear on hover/move, disappear
* after 2.8 s of inactivity while playing.
*/
import {
useRef,
useState,
useCallback,
useEffect,
useId,
} from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Camera,
CameraOff,
Play,
Pause,
Activity,
ShieldAlert,
Loader2,
Eye,
Volume2,
VolumeX,
Maximize,
Minimize,
Gauge,
} from 'lucide-react';
import { useMediapipeFaceLandmarker } from '@/hooks/useMediapipeFaceLandmarker';
import { useVelocitySocket } from '@/hooks/useVelocitySocket';
import { encodeLandmarkPacket, hasSignificantActivity } from '@/utils/landmarkPacketEncoder';
import { API_URL } from '@/lib/api';
import { cn } from '@/lib/utils';
import type { QDScoreUpdate } from '@/types';
// ── Types ─────────────────────────────────────────────────────────────────────
interface PerceptionPlayerProps {
videoUrl: string;
videoAssetId?: string;
leadId?: string;
sessionMode?: 'assigned' | 'auto';
videoTitle?: string;
onSessionComplete?: (finalQdScore: number) => void;
}
const SPEEDS = [0.5, 0.75, 1, 1.5, 2] as const;
type Speed = (typeof SPEEDS)[number];
// ── Helpers ───────────────────────────────────────────────────────────────────
function fmt(s: number): string {
if (!isFinite(s) || isNaN(s)) return '0:00';
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
return `${m}:${sec.toString().padStart(2, '0')}`;
}
// ── QD Ring ───────────────────────────────────────────────────────────────────
function QDRing({ score }: { score: number }) {
const r = 20;
const circ = 2 * Math.PI * r;
const color =
score >= 75 ? '#22c55e' :
score >= 50 ? '#3b82f6' :
score >= 30 ? '#f59e0b' : '#ef4444';
return (
<div className="relative w-12 h-12 flex items-center justify-center">
<svg width="48" height="48" className="absolute inset-0 -rotate-90">
<circle cx="24" cy="24" r={r} fill="none" stroke="rgba(255,255,255,0.1)" strokeWidth="3.5" />
<motion.circle
cx="24" cy="24" r={r}
fill="none" stroke={color} strokeWidth="3.5" strokeLinecap="round"
strokeDasharray={circ}
animate={{ strokeDashoffset: circ * (1 - score / 100) }}
transition={{ duration: 0.6, ease: 'easeOut' }}
style={{ filter: `drop-shadow(0 0 5px ${color})` }}
/>
</svg>
<motion.span
key={score}
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="text-[12px] font-bold relative z-10"
style={{ color }}
>
{score}
</motion.span>
</div>
);
}
// ── Speed Menu ────────────────────────────────────────────────────────────────
function SpeedMenu({ current, onChange }: { current: Speed; onChange: (s: Speed) => void }) {
const [open, setOpen] = useState(false);
return (
<div className="relative">
<button
onClick={() => setOpen((v) => !v)}
title="Playback speed"
className={cn(
'flex items-center gap-1 px-2 py-1 rounded-lg text-[11px] font-bold transition-colors select-none',
open ? 'bg-white/25 text-white' : 'bg-white/10 hover:bg-white/20 text-white/80 hover:text-white',
)}
>
<Gauge className="w-3.5 h-3.5 shrink-0" />
{current}x
</button>
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0, y: 8, scale: 0.94 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 8, scale: 0.94 }}
transition={{ duration: 0.13 }}
className="absolute bottom-full mb-2 right-0 rounded-xl overflow-hidden shadow-2xl z-[100]"
style={{
background: 'rgba(8,10,20,0.97)',
border: '1px solid rgba(255,255,255,0.12)',
backdropFilter: 'blur(20px)',
minWidth: 88,
}}
>
{[...SPEEDS].reverse().map((s) => (
<button
key={s}
onClick={() => { onChange(s); setOpen(false); }}
className={cn(
'w-full text-center px-4 py-2 text-[12px] font-semibold transition-colors',
s === current
? 'bg-blue-500/25 text-blue-300'
: 'text-zinc-300 hover:bg-white/10 hover:text-white',
)}
>
{s}x
</button>
))}
</motion.div>
)}
</AnimatePresence>
</div>
);
}
// ── Volume Control ────────────────────────────────────────────────────────────
function VolumeControl({
volume, muted, onVolume, onToggleMute,
}: { volume: number; muted: boolean; onVolume: (v: number) => void; onToggleMute: () => void }) {
const [hovered, setHovered] = useState(false);
const fill = muted ? 0 : volume;
return (
<div
className="flex items-center gap-1.5"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
<button
onClick={onToggleMute}
title={muted ? 'Unmute' : 'Mute'}
className="w-7 h-7 flex items-center justify-center rounded-lg bg-white/10 hover:bg-white/20 transition-colors shrink-0"
>
{muted || volume === 0
? <VolumeX className="w-3.5 h-3.5 text-white" />
: <Volume2 className="w-3.5 h-3.5 text-white" />}
</button>
<motion.div
animate={{ width: hovered ? 72 : 0, opacity: hovered ? 1 : 0 }}
transition={{ duration: 0.18, ease: 'easeInOut' }}
className="overflow-hidden flex items-center"
>
<input
type="range" min={0} max={1} step={0.02} value={fill}
onChange={(e) => onVolume(parseFloat(e.target.value))}
className="pp-range pp-volume shrink-0"
style={{ width: 68, '--rf': `${fill * 100}%` } as React.CSSProperties}
/>
</motion.div>
</div>
);
}
// ── Main Component ────────────────────────────────────────────────────────────
export function PerceptionPlayer({
videoUrl, videoAssetId, leadId,
sessionMode = 'assigned', videoTitle, onSessionComplete,
}: PerceptionPlayerProps) {
const reactId = useId().replace(/:/g, '');
const [sessionId] = useState(
() => globalThis.crypto?.randomUUID?.() ?? `00000000-0000-4000-8000-${reactId.slice(-12).padStart(12, '0')}`,
);
// Refs
const videoRef = useRef<HTMLVideoElement>(null);
const webcamRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const rootRef = useRef<HTMLDivElement>(null); // ← fullscreen target
const rafRef = useRef<number | null>(null);
const lastTs = useRef<number>(0);
const streamRef = useRef<MediaStream | null>(null);
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const scrubbing = useRef(false);
// State — session
const [consentGiven, setConsentGiven] = useState(false);
const [cameraActive, setCameraActive] = useState(false);
const [cameraError, setCameraError] = useState<string | null>(null);
const [sessionActive, setSessionActive] = useState(false);
const [qdScore, setQdScore] = useState(50);
const [showCamera, setShowCamera] = useState(true);
// State — playback
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [volume, setVolume] = useState(1);
const [muted, setMuted] = useState(false);
const [speed, setSpeed] = useState<Speed>(1);
const [isFullscreen, setIsFullscreen] = useState(false);
// State — UI
const [ctrlVisible, setCtrlVisible] = useState(true);
const [cursorHidden, setCursorHidden] = useState(false);
// ── Hooks ─────────────────────────────────────────────────────────────────
const { isReady, isLoading: mpLoading, error: mpError, detectFrame } =
useMediapipeFaceLandmarker();
const { sendPacket } = useVelocitySocket({
channel: 'perception',
onMessage: useCallback((msg: { type: string; data?: Record<string, unknown> }) => {
if (msg.type !== 'QD_UPDATED') return;
const d = msg.data as Partial<QDScoreUpdate>;
const match = (leadId && d.lead_id === leadId) || d.session_id === sessionId;
if (match && typeof d.qd_score === 'number') setQdScore(d.qd_score);
}, [leadId, sessionId]),
});
// ── Auto-hide (YouTube behaviour) ─────────────────────────────────────────
// Show controls, reset the hide timer. After 2.8 s of no movement while
// playing, hide controls and the cursor.
const scheduleHide = useCallback(() => {
if (hideTimer.current) clearTimeout(hideTimer.current);
hideTimer.current = setTimeout(() => {
setCtrlVisible(false);
setCursorHidden(true);
}, 2800);
}, []);
const revealControls = useCallback(() => {
setCtrlVisible(true);
setCursorHidden(false);
if (hideTimer.current) clearTimeout(hideTimer.current);
}, []);
const handleActivity = useCallback(() => {
revealControls();
if (isPlaying) scheduleHide();
}, [isPlaying, revealControls, scheduleHide]);
// When play state changes, re-arm / cancel the timer
useEffect(() => {
revealControls();
if (isPlaying) {
scheduleHide();
} else {
if (hideTimer.current) clearTimeout(hideTimer.current);
}
return () => { if (hideTimer.current) clearTimeout(hideTimer.current); };
}, [isPlaying, revealControls, scheduleHide]);
// ── Camera ────────────────────────────────────────────────────────────────
const startCamera = useCallback(async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: { width: 320, height: 240, facingMode: 'user' },
audio: false,
});
streamRef.current = stream;
if (webcamRef.current) {
webcamRef.current.srcObject = stream;
await webcamRef.current.play();
}
setCameraActive(true);
setCameraError(null);
} catch (err) {
setCameraError(err instanceof Error ? err.message : 'Camera access denied.');
}
}, []);
const stopCamera = useCallback(() => {
streamRef.current?.getTracks().forEach((t) => t.stop());
streamRef.current = null;
if (webcamRef.current) webcamRef.current.srcObject = null;
setCameraActive(false);
}, []);
// ── Perception loop ───────────────────────────────────────────────────────
const perceptionLoop = useCallback(() => {
rafRef.current = requestAnimationFrame(perceptionLoop);
const video = videoRef.current;
const webcam = webcamRef.current;
if (!video || !webcam || !isReady || video.paused || video.ended) return;
const now = performance.now();
if (now - lastTs.current < 500) return;
lastTs.current = now;
const videoTsMs = Math.round(video.currentTime * 1000);
const result = detectFrame(webcam, now);
if (!result?.faceBlendshapes?.length) return;
const categories = result.faceBlendshapes[0]?.categories ?? [];
const packet = encodeLandmarkPacket(categories, videoTsMs, leadId ?? '', sessionId);
if (!hasSignificantActivity(packet)) return;
sendPacket({
event: 'BIOMETRIC_PACKET',
lead_id: leadId, session_id: sessionId,
session_mode: sessionMode, video_asset_id: videoAssetId,
video_ts_ms: packet.video_ts_ms, blend_shapes: packet.blend_shapes,
});
const canvas = canvasRef.current;
if (canvas && result.faceLandmarks?.length) {
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
const lm = result.faceLandmarks[0];
if (lm?.length) {
const pts = [10, 234, 454, 152, 58, 288];
ctx.strokeStyle = 'rgba(59,130,246,0.7)';
ctx.lineWidth = 1.5;
ctx.beginPath();
pts.forEach((idx, i) => {
const p = lm[idx];
if (!p) return;
i === 0 ? ctx.moveTo(p.x * canvas.width, p.y * canvas.height)
: ctx.lineTo(p.x * canvas.width, p.y * canvas.height);
});
ctx.closePath();
ctx.stroke();
}
}
}
}, [detectFrame, isReady, leadId, sendPacket, sessionId, sessionMode, videoAssetId]);
useEffect(() => {
if (sessionActive && cameraActive && isReady) {
rafRef.current = requestAnimationFrame(perceptionLoop);
}
return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); };
}, [cameraActive, isReady, perceptionLoop, sessionActive]);
useEffect(() => () => stopCamera(), [stopCamera]);
// ── Fullscreen ────────────────────────────────────────────────────────────
const toggleFullscreen = useCallback(() => {
const el = rootRef.current;
if (!el) return;
if (!document.fullscreenElement) {
void el.requestFullscreen();
} else {
void document.exitFullscreen();
}
}, []);
useEffect(() => {
const handler = () => setIsFullscreen(!!document.fullscreenElement);
document.addEventListener('fullscreenchange', handler);
return () => document.removeEventListener('fullscreenchange', handler);
}, []);
// ── Video handlers ────────────────────────────────────────────────────────
const handleVideoPlay = () => setIsPlaying(true);
const handleVideoPause = () => setIsPlaying(false);
const handleLoadedMetadata = () => { if (videoRef.current) setDuration(videoRef.current.duration); };
const handleTimeUpdate = () => {
if (!scrubbing.current && videoRef.current) setCurrentTime(videoRef.current.currentTime);
};
const handleVideoEnd = () => {
setIsPlaying(false);
setSessionActive(false);
stopCamera();
void fetch(`${API_URL}/api/sentinel/session/complete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: sessionId, session_mode: sessionMode, lead_id: leadId, final_qd_score: qdScore }),
}).catch(() => null);
onSessionComplete?.(qdScore);
};
// ── Controls ──────────────────────────────────────────────────────────────
const togglePlayPause = () => {
const v = videoRef.current;
if (!v) return;
void (v.paused ? v.play() : v.pause());
};
const handleScrub = (e: React.ChangeEvent<HTMLInputElement>) => {
const t = parseFloat(e.target.value);
setCurrentTime(t);
if (videoRef.current) videoRef.current.currentTime = t;
};
const handleVolumeChange = (val: number) => {
setVolume(val);
setMuted(val === 0);
if (videoRef.current) { videoRef.current.volume = val; videoRef.current.muted = val === 0; }
};
const toggleMute = () => {
const next = !muted;
setMuted(next);
if (videoRef.current) videoRef.current.muted = next;
};
const handleSpeedChange = (s: Speed) => {
setSpeed(s);
if (videoRef.current) videoRef.current.playbackRate = s;
};
const handleConsent = useCallback(async () => {
setConsentGiven(true);
await startCamera();
setSessionActive(true);
}, [startCamera]);
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
// ── Render ────────────────────────────────────────────────────────────────
return (
<>
{/* ── Global player styles (injected once) ───────────────────────── */}
<style>{`
/* Custom range slider — works in normal & fullscreen */
.pp-range {
-webkit-appearance: none;
appearance: none;
border-radius: 99px;
outline: none;
cursor: pointer;
background: linear-gradient(
to right,
rgba(255,255,255,0.9) 0%,
rgba(255,255,255,0.9) var(--rf,0%),
rgba(255,255,255,0.2) var(--rf,0%),
rgba(255,255,255,0.2) 100%
);
transition: height 0.12s ease;
}
.pp-timeline { height: 4px; }
.pp-timeline:hover { height: 6px; }
.pp-volume { height: 3px; }
.pp-range::-webkit-slider-thumb {
-webkit-appearance: none;
border-radius: 50%;
background: #fff;
box-shadow: 0 0 0 2px rgba(59,130,246,0.7);
cursor: pointer;
transition: transform 0.12s;
}
.pp-timeline::-webkit-slider-thumb { width: 14px; height: 14px; }
.pp-volume::-webkit-slider-thumb { width: 10px; height: 10px; }
.pp-range::-webkit-slider-thumb:hover { transform: scale(1.35); }
.pp-range::-moz-range-thumb {
border-radius: 50%;
background: #fff;
border: none;
cursor: pointer;
}
.pp-timeline::-moz-range-thumb { width: 14px; height: 14px; }
.pp-volume::-moz-range-thumb { width: 10px; height: 10px; }
/* Full-screen background always black */
.pp-root:-webkit-full-screen { background: #000; }
.pp-root:-moz-full-screen { background: #000; }
.pp-root:fullscreen { background: #000; }
/* Hide scrollbar in fullscreen */
.pp-root:fullscreen { width: 100vw !important; max-width: 100vw !important; }
`}</style>
{/*
╔══════════════════════════════════════════════════════════════════════╗
║ ROOT — this div is what requestFullscreen() expands. ║
║ EVERYTHING (video, overlays, controls) must be a child of it. ║
╚══════════════════════════════════════════════════════════════════════╝
*/}
<div
ref={rootRef}
className="pp-root relative rounded-xl overflow-hidden mx-auto select-none"
style={{
background: '#000',
border: isFullscreen ? 'none' : '1px solid rgba(255,255,255,0.08)',
boxShadow: isFullscreen ? 'none' : '0 16px 48px rgba(0,0,0,0.7)',
maxWidth: isFullscreen ? '100vw' : 720,
width: '100%',
aspectRatio: '16/9',
cursor: cursorHidden ? 'none' : 'default',
}}
onMouseMove={handleActivity}
onMouseLeave={() => isPlaying && setCtrlVisible(false)}
onTouchStart={handleActivity}
>
{/* ── VIDEO ─────────────────────────────────────────────────────── */}
<video
ref={videoRef}
src={videoUrl}
className="absolute inset-0 w-full h-full object-contain"
onPlay={handleVideoPlay}
onPause={handleVideoPause}
onEnded={handleVideoEnd}
onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={handleLoadedMetadata}
playsInline
onClick={togglePlayPause}
style={{ cursor: 'inherit' }}
/>
{/* ── CONSENT GATE ──────────────────────────────────────────────── */}
<AnimatePresence>
{!consentGiven && (
<motion.div
key="consent"
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
className="absolute inset-0 z-30 flex flex-col items-center justify-center gap-5 px-8 text-center"
style={{ background: 'rgba(0,0,0,0.92)', backdropFilter: 'blur(14px)' }}
>
<div className="w-14 h-14 rounded-2xl flex items-center justify-center"
style={{ background: 'rgba(59,130,246,0.12)', border: '1px solid rgba(59,130,246,0.3)' }}>
<Eye className="w-6 h-6 text-blue-400" />
</div>
<div>
<h3 className="text-base font-semibold text-white mb-2">Enable Perception Mode</h3>
<p className="text-xs text-zinc-400 max-w-xs leading-relaxed">
The Sentinel will analyse your{' '}
<strong className="text-zinc-200">facial expressions</strong> during this walkthrough
to personalise your consultation.
</p>
<p className="text-[10px] text-zinc-500 mt-2 max-w-xs">
No video is recorded. Only anonymised expression data is processed locally.
</p>
</div>
<div className="flex gap-3">
<motion.button
whileHover={{ scale: 1.03 }} whileTap={{ scale: 0.97 }}
onClick={handleConsent}
className="px-5 py-2 rounded-xl text-sm font-semibold text-white"
style={{ background: 'linear-gradient(135deg, #3b82f6, #06b6d4)', boxShadow: '0 0 20px rgba(59,130,246,0.4)' }}
>
<Camera className="w-3.5 h-3.5 inline mr-1.5" />
Allow & Start
</motion.button>
<button
onClick={() => { setConsentGiven(true); setSessionActive(true); }}
className="px-4 py-2 rounded-xl text-sm text-zinc-400 hover:text-white border border-white/10 hover:border-white/20 transition-colors"
>
Skip
</button>
</div>
</motion.div>
)}
</AnimatePresence>
{/* ── QD SCORE badge (top-left) ──────────────────────────────────── */}
{sessionActive && (
<motion.div
initial={{ opacity: 0, x: -10 }} animate={{ opacity: 1, x: 0 }}
className="absolute top-3 left-3 flex items-center gap-1.5 px-2.5 py-1 rounded-xl z-20"
style={{ background: 'rgba(0,0,0,0.7)', backdropFilter: 'blur(10px)', border: '1px solid rgba(255,255,255,0.1)' }}
>
<Activity className="w-3 h-3 text-blue-400" />
<span className="text-[10px] text-zinc-400 font-medium">QD</span>
<QDRing score={qdScore} />
</motion.div>
)}
{/* ── AI STATUS badge (top-right) ────────────────────────────────── */}
<div className="absolute top-3 right-3 flex items-center gap-1.5 px-2 py-1 rounded-lg z-20
border border-white/10 bg-black/60 backdrop-blur-sm">
{mpLoading ? (
<><Loader2 className="w-3 h-3 text-zinc-400 animate-spin" /><span className="text-[10px] text-zinc-500">Loading AI</span></>
) : mpError ? (
<><ShieldAlert className="w-3 h-3 text-amber-400" /><span className="text-[10px] text-amber-400">Perception unavailable</span></>
) : isReady && cameraActive ? (
<><div className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse" /><span className="text-[10px] text-green-400">Perception active</span></>
) : null}
</div>
{/* ── CLICK-TO-PLAY hint ─────────────────────────────────────────── */}
<AnimatePresence>
{!isPlaying && consentGiven && (
<motion.div
key="play-hint"
initial={{ opacity: 0, scale: 0.8 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.8 }}
className="absolute inset-0 flex items-center justify-center pointer-events-none z-10"
>
<div className="w-16 h-16 rounded-full flex items-center justify-center"
style={{ background: 'rgba(0,0,0,0.52)', backdropFilter: 'blur(8px)', border: '1px solid rgba(255,255,255,0.15)' }}>
<Play className="w-6 h-6 text-white ml-1" />
</div>
</motion.div>
)}
</AnimatePresence>
{/* ── WEBCAM PiP ─────────────────────────────────────────────────── */}
<AnimatePresence>
{cameraActive && consentGiven && showCamera && (
<motion.div
key="pip"
initial={{ opacity: 0, scale: 0.8 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.8 }}
className="absolute z-20 rounded-xl overflow-hidden"
style={{
right: 12,
bottom: 72, /* sits above the control bar */
width: 112, height: 80,
border: '1px solid rgba(59,130,246,0.4)',
boxShadow: '0 0 16px rgba(59,130,246,0.25)',
}}
>
<video ref={webcamRef} autoPlay muted playsInline className="w-full h-full object-cover scale-x-[-1]" />
<canvas ref={canvasRef} width={112} height={80} className="absolute inset-0 pointer-events-none" />
</motion.div>
)}
</AnimatePresence>
{/* ── CAMERA ERROR bar ──────────────────────────────────────────── */}
{cameraError && (
<div
className="absolute bottom-0 left-0 right-0 z-30 px-3 py-2 flex items-center gap-2"
style={{ background: 'rgba(239,68,68,0.15)', borderTop: '1px solid rgba(239,68,68,0.25)' }}
>
<ShieldAlert className="w-3.5 h-3.5 text-red-400 shrink-0" />
<p className="text-[11px] text-red-400">{cameraError}</p>
</div>
)}
{/*
╔═══════════════════════════════════════════════════════════════════╗
║ CONTROL BAR — absolutely positioned at the BOTTOM of the root. ║
║ Lives inside the fullscreen element → visible in fullscreen. ║
║ Auto-hides via opacity + pointer-events (YouTube style). ║
╚═══════════════════════════════════════════════════════════════════╝
*/}
<motion.div
animate={{ opacity: ctrlVisible ? 1 : 0 }}
transition={{ duration: 0.22, ease: 'easeInOut' }}
className="absolute bottom-0 left-0 right-0 z-30 px-3 pt-8 pb-3"
style={{
/* Gradient scrim so controls are always readable over the video */
background: 'linear-gradient(to top, rgba(0,0,0,0.88) 0%, rgba(0,0,0,0.5) 55%, transparent 100%)',
pointerEvents: ctrlVisible ? 'auto' : 'none',
}}
onMouseEnter={revealControls} /* hovering controls re-reveals them */
>
{/* ── Timeline row ──────────────────────────────────────────────── */}
<div className="flex items-center gap-2 mb-2.5">
<span className="text-[10px] text-zinc-300 font-mono tabular-nums w-8 text-right shrink-0">
{fmt(currentTime)}
</span>
<input
type="range"
min={0} max={duration || 100} step={0.1} value={currentTime}
onChange={handleScrub}
onMouseDown={() => { scrubbing.current = true; revealControls(); }}
onMouseUp={() => { scrubbing.current = false; }}
className="pp-range pp-timeline flex-1"
style={{ '--rf': `${progress}%` } as React.CSSProperties}
/>
<span className="text-[10px] text-zinc-500 font-mono tabular-nums w-8 shrink-0">
{fmt(duration)}
</span>
</div>
{/* ── Button row ────────────────────────────────────────────────── */}
<div className="flex items-center gap-2">
{/* Play / Pause */}
<button
onClick={togglePlayPause}
title={isPlaying ? 'Pause (Space)' : 'Play (Space)'}
className="w-7 h-7 flex items-center justify-center rounded-lg bg-white/10 hover:bg-white/22 transition-colors shrink-0"
>
{isPlaying
? <Pause className="w-3.5 h-3.5 text-white" />
: <Play className="w-3.5 h-3.5 text-white ml-0.5" />}
</button>
{/* Volume */}
<VolumeControl
volume={volume} muted={muted}
onVolume={handleVolumeChange} onToggleMute={toggleMute}
/>
{/* Title */}
{videoTitle && (
<span className="text-[11px] text-white/55 font-medium flex-1 truncate mx-1 hidden sm:block">
{videoTitle}
</span>
)}
{!videoTitle && <span className="flex-1" />}
{/* Speed */}
<SpeedMenu current={speed} onChange={handleSpeedChange} />
{/* Camera toggle */}
<button
onClick={() => setShowCamera((v) => !v)}
title={showCamera ? 'Hide camera' : 'Show camera'}
className="w-7 h-7 flex items-center justify-center rounded-lg bg-white/10 hover:bg-white/20 transition-colors shrink-0"
>
{showCamera
? <Camera className="w-3.5 h-3.5 text-white" />
: <CameraOff className="w-3.5 h-3.5 text-white/40" />}
</button>
{/* Fullscreen */}
<button
onClick={toggleFullscreen}
title={isFullscreen ? 'Exit fullscreen (F)' : 'Fullscreen (F)'}
className="w-7 h-7 flex items-center justify-center rounded-lg bg-white/10 hover:bg-white/20 transition-colors shrink-0"
>
{isFullscreen
? <Minimize className="w-3.5 h-3.5 text-white" />
: <Maximize className="w-3.5 h-3.5 text-white" />}
</button>
</div>
</motion.div>
</div>
</>
);
}

View File

@@ -0,0 +1,867 @@
import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
User,
Zap,
Play,
ArrowLeft,
Monitor,
Users,
CheckCircle2,
AlertCircle,
ChevronRight,
Shuffle,
Upload,
FileSpreadsheet,
Loader2,
Search,
Film,
} from 'lucide-react';
import { useStore } from '@/store/useStore';
import { PerceptionPlayer } from '@/components/modules/sentinel/PerceptionPlayer';
import { API_URL } from '@/lib/api';
import { cn } from '@/lib/utils';
import type { Lead, MarketingVideo } from '@/types';
// ── Local static video catalog (served from public/videos/) ──────────────────
// Used as primary source when the remote backend is unreachable.
const LOCAL_VIDEOS: MarketingVideo[] = [
{
id: 'eden-devprayag',
title: 'Eden Devprayag — Landmark Riverfront Living',
property_name: 'Eden Devprayag',
unit_number: '3-4BHK',
type: 'Property Walkthrough',
duration_seconds: 0,
video_url: '/videos/eden-devprayag.mp4',
thumbnail_color: '#3b82f6',
},
{
id: 'sugam-prakriti',
title: 'Sugam Prakriti — Nature-Inspired Urban Homes',
property_name: 'Sugam Prakriti',
unit_number: '2-3BHK',
type: 'Property Walkthrough',
duration_seconds: 0,
video_url: '/videos/sugam-prakriti.mp4',
thumbnail_color: '#10b981',
},
{
id: 'atri-aqua',
title: 'Atri Aqua — Premium Waterside Residences',
property_name: 'Atri Aqua',
unit_number: '2-3BHK',
type: 'Property Walkthrough',
duration_seconds: 0,
video_url: '/videos/atri-aqua.mp4',
thumbnail_color: '#06b6d4',
},
{
id: 'atri-suryatoron',
title: 'Atri Suryatoron — Sun-Drenched Elevated Living',
property_name: 'Atri Suryatoron',
unit_number: '2-3BHK',
type: 'Property Walkthrough',
duration_seconds: 0,
video_url: '/videos/atri-suryatoron.mp4',
thumbnail_color: '#f59e0b',
},
];
/**
* Resolve the full playback URL for a video.
* Local catalog videos (starting with /videos/) are served directly by Vite
* from public/. Remote videos from the backend already carry a full /assets/… path
* that goes through the Vite proxy.
*/
function resolveVideoUrl(video_url: string): string {
if (video_url.startsWith('/videos/')) {
// Served from public/ by Vite — no proxy prefixing needed.
return video_url;
}
// Remote asset — prefix with API_URL so it routes through the Vite /assets proxy.
return `${API_URL}${video_url}`;
}
type SessionMode = 'assigned' | 'auto';
type Step = 'select-video' | 'select-mode' | 'select-lead' | 'session' | 'summary';
function apiAuthHeaders() {
const token = localStorage.getItem('velocity-api-token');
const headers: Record<string, string> = {};
if (token) {
headers.Authorization = `Bearer ${token}`;
}
return headers;
}
function formatDuration(totalSeconds: number) {
const mins = Math.floor(totalSeconds / 60);
const secs = totalSeconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
function VideoCard({
video,
isSelected,
onClick,
}: {
video: MarketingVideo;
isSelected: boolean;
onClick: () => void;
}) {
const videoRef = useRef<HTMLVideoElement | null>(null);
const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// isPreviewing tracks whether we are actively playing a hover-preview.
const [isPreviewing, setIsPreviewing] = useState(false);
// srcLoaded tracks whether we have set the src attribute yet (lazy load).
const [srcLoaded, setSrcLoaded] = useState(false);
const [isReady, setIsReady] = useState(false);
const resolvedSrc = resolveVideoUrl(video.video_url);
// Stop playback and reset state. Keep src so the browser can reuse the
// cached data next time the card is hovered.
const stopPreview = useCallback(() => {
if (hoverTimerRef.current) {
clearTimeout(hoverTimerRef.current);
hoverTimerRef.current = null;
}
const el = videoRef.current;
setIsPreviewing(false);
if (el) {
el.pause();
// Snap back to a good thumbnail frame without forcing a full seek.
if (isFinite(el.duration) && el.duration > 0) {
el.currentTime = Math.min(5, el.duration * 0.05);
}
}
}, []);
// On mouse-enter: lazily assign src for the first time, then play after a
// short delay so rapid hover-throughs don't start loading all videos at once.
const startPreview = useCallback(() => {
const el = videoRef.current;
if (!el) return;
// First hover — assign src and set preload to auto so the browser starts
// buffering just this one video.
if (!srcLoaded) {
el.src = resolvedSrc;
el.preload = 'auto';
setSrcLoaded(true);
}
// Small delay so a quick mouse-over doesn't trigger playback.
hoverTimerRef.current = setTimeout(() => {
if (!el) return;
el.muted = true;
el.playbackRate = 1.0;
void el.play().then(() => setIsPreviewing(true)).catch(() => null);
}, 350);
}, [srcLoaded, resolvedSrc]);
// Cleanup on unmount.
useEffect(() => {
return () => {
if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current);
const el = videoRef.current;
if (el) {
el.pause();
el.removeAttribute('src');
el.load();
}
};
}, []);
return (
<motion.button
whileHover={{ scale: 1.015, y: -2 }}
whileTap={{ scale: 0.985 }}
onClick={onClick}
onMouseEnter={startPreview}
onMouseLeave={stopPreview}
className={cn(
'relative flex flex-col gap-3 p-3 rounded-2xl text-left transition-all w-full',
isSelected ? 'ring-2 ring-blue-500' : 'hover:bg-white/[0.04]',
)}
style={{
background: isSelected ? 'rgba(59,130,246,0.12)' : 'rgba(255,255,255,0.03)',
border: isSelected ? '1px solid rgba(59,130,246,0.4)' : '1px solid rgba(255,255,255,0.07)',
}}
>
<div
className="w-full aspect-video rounded-xl flex items-center justify-center relative overflow-hidden"
style={{ background: `linear-gradient(135deg, ${video.thumbnail_color}22, ${video.thumbnail_color}44)` }}
>
{/*
The <video> element is always rendered but starts with NO src (preload=none).
src is assigned lazily on first hover via startPreview().
This prevents the browser from fetching all 4 video files on mount.
*/}
<video
ref={videoRef}
muted
playsInline
preload="none"
className={cn(
'absolute inset-0 h-full w-full object-cover transition-opacity duration-300',
isReady ? 'opacity-100' : 'opacity-0',
)}
onLoadedData={(e) => {
const el = e.currentTarget;
setIsReady(true);
if (isFinite(el.duration) && el.duration > 0) {
el.currentTime = Math.min(5, el.duration * 0.05);
}
}}
/>
<div
className="absolute inset-0 pointer-events-none"
style={{ background: `radial-gradient(ellipse at center, ${video.thumbnail_color}25 0%, rgba(8,10,18,0.2) 62%, rgba(8,10,18,0.55) 100%)` }}
/>
{/* Placeholder icon shown until the first hover loads the video */}
{!isReady && (
<div className="absolute inset-0 flex items-center justify-center">
<Film className="w-7 h-7" style={{ color: video.thumbnail_color, opacity: 0.6 }} />
</div>
)}
<div
className="absolute bottom-2 right-2 px-2 py-0.5 rounded-md text-[10px] font-medium"
style={{ background: 'rgba(0,0,0,0.68)', color: '#dbeafe' }}
>
{video.duration_seconds > 0 ? formatDuration(video.duration_seconds) : 'Video'}
</div>
{isPreviewing && (
<div className="absolute top-2 left-2 px-2 py-0.5 rounded-md text-[10px] font-semibold bg-cyan-500/20 border border-cyan-400/30 text-cyan-300">
Preview
</div>
)}
{isSelected && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-10 h-10 rounded-full bg-blue-500/20 flex items-center justify-center backdrop-blur-md">
<CheckCircle2 className="w-5 h-5 text-blue-300" />
</div>
</div>
)}
</div>
<div>
<p className="text-sm font-semibold text-white leading-tight line-clamp-2">{video.title}</p>
<p className="text-[11px] text-zinc-500 mt-0.5">{video.type} · {video.property_name}</p>
</div>
</motion.button>
);
}
function LeadRow({
lead,
isSelected,
onClick,
}: {
lead: Lead;
isSelected: boolean;
onClick: () => void;
}) {
const initials = lead.name.split(' ').map((n) => n[0]).join('').slice(0, 2);
const qd = lead.quantumDynamicsScore;
return (
<motion.button
whileHover={{ x: 4 }}
onClick={onClick}
className={cn(
'flex items-center gap-3 w-full px-4 py-3 rounded-xl transition-all text-left',
isSelected ? 'ring-1 ring-blue-500' : 'hover:bg-white/[0.04]',
)}
style={{
background: isSelected ? 'rgba(59,130,246,0.10)' : 'rgba(255,255,255,0.02)',
border: `1px solid ${isSelected ? 'rgba(59,130,246,0.3)' : 'rgba(255,255,255,0.06)'}`,
}}
>
<div
className="w-9 h-9 rounded-xl flex items-center justify-center text-xs font-bold flex-shrink-0"
style={{ background: 'rgba(59,130,246,0.15)', color: '#60a5fa' }}
>
{initials}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-white truncate">{lead.name}</p>
<p className="text-[11px] text-zinc-500">{lead.interest ?? lead.phone ?? 'No interest specified'}</p>
</div>
<div className="flex gap-1 flex-shrink-0">
{lead.tags?.map((tag) => (
<span
key={tag}
className="text-[9px] px-1.5 py-0.5 rounded-md font-bold uppercase tracking-wide"
style={{ background: 'rgba(251,191,36,0.15)', color: '#fbbf24', border: '1px solid rgba(251,191,36,0.2)' }}
>
{tag}
</span>
))}
{qd !== undefined && (
<span
className="text-[9px] px-1.5 py-0.5 rounded-md font-bold"
style={{
background: qd >= 75 ? 'rgba(34,197,94,0.15)' : 'rgba(59,130,246,0.15)',
color: qd >= 75 ? '#4ade80' : '#60a5fa',
border: `1px solid ${qd >= 75 ? 'rgba(34,197,94,0.3)' : 'rgba(59,130,246,0.3)'}`,
}}
>
QD {qd}
</span>
)}
</div>
{isSelected && <ChevronRight className="w-4 h-4 text-blue-400 flex-shrink-0" />}
</motion.button>
);
}
function SessionSummary({
lead,
finalQdScore,
sessionMode,
onNewSession,
}: {
lead?: Lead;
finalQdScore: number;
sessionMode: SessionMode;
onNewSession: () => void;
}) {
const qColor = finalQdScore >= 75 ? '#22c55e' : finalQdScore >= 50 ? '#3b82f6' : '#f59e0b';
const verdict =
finalQdScore >= 80 ? 'Strongly engaged - priority follow-up' :
finalQdScore >= 65 ? 'Engaged - schedule viewing' :
finalQdScore >= 45 ? 'Neutral - send brochure' :
'Low interest - reassess approach';
return (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-6 py-10 px-8 max-w-md mx-auto text-center"
>
<div className="relative w-28 h-28">
<svg width="112" height="112" className="-rotate-90">
<circle cx="56" cy="56" r="46" fill="none" stroke="rgba(255,255,255,0.06)" strokeWidth="8" />
<motion.circle
cx="56"
cy="56"
r="46"
fill="none"
stroke={qColor}
strokeWidth="8"
strokeLinecap="round"
strokeDasharray={2 * Math.PI * 46}
initial={{ strokeDashoffset: 2 * Math.PI * 46 }}
animate={{ strokeDashoffset: 2 * Math.PI * 46 * (1 - finalQdScore / 100) }}
transition={{ duration: 1.2, ease: 'easeOut', delay: 0.3 }}
style={{ filter: `drop-shadow(0 0 10px ${qColor})` }}
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-3xl font-bold" style={{ color: qColor }}>{finalQdScore}</span>
<span className="text-[10px] text-zinc-500 font-medium">QD SCORE</span>
</div>
</div>
<div>
<h3 className="text-xl font-bold text-white mb-1">Session Complete</h3>
{lead && <p className="text-sm text-zinc-400 mb-2">Client: {lead.name}</p>}
{sessionMode === 'auto' && (
<div
className="flex items-center gap-2 justify-center mb-3 px-4 py-2 rounded-xl"
style={{ background: 'rgba(251,191,36,0.1)', border: '1px solid rgba(251,191,36,0.2)' }}
>
<AlertCircle className="w-4 h-4 text-amber-400" />
<span className="text-xs text-amber-400">Auto mode finalized and lead match requested</span>
</div>
)}
<p className="text-sm font-semibold" style={{ color: qColor }}>{verdict}</p>
</div>
<motion.button
whileHover={{ scale: 1.03 }}
whileTap={{ scale: 0.97 }}
onClick={onNewSession}
className="px-6 py-2.5 rounded-xl text-sm font-semibold text-white"
style={{ background: 'linear-gradient(135deg, #3b82f6, #06b6d4)', boxShadow: '0 0 20px rgba(59,130,246,0.3)' }}
>
New Session
</motion.button>
</motion.div>
);
}
export function SentinelLiveSession() {
const { leads } = useStore();
const [step, setStep] = useState<Step>('select-video');
const [videos, setVideos] = useState<MarketingVideo[]>([]);
const [videosLoading, setVideosLoading] = useState(true);
const [videosError, setVideosError] = useState<string>('');
const [selectedVideo, setSelectedVideo] = useState<MarketingVideo | null>(null);
const [sessionMode, setSessionMode] = useState<SessionMode>('assigned');
const [selectedLead, setSelectedLead] = useState<Lead | null>(null);
const [leadSearch, setLeadSearch] = useState('');
const [finalQdScore, setFinalQdScore] = useState(50);
const [sceneFile, setSceneFile] = useState<File | null>(null);
const [sceneUploadState, setSceneUploadState] = useState<'idle' | 'uploading' | 'done' | 'error'>('idle');
const [sceneMessage, setSceneMessage] = useState<string>('');
useEffect(() => {
let cancelled = false;
const loadVideos = async () => {
setVideosLoading(true);
setVideosError('');
try {
// Try the remote backend first (5-second timeout so we fail fast when offline).
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
const response = await fetch(`${API_URL}/api/videos/marketing`, {
signal: controller.signal,
});
clearTimeout(timeout);
if (!response.ok) {
throw new Error(`Failed to load videos (${response.status})`);
}
const data = await response.json() as { videos?: MarketingVideo[] };
if (!cancelled) {
const nextVideos = data.videos ?? [];
// If the backend returned nothing, fall back to the local catalog.
const resolved = nextVideos.length > 0 ? nextVideos : LOCAL_VIDEOS;
setVideos(resolved);
setSelectedVideo((current) => current ?? resolved[0] ?? null);
}
} catch {
// Backend unreachable — silently serve the local static catalog.
if (!cancelled) {
setVideos(LOCAL_VIDEOS);
setSelectedVideo((current) => current ?? LOCAL_VIDEOS[0] ?? null);
}
} finally {
if (!cancelled) {
setVideosLoading(false);
}
}
};
void loadVideos();
return () => {
cancelled = true;
};
}, []);
const selectedVideoSceneStatus = useMemo(() => {
if (!selectedVideo) {
return 'No video selected';
}
if (sceneUploadState === 'done') {
return sceneMessage || 'Scene map uploaded';
}
if (sceneUploadState === 'error') {
return sceneMessage || 'Scene upload failed';
}
return sceneMessage || 'No scene CSV uploaded in this session';
}, [sceneMessage, sceneUploadState, selectedVideo]);
const filteredLeads = useMemo(() => {
const query = leadSearch.trim().toLowerCase();
if (!query) {
return leads;
}
return leads.filter((lead) => {
const haystack = [
lead.name,
lead.phone,
lead.interest,
lead.budget,
...(lead.tags ?? []),
]
.filter(Boolean)
.join(' ')
.toLowerCase();
return haystack.includes(query);
});
}, [leadSearch, leads]);
const handleVideoSelect = (video: MarketingVideo) => setSelectedVideo(video);
const handleVideoConfirm = () => {
if (selectedVideo) {
setStep('select-mode');
}
};
const handleModeSelect = (mode: SessionMode) => {
setSessionMode(mode);
if (mode === 'assigned') {
setStep('select-lead');
return;
}
setSelectedLead(null);
setStep('session');
};
const handleLeadConfirm = () => {
if (selectedLead) {
setStep('session');
}
};
const handleSessionComplete = useCallback((score: number) => {
setFinalQdScore(score);
setStep('summary');
}, []);
const handleNewSession = () => {
setStep('select-video');
setSelectedVideo(videos[0] ?? null);
setSelectedLead(null);
setLeadSearch('');
setFinalQdScore(50);
setSceneFile(null);
setSceneUploadState('idle');
setSceneMessage('');
};
const handleSceneUpload = async () => {
if (!selectedVideo || !sceneFile) {
return;
}
setSceneUploadState('uploading');
setSceneMessage('');
const form = new FormData();
form.append('file', sceneFile);
try {
const response = await fetch(`${API_URL}/api/scenes/upload?video_asset_id=${encodeURIComponent(selectedVideo.id)}`, {
method: 'POST',
headers: apiAuthHeaders(),
body: form,
});
if (!response.ok) {
const detail = await response.text();
throw new Error(detail || 'Upload failed');
}
const data = await response.json() as { row_count?: number };
setSceneUploadState('done');
setSceneMessage(`Uploaded ${data.row_count ?? 0} scene rows for ${selectedVideo.property_name}`);
} catch (error) {
setSceneUploadState('error');
setSceneMessage(error instanceof Error ? error.message : 'Upload failed');
}
};
const goBack = () => {
if (step === 'select-mode') {
setStep('select-video');
} else if (step === 'select-lead') {
setStep('select-mode');
} else if (step === 'session') {
setStep('select-mode');
}
};
return (
<div
className="rounded-2xl overflow-hidden"
style={{
background: 'rgba(255,255,255,0.02)',
border: '1px solid rgba(255,255,255,0.07)',
minHeight: 500,
}}
>
<div
className="flex items-center gap-3 px-5 py-4"
style={{ borderBottom: '1px solid rgba(255,255,255,0.06)' }}
>
{step !== 'select-video' && step !== 'summary' && (
<button
onClick={goBack}
className="p-1.5 rounded-lg text-zinc-500 hover:text-white hover:bg-white/5 transition-colors"
>
<ArrowLeft className="w-4 h-4" />
</button>
)}
<div className="flex items-center gap-2">
<div
className="w-7 h-7 rounded-lg flex items-center justify-center"
style={{ background: 'rgba(59,130,246,0.15)', border: '1px solid rgba(59,130,246,0.25)' }}
>
<Monitor className="w-3.5 h-3.5 text-blue-400" />
</div>
<div>
<h2 className="text-sm font-semibold text-white">Live Perception Session</h2>
<p className="text-[10px] text-zinc-500">
{step === 'select-video' && 'Select a property video and optional scene CSV'}
{step === 'select-mode' && `Selected: ${selectedVideo?.title}`}
{step === 'select-lead' && 'Choose a client from your CRM'}
{step === 'session' && (sessionMode === 'assigned' ? `Assigned: ${selectedLead?.name}` : 'Auto mode - gathering visitor intelligence')}
{step === 'summary' && 'Session Summary'}
</p>
</div>
</div>
<div className="ml-auto flex items-center gap-1.5">
{(['select-video', 'select-mode', 'select-lead', 'session'] as Step[]).map((s, i) => (
<div
key={s}
className="w-1.5 h-1.5 rounded-full transition-colors"
style={{
background:
step === s ? '#3b82f6' :
['select-video', 'select-mode', 'select-lead', 'session'].indexOf(step) > i
? 'rgba(59,130,246,0.4)' : 'rgba(255,255,255,0.15)',
}}
/>
))}
</div>
</div>
<div className="p-5">
<AnimatePresence mode="wait">
{step === 'select-video' && (
<motion.div
key="select-video"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.2 }}
className="space-y-5"
>
{videosLoading ? (
<div className="rounded-2xl p-8 border border-white/10 bg-white/[0.02] flex items-center justify-center gap-3 text-zinc-400">
<Loader2 className="w-4 h-4 animate-spin" />
Loading marketing videos
</div>
) : videosError ? (
<div className="rounded-2xl p-5 border border-red-500/20 bg-red-500/5 text-sm text-red-300">
{videosError}
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-3">
{videos.map((video) => (
<VideoCard
key={video.id}
video={video}
isSelected={selectedVideo?.id === video.id}
onClick={() => handleVideoSelect(video)}
/>
))}
</div>
)}
<div
className="rounded-2xl p-4"
style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.07)' }}
>
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-sm font-semibold text-white">Scene CSV Upload</p>
<p className="text-xs text-zinc-400 mt-1">
Upload `scene_no,start_ms,end_ms,room_type,description` so the backend can attach scene labels to biometric packets.
</p>
</div>
<div className="flex items-center gap-2 text-[11px] text-zinc-400">
<FileSpreadsheet className="w-4 h-4 text-cyan-400" />
{selectedVideo ? selectedVideoSceneStatus : 'Select a video first'}
</div>
</div>
<div className="mt-4 flex flex-col gap-3 md:flex-row md:items-center">
<label
className="flex-1 rounded-xl border border-dashed border-white/15 px-4 py-3 text-sm text-zinc-400 cursor-pointer hover:border-cyan-400/40 hover:text-white transition-colors"
>
<input
type="file"
accept=".csv,text/csv"
className="hidden"
onChange={(event) => setSceneFile(event.target.files?.[0] ?? null)}
/>
{sceneFile ? sceneFile.name : 'Choose scene CSV'}
</label>
<motion.button
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
onClick={handleSceneUpload}
disabled={!selectedVideo || !sceneFile || sceneUploadState === 'uploading'}
className="px-4 py-3 rounded-xl text-sm font-semibold text-white disabled:opacity-40 disabled:cursor-not-allowed flex items-center justify-center gap-2 min-w-[180px]"
style={{ background: 'linear-gradient(135deg, #0891b2, #2563eb)' }}
>
{sceneUploadState === 'uploading' ? <Loader2 className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />}
Upload Scene Map
</motion.button>
</div>
</div>
<motion.button
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
disabled={!selectedVideo}
onClick={handleVideoConfirm}
className="w-full py-3 rounded-xl text-sm font-semibold text-white transition-all disabled:opacity-40 disabled:cursor-not-allowed flex items-center justify-center gap-2"
style={{
background: selectedVideo ? 'linear-gradient(135deg, #3b82f6, #06b6d4)' : 'rgba(255,255,255,0.06)',
boxShadow: selectedVideo ? '0 0 20px rgba(59,130,246,0.3)' : 'none',
}}
>
<Play className="w-4 h-4" />
Continue with {selectedVideo?.property_name ?? 'selected video'}
</motion.button>
</motion.div>
)}
{step === 'select-mode' && (
<motion.div
key="select-mode"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.2 }}
className="flex flex-col gap-3"
>
<p className="text-sm text-zinc-400 mb-2">How would you like to start this session?</p>
<motion.button
whileHover={{ scale: 1.01, x: 4 }}
whileTap={{ scale: 0.99 }}
onClick={() => handleModeSelect('assigned')}
className="flex items-start gap-4 p-5 rounded-2xl text-left"
style={{ background: 'rgba(59,130,246,0.08)', border: '1px solid rgba(59,130,246,0.2)' }}
>
<div className="w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0" style={{ background: 'rgba(59,130,246,0.15)' }}>
<Users className="w-5 h-5 text-blue-400" />
</div>
<div className="flex-1">
<p className="text-sm font-semibold text-white">Assigned Mode</p>
<p className="text-xs text-zinc-400 mt-1 leading-relaxed">
Select an existing client from your CRM before starting. All biometric data is linked to their profile in real time.
</p>
</div>
<ChevronRight className="w-4 h-4 text-zinc-500 flex-shrink-0 mt-3" />
</motion.button>
<motion.button
whileHover={{ scale: 1.01, x: 4 }}
whileTap={{ scale: 0.99 }}
onClick={() => handleModeSelect('auto')}
className="flex items-start gap-4 p-5 rounded-2xl text-left"
style={{ background: 'rgba(245,158,11,0.08)', border: '1px solid rgba(245,158,11,0.2)' }}
>
<div className="w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0" style={{ background: 'rgba(245,158,11,0.15)' }}>
<Zap className="w-5 h-5 text-amber-400" />
</div>
<div className="flex-1">
<p className="text-sm font-semibold text-white">Auto Mode</p>
<p className="text-xs text-zinc-400 mt-1 leading-relaxed">
Start immediately. The Sentinel gathers face, plate, and vehicle data from entry CCTV and matches or creates a lead after the session.
</p>
<div className="flex items-center gap-1.5 mt-2">
<Shuffle className="w-3 h-3 text-amber-500" />
<span className="text-[10px] text-amber-500 font-medium">Requires CCTV integration active</span>
</div>
</div>
<ChevronRight className="w-4 h-4 text-zinc-500 flex-shrink-0 mt-3" />
</motion.button>
</motion.div>
)}
{step === 'select-lead' && (
<motion.div
key="select-lead"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.2 }}
className="space-y-4"
>
<div className="relative">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500" />
<input
value={leadSearch}
onChange={(event) => setLeadSearch(event.target.value)}
placeholder="Search by client name, phone, interest, budget or tags"
className="w-full rounded-xl bg-white/[0.03] border border-white/10 pl-10 pr-4 py-3 text-sm text-white placeholder:text-zinc-500 outline-none focus:border-blue-500/40"
/>
</div>
<div className="flex flex-col gap-2 max-h-72 overflow-y-auto custom-scrollbar mb-4">
{filteredLeads.map((lead) => (
<LeadRow
key={lead.id}
lead={lead}
isSelected={selectedLead?.id === lead.id}
onClick={() => setSelectedLead(lead)}
/>
))}
{filteredLeads.length === 0 && (
<div className="rounded-xl border border-white/8 bg-white/[0.02] px-4 py-8 text-center text-sm text-zinc-500">
No client matches that search.
</div>
)}
</div>
<motion.button
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
disabled={!selectedLead}
onClick={handleLeadConfirm}
className="w-full py-3 rounded-xl text-sm font-semibold text-white flex items-center justify-center gap-2 disabled:opacity-40 disabled:cursor-not-allowed"
style={{
background: selectedLead ? 'linear-gradient(135deg, #3b82f6, #06b6d4)' : 'rgba(255,255,255,0.06)',
boxShadow: selectedLead ? '0 0 20px rgba(59,130,246,0.3)' : 'none',
}}
>
<User className="w-4 h-4" />
Start Session with {selectedLead?.name ?? 'selected client'}
</motion.button>
</motion.div>
)}
{step === 'session' && selectedVideo && (
<motion.div
key="session"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
>
{sessionMode === 'auto' && (
<motion.div
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
className="flex items-center gap-2 px-4 py-2.5 rounded-xl mb-4"
style={{ background: 'rgba(245,158,11,0.08)', border: '1px solid rgba(245,158,11,0.2)' }}
>
<Zap className="w-4 h-4 text-amber-400" />
<p className="text-xs text-amber-300 font-medium">
Auto mode active - CCTV data can be attached to this session and the backend will finalize lead matching when the video ends.
</p>
</motion.div>
)}
<PerceptionPlayer
videoUrl={resolveVideoUrl(selectedVideo.video_url)}
videoAssetId={selectedVideo.id}
leadId={sessionMode === 'assigned' ? selectedLead?.id : undefined}
sessionMode={sessionMode}
videoTitle={selectedVideo.title}
onSessionComplete={handleSessionComplete}
/>
</motion.div>
)}
{step === 'summary' && (
<motion.div
key="summary"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<SessionSummary
lead={selectedLead ?? undefined}
finalQdScore={finalQdScore}
sessionMode={sessionMode}
onNewSession={handleNewSession}
/>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
);
}