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,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>
);
}