feat: Oracle CRM Page, Synthetic Client Data and Live Snapshot when hitting emotion hotpoint
This commit is contained in:
@@ -2,7 +2,12 @@ import { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Scan, Mail, Lock } from 'lucide-react';
|
||||
import { useStore } from '@/store/useStore';
|
||||
import { clearVelocityToken, loginVelocity, normalizeVelocityRole } from '@/lib/velocityPlatformClient';
|
||||
import {
|
||||
clearVelocityToken,
|
||||
loginVelocity,
|
||||
normalizeVelocityRole,
|
||||
type VelocityUserProfile,
|
||||
} from '@/lib/velocityPlatformClient';
|
||||
|
||||
export function LoginScreen() {
|
||||
const { login } = useStore();
|
||||
@@ -19,7 +24,7 @@ export function LoginScreen() {
|
||||
const me = await loginVelocity(email.trim(), password);
|
||||
login({
|
||||
id: me.user_id,
|
||||
name: me.user_id,
|
||||
name: resolveVelocityDisplayName(me),
|
||||
role: normalizeVelocityRole(me.role),
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -167,3 +172,17 @@ export function LoginScreen() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function resolveVelocityDisplayName(profile: VelocityUserProfile) {
|
||||
const fullName = profile.full_name?.trim();
|
||||
if (fullName) {
|
||||
return fullName;
|
||||
}
|
||||
|
||||
const email = profile.email?.trim();
|
||||
if (email) {
|
||||
return email;
|
||||
}
|
||||
|
||||
return profile.user_id;
|
||||
}
|
||||
|
||||
@@ -41,15 +41,21 @@ import { useVelocitySocket } from '@/hooks/useVelocitySocket';
|
||||
import { encodeLandmarkPacket, hasSignificantActivity } from '@/utils/landmarkPacketEncoder';
|
||||
import { API_URL } from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useStore } from '@/store/useStore';
|
||||
import type { Lead, QDScoreUpdate } from '@/types';
|
||||
import type { QDScoreUpdate } from '@/types';
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface PerceptionPlayerProps {
|
||||
videoUrl: string;
|
||||
videoAssetId?: string;
|
||||
personId?: string;
|
||||
canonicalLeadId?: string;
|
||||
leadId?: string;
|
||||
leadName?: string;
|
||||
leadBudget?: string;
|
||||
leadInterest?: string;
|
||||
priorInteractionCount?: number;
|
||||
initialQdScore?: number;
|
||||
sessionMode?: 'assigned' | 'auto';
|
||||
videoTitle?: string;
|
||||
onSessionComplete?: (finalQdScore: number) => void;
|
||||
@@ -214,11 +220,17 @@ function buildScoreMap(categories: Array<{ categoryName: string; score: number }
|
||||
return map;
|
||||
}
|
||||
|
||||
function deriveInitialSessionScore(lead?: Lead | null, priorInteractionCount = 0): number {
|
||||
const baseScore = lead?.quantumDynamicsScore ?? 50;
|
||||
function deriveInitialSessionScore(input?: {
|
||||
qdScore?: number | null;
|
||||
budget?: string | null;
|
||||
interest?: string | null;
|
||||
tags?: string[];
|
||||
}, priorInteractionCount = 0): number {
|
||||
const baseScore = input?.qdScore ?? 50;
|
||||
let modifier = 0;
|
||||
const budget = (lead?.budget ?? '').toLowerCase();
|
||||
const tags = (lead?.tags ?? []).map((tag) => tag.toLowerCase());
|
||||
const budget = (input?.budget ?? '').toLowerCase();
|
||||
const interest = (input?.interest ?? '').toLowerCase();
|
||||
const tags = (input?.tags ?? []).map((tag) => tag.toLowerCase());
|
||||
|
||||
if (/(10m|15m|20m|crore|million)/.test(budget)) {
|
||||
modifier += 15;
|
||||
@@ -228,6 +240,7 @@ function deriveInitialSessionScore(lead?: Lead | null, priorInteractionCount = 0
|
||||
|
||||
if (tags.includes('hni')) modifier += 12;
|
||||
if (tags.includes('nri')) modifier += 5;
|
||||
if (/(waterfront|bungalow|penthouse|luxury|premium)/.test(interest)) modifier += 4;
|
||||
|
||||
if (baseScore > 75) {
|
||||
modifier += 8;
|
||||
@@ -259,19 +272,44 @@ function calculateLocalQdScore(previousScore: number, scoreMap: Record<string, n
|
||||
const eyeBlinkLeft = scoreMap.eyeBlinkLeft ?? 0;
|
||||
const eyeBlinkRight = scoreMap.eyeBlinkRight ?? 0;
|
||||
const mouthFrown = Math.max(scoreMap.mouthFrownLeft ?? 0, scoreMap.mouthFrownRight ?? 0);
|
||||
const mouthPress = Math.max(scoreMap.lipsPressLeft ?? 0, scoreMap.lipsPressRight ?? 0);
|
||||
const noseSneer = Math.max(scoreMap.noseSneerLeft ?? 0, scoreMap.noseSneerRight ?? 0);
|
||||
const mouthStretch = Math.max(scoreMap.mouthStretchLeft ?? 0, scoreMap.mouthStretchRight ?? 0);
|
||||
const jawForward = scoreMap.jawForward ?? 0;
|
||||
const browOuterUp = Math.max(scoreMap.browOuterUpLeft ?? 0, scoreMap.browOuterUpRight ?? 0);
|
||||
const eyeSquint = Math.max(scoreMap.eyeSquintLeft ?? 0, scoreMap.eyeSquintRight ?? 0);
|
||||
|
||||
let positiveDelta = 0;
|
||||
if (smileLeft > 0.5 && smileRight > 0.5) positiveDelta += 15;
|
||||
else if (smileLeft > 0.5 || smileRight > 0.5) positiveDelta += 10;
|
||||
if (browInnerUp > 0.4) positiveDelta += 8;
|
||||
if (eyeWideMax > 0.5) positiveDelta += 7;
|
||||
if (jawOpen > 0.3 && eyeWideMax > 0.5) positiveDelta += 5;
|
||||
if (cheekPuff > 0.3) positiveDelta += 3;
|
||||
const positiveValence =
|
||||
(smileMax * 0.55) +
|
||||
(browInnerUp * 0.12) +
|
||||
(browOuterUp * 0.08) +
|
||||
(eyeWideMax * 0.08) +
|
||||
(jawOpen * 0.07) +
|
||||
(cheekPuff * 0.1);
|
||||
|
||||
let negativeDelta = 0;
|
||||
if (browDownLeft > 0.45 && browDownRight > 0.45 && smileMax < 0.2) negativeDelta -= 10;
|
||||
if (eyeBlinkLeft > 0.7 && eyeBlinkRight > 0.7 && eyeWideMax < 0.2) negativeDelta -= 15;
|
||||
if (mouthFrown > 0.4) negativeDelta -= 8;
|
||||
const negativeValence =
|
||||
(mouthFrown * 0.28) +
|
||||
(mouthPress * 0.18) +
|
||||
(mouthStretch * 0.12) +
|
||||
(noseSneer * 0.14) +
|
||||
(Math.max(browDownLeft, browDownRight) * 0.16) +
|
||||
(jawForward * 0.12);
|
||||
|
||||
const engagement =
|
||||
(eyeWideMax * 0.34) +
|
||||
(browInnerUp * 0.18) +
|
||||
(jawOpen * 0.16) +
|
||||
(smileMax * 0.18) +
|
||||
(eyeSquint * 0.14);
|
||||
|
||||
const disengagement =
|
||||
((eyeBlinkLeft + eyeBlinkRight) / 2 * 0.36) +
|
||||
(mouthPress * 0.18) +
|
||||
(smileMax < 0.14 && eyeWideMax < 0.18 ? 0.2 : 0);
|
||||
|
||||
const netSignal = (positiveValence - negativeValence) * 22;
|
||||
const attentionSignal = (engagement - disengagement) * 10;
|
||||
let boundedDelta = netSignal + attentionSignal;
|
||||
|
||||
const weightedShapes = [
|
||||
smileLeft,
|
||||
@@ -286,15 +324,18 @@ function calculateLocalQdScore(previousScore: number, scoreMap: Record<string, n
|
||||
eyeBlinkLeft,
|
||||
eyeBlinkRight,
|
||||
mouthFrown,
|
||||
mouthPress,
|
||||
noseSneer,
|
||||
mouthStretch,
|
||||
jawForward,
|
||||
browOuterUp,
|
||||
eyeSquint,
|
||||
];
|
||||
if (weightedShapes.every((value) => value < 0.15)) negativeDelta -= 3;
|
||||
|
||||
let delta = positiveDelta + negativeDelta;
|
||||
if (positiveDelta > 0 && negativeDelta < 0) {
|
||||
delta = Math.abs(positiveDelta) >= Math.abs(negativeDelta) ? positiveDelta : negativeDelta;
|
||||
if (weightedShapes.every((value) => value < 0.15)) {
|
||||
boundedDelta -= 4;
|
||||
}
|
||||
|
||||
const boundedDelta = Math.max(-20, Math.min(20, delta));
|
||||
boundedDelta = Math.max(-18, Math.min(18, boundedDelta));
|
||||
return clampScore(previousScore + boundedDelta);
|
||||
}
|
||||
|
||||
@@ -343,16 +384,22 @@ function getFaceBounds(
|
||||
}
|
||||
|
||||
export function PerceptionPlayer({
|
||||
videoUrl, videoAssetId, leadId,
|
||||
videoUrl, videoAssetId, personId, canonicalLeadId, leadId,
|
||||
leadName, leadBudget, leadInterest, priorInteractionCount = 0, initialQdScore,
|
||||
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')}`,
|
||||
);
|
||||
const lead = useStore((state) => state.leads.find((candidate) => candidate.id === leadId) ?? null);
|
||||
const priorInteractionCount = useStore((state) => (leadId ? state.messages[leadId]?.length ?? 0 : 0));
|
||||
const initialSessionScore = deriveInitialSessionScore(lead, priorInteractionCount);
|
||||
const initialSessionScore = deriveInitialSessionScore(
|
||||
{
|
||||
qdScore: initialQdScore,
|
||||
budget: leadBudget,
|
||||
interest: leadInterest,
|
||||
},
|
||||
priorInteractionCount,
|
||||
);
|
||||
|
||||
// Refs
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
@@ -398,19 +445,22 @@ export function PerceptionPlayer({
|
||||
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;
|
||||
const match =
|
||||
(personId && d.person_id === personId) ||
|
||||
(leadId && d.lead_id === leadId) ||
|
||||
d.session_id === sessionId;
|
||||
if (match && typeof d.qd_score === 'number') {
|
||||
lastBackendQdTsRef.current = Date.now();
|
||||
qdScoreRef.current = d.qd_score;
|
||||
setQdScore(d.qd_score);
|
||||
}
|
||||
}, [leadId, sessionId]),
|
||||
}, [leadId, personId, sessionId]),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
qdScoreRef.current = initialSessionScore;
|
||||
setQdScore(initialSessionScore);
|
||||
}, [initialSessionScore, leadId, sessionId]);
|
||||
}, [initialSessionScore, personId, leadId, sessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cameraActive || !streamRef.current || !webcamRef.current) return;
|
||||
@@ -511,7 +561,10 @@ export function PerceptionPlayer({
|
||||
if (significantActivity) {
|
||||
sendPacket({
|
||||
event: 'BIOMETRIC_PACKET',
|
||||
lead_id: leadId, session_id: sessionId,
|
||||
person_id: personId,
|
||||
canonical_lead_id: canonicalLeadId,
|
||||
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,
|
||||
});
|
||||
@@ -541,7 +594,7 @@ export function PerceptionPlayer({
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [detectFrame, isReady, leadId, sendPacket, sessionId, sessionMode, videoAssetId]);
|
||||
}, [canonicalLeadId, detectFrame, isReady, leadId, personId, sendPacket, sessionId, sessionMode, videoAssetId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sessionActive && cameraActive && isReady) {
|
||||
@@ -585,7 +638,15 @@ export function PerceptionPlayer({
|
||||
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 }),
|
||||
body: JSON.stringify({
|
||||
session_id: sessionId,
|
||||
session_mode: sessionMode,
|
||||
person_id: personId,
|
||||
canonical_lead_id: canonicalLeadId,
|
||||
lead_id: leadId,
|
||||
lead_name: leadName,
|
||||
final_qd_score: qdScore,
|
||||
}),
|
||||
}).catch(() => null);
|
||||
onSessionComplete?.(qdScore);
|
||||
};
|
||||
|
||||
@@ -17,11 +17,12 @@ import {
|
||||
Search,
|
||||
Film,
|
||||
} from 'lucide-react';
|
||||
import { useStore } from '@/store/useStore';
|
||||
import { PerceptionPlayer } from '@/components/modules/sentinel/PerceptionPlayer';
|
||||
import { API_URL } from '@/lib/api';
|
||||
import { fetchContacts } from '@/lib/crmApi';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Lead, MarketingVideo } from '@/types';
|
||||
import type { MarketingVideo } from '@/types';
|
||||
import type { CrmContactListItem } from '@/types/crmTypes';
|
||||
|
||||
// ── Local static video catalog (served from public/videos/) ──────────────────
|
||||
// Used as primary source when the remote backend is unreachable.
|
||||
@@ -86,6 +87,21 @@ function resolveVideoUrl(video_url: string): string {
|
||||
type SessionMode = 'assigned' | 'auto';
|
||||
type Step = 'select-video' | 'select-mode' | 'select-lead' | 'session' | 'summary';
|
||||
|
||||
interface SentinelClient {
|
||||
personId: string;
|
||||
leadId: string | null;
|
||||
legacyLiId: string | null;
|
||||
name: string;
|
||||
phone: string | null;
|
||||
buyerType: string | null;
|
||||
leadStatus: string | null;
|
||||
budget: string | null;
|
||||
interest: string | null;
|
||||
tags: string[];
|
||||
currentQdScore: number;
|
||||
priorInteractionCount: number;
|
||||
}
|
||||
|
||||
function apiAuthHeaders() {
|
||||
const token = localStorage.getItem('velocity-api-token');
|
||||
const headers: Record<string, string> = {};
|
||||
@@ -101,6 +117,29 @@ function formatDuration(totalSeconds: number) {
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function mapContactToSentinelClient(contact: CrmContactListItem): SentinelClient {
|
||||
const inferredTags = [
|
||||
contact.buyer_type,
|
||||
contact.lead_status,
|
||||
contact.urgency,
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
return {
|
||||
personId: contact.person_id,
|
||||
leadId: contact.lead_id,
|
||||
legacyLiId: contact.legacy_li_id,
|
||||
name: contact.full_name,
|
||||
phone: contact.primary_phone,
|
||||
buyerType: contact.buyer_type,
|
||||
leadStatus: contact.lead_status,
|
||||
budget: contact.budget_band,
|
||||
interest: contact.primary_interest,
|
||||
tags: inferredTags,
|
||||
currentQdScore: Math.round(Math.max(contact.engagement_score, contact.intent_score, 0.5) * 100),
|
||||
priorInteractionCount: contact.interaction_count,
|
||||
};
|
||||
}
|
||||
|
||||
function VideoCard({
|
||||
video,
|
||||
isSelected,
|
||||
@@ -259,12 +298,12 @@ function LeadRow({
|
||||
isSelected,
|
||||
onClick,
|
||||
}: {
|
||||
lead: Lead;
|
||||
lead: SentinelClient;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const initials = lead.name.split(' ').map((n) => n[0]).join('').slice(0, 2);
|
||||
const qd = lead.quantumDynamicsScore;
|
||||
const qd = lead.currentQdScore;
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
@@ -323,7 +362,7 @@ function SessionSummary({
|
||||
sessionMode,
|
||||
onNewSession,
|
||||
}: {
|
||||
lead?: Lead;
|
||||
lead?: SentinelClient;
|
||||
finalQdScore: number;
|
||||
sessionMode: SessionMode;
|
||||
onNewSession: () => void;
|
||||
@@ -394,15 +433,16 @@ function SessionSummary({
|
||||
}
|
||||
|
||||
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 [clients, setClients] = useState<SentinelClient[]>([]);
|
||||
const [clientsLoading, setClientsLoading] = useState(true);
|
||||
const [clientsError, setClientsError] = useState('');
|
||||
const [selectedLead, setSelectedLead] = useState<SentinelClient | null>(null);
|
||||
const [leadSearch, setLeadSearch] = useState('');
|
||||
const [finalQdScore, setFinalQdScore] = useState(50);
|
||||
const [sceneFile, setSceneFile] = useState<File | null>(null);
|
||||
@@ -451,6 +491,33 @@ export function SentinelLiveSession() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const loadClients = async () => {
|
||||
setClientsLoading(true);
|
||||
setClientsError('');
|
||||
try {
|
||||
const data = await fetchContacts({ limit: 200, offset: 0 });
|
||||
if (!cancelled) {
|
||||
setClients(data.contacts.map(mapContactToSentinelClient));
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
setClients([]);
|
||||
setClientsError(error instanceof Error ? error.message : 'Failed to load CRM contacts.');
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setClientsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
void loadClients();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const selectedVideoSceneStatus = useMemo(() => {
|
||||
if (!selectedVideo) {
|
||||
return 'No video selected';
|
||||
@@ -467,9 +534,9 @@ export function SentinelLiveSession() {
|
||||
const filteredLeads = useMemo(() => {
|
||||
const query = leadSearch.trim().toLowerCase();
|
||||
if (!query) {
|
||||
return leads;
|
||||
return clients;
|
||||
}
|
||||
return leads.filter((lead) => {
|
||||
return clients.filter((lead) => {
|
||||
const haystack = [
|
||||
lead.name,
|
||||
lead.phone,
|
||||
@@ -482,7 +549,7 @@ export function SentinelLiveSession() {
|
||||
.toLowerCase();
|
||||
return haystack.includes(query);
|
||||
});
|
||||
}, [leadSearch, leads]);
|
||||
}, [clients, leadSearch]);
|
||||
|
||||
const handleVideoSelect = (video: MarketingVideo) => setSelectedVideo(video);
|
||||
const handleVideoConfirm = () => {
|
||||
@@ -782,15 +849,24 @@ export function SentinelLiveSession() {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 max-h-72 overflow-y-auto custom-scrollbar mb-4">
|
||||
{filteredLeads.map((lead) => (
|
||||
{clientsLoading ? (
|
||||
<div className="rounded-xl border border-white/8 bg-white/[0.02] px-4 py-8 text-center text-sm text-zinc-500 flex items-center justify-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Loading CRM contacts
|
||||
</div>
|
||||
) : clientsError ? (
|
||||
<div className="rounded-xl border border-red-500/20 bg-red-500/5 px-4 py-6 text-center text-sm text-red-300">
|
||||
{clientsError}
|
||||
</div>
|
||||
) : filteredLeads.map((lead) => (
|
||||
<LeadRow
|
||||
key={lead.id}
|
||||
key={lead.personId}
|
||||
lead={lead}
|
||||
isSelected={selectedLead?.id === lead.id}
|
||||
isSelected={selectedLead?.personId === lead.personId}
|
||||
onClick={() => setSelectedLead(lead)}
|
||||
/>
|
||||
))}
|
||||
{filteredLeads.length === 0 && (
|
||||
{!clientsLoading && !clientsError && 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>
|
||||
@@ -837,7 +913,14 @@ export function SentinelLiveSession() {
|
||||
<PerceptionPlayer
|
||||
videoUrl={resolveVideoUrl(selectedVideo.video_url)}
|
||||
videoAssetId={selectedVideo.id}
|
||||
leadId={sessionMode === 'assigned' ? selectedLead?.id : undefined}
|
||||
personId={sessionMode === 'assigned' ? selectedLead?.personId : undefined}
|
||||
canonicalLeadId={sessionMode === 'assigned' ? selectedLead?.leadId ?? undefined : undefined}
|
||||
leadId={sessionMode === 'assigned' ? selectedLead?.legacyLiId ?? undefined : undefined}
|
||||
leadName={sessionMode === 'assigned' ? selectedLead?.name : undefined}
|
||||
leadBudget={sessionMode === 'assigned' ? selectedLead?.budget ?? undefined : undefined}
|
||||
leadInterest={sessionMode === 'assigned' ? selectedLead?.interest ?? undefined : undefined}
|
||||
priorInteractionCount={sessionMode === 'assigned' ? selectedLead?.priorInteractionCount : undefined}
|
||||
initialQdScore={sessionMode === 'assigned' ? selectedLead?.currentQdScore : undefined}
|
||||
sessionMode={sessionMode}
|
||||
videoTitle={selectedVideo.title}
|
||||
onSessionComplete={handleSessionComplete}
|
||||
|
||||
Reference in New Issue
Block a user