Built the Sentinel Tab
This commit is contained in:
309
app/src/components/layout/NotificationCenter.tsx
Normal file
309
app/src/components/layout/NotificationCenter.tsx
Normal 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
756
app/src/components/modules/sentinel/PerceptionPlayer.tsx
Normal file
756
app/src/components/modules/sentinel/PerceptionPlayer.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
867
app/src/components/modules/sentinel/SentinelLiveSession.tsx
Normal file
867
app/src/components/modules/sentinel/SentinelLiveSession.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user