feat: Oracle CRM Page, Synthetic Client Data and Live Snapshot when hitting emotion hotpoint
This commit is contained in:
2
app/dist/index.html
vendored
2
app/dist/index.html
vendored
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Velocity WebOS</title>
|
||||
<script type="module" crossorigin src="./assets/index-DbaoiOvw.js"></script>
|
||||
<script type="module" crossorigin src="./assets/index-CV1YNwsn.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-BDvIhi37.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
getVelocityToken,
|
||||
isAdminRole,
|
||||
normalizeVelocityRole,
|
||||
type VelocityUserProfile,
|
||||
} from '@/lib/velocityPlatformClient';
|
||||
|
||||
import {
|
||||
@@ -258,7 +259,7 @@ function App() {
|
||||
if (cancelled) return;
|
||||
login({
|
||||
id: me.user_id,
|
||||
name: me.user_id,
|
||||
name: resolveVelocityDisplayName(me),
|
||||
role: normalizeVelocityRole(me.role),
|
||||
});
|
||||
setAuthBootstrapped(true);
|
||||
@@ -287,7 +288,7 @@ function App() {
|
||||
if (cancelled) return;
|
||||
login({
|
||||
id: me.user_id,
|
||||
name: me.user_id,
|
||||
name: resolveVelocityDisplayName(me),
|
||||
role: normalizeVelocityRole(me.role),
|
||||
});
|
||||
})
|
||||
@@ -362,3 +363,17 @@ function formatRoleLabel(role: string | undefined) {
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -14,11 +14,12 @@ import type {
|
||||
ImportReviewDecision,
|
||||
QdScoreEntry,
|
||||
} from '@/types/crmTypes';
|
||||
import { VELOCITY_TOKEN_KEY } from '@/lib/velocityPlatformClient';
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? '';
|
||||
|
||||
function getAuthHeaders(): Record<string, string> {
|
||||
const token = localStorage.getItem('velocity_token');
|
||||
const token = localStorage.getItem(VELOCITY_TOKEN_KEY);
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ export const VELOCITY_TOKEN_KEY = 'velocity-api-token';
|
||||
export interface VelocityUserProfile {
|
||||
user_id: string;
|
||||
role: string;
|
||||
full_name?: string | null;
|
||||
email?: string | null;
|
||||
}
|
||||
|
||||
export interface VelocityLoginResponse {
|
||||
|
||||
@@ -19,8 +19,29 @@ import type {
|
||||
} from '../types/canvas';
|
||||
import { VELOCITY_TOKEN_KEY } from '@/lib/velocityPlatformClient';
|
||||
|
||||
const BASE_URL = (import.meta.env.VITE_ORACLE_API_URL as string | undefined) ?? '';
|
||||
const WS_URL = (import.meta.env.VITE_ORACLE_WS_URL as string | undefined) ?? '';
|
||||
function getBrowserOrigin(): string {
|
||||
return typeof window !== 'undefined' ? window.location.origin : '';
|
||||
}
|
||||
|
||||
function resolveBaseUrl(): string {
|
||||
const configured = (import.meta.env.VITE_ORACLE_API_URL as string | undefined)?.trim();
|
||||
if (configured) {
|
||||
return configured.replace(/\/$/, '');
|
||||
}
|
||||
return getBrowserOrigin();
|
||||
}
|
||||
|
||||
function resolveWsUrl(): string {
|
||||
const configured = (import.meta.env.VITE_ORACLE_WS_URL as string | undefined)?.trim();
|
||||
if (configured) {
|
||||
return configured.replace(/\/$/, '');
|
||||
}
|
||||
const origin = getBrowserOrigin();
|
||||
return origin ? origin.replace(/^http/, 'ws') : '';
|
||||
}
|
||||
|
||||
const BASE_URL = resolveBaseUrl();
|
||||
const WS_URL = resolveWsUrl();
|
||||
|
||||
function apiUrl(path: string): string {
|
||||
return `${BASE_URL}/api/oracle/v1${path}`;
|
||||
@@ -30,10 +51,6 @@ async function apiFetch<T>(
|
||||
path: string,
|
||||
options?: RequestInit & { idempotencyKey?: string },
|
||||
): Promise<T> {
|
||||
if (!BASE_URL) {
|
||||
throw new Error('Oracle API is not configured. Set VITE_ORACLE_API_URL to a live backend.');
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Oracle-Contract-Version': 'v1',
|
||||
|
||||
@@ -11,10 +11,13 @@ export interface CrmContactListItem {
|
||||
primary_phone: string | null;
|
||||
buyer_type: string | null;
|
||||
lead_id: string | null;
|
||||
legacy_li_id: string | null;
|
||||
lead_status: string | null;
|
||||
budget_band: string | null;
|
||||
urgency: string | null;
|
||||
primary_interest: string | null;
|
||||
intent_score: number;
|
||||
engagement_score: number;
|
||||
urgency_score: number;
|
||||
interaction_count: number;
|
||||
last_interaction_at: string | null;
|
||||
|
||||
@@ -213,7 +213,9 @@ export interface CatalystSettings {
|
||||
* plus the exact video playback timestamp for stimulus correlation.
|
||||
*/
|
||||
export interface BiometricPacket {
|
||||
person_id?: string;
|
||||
lead_id?: string;
|
||||
canonical_lead_id?: string;
|
||||
session_id: string;
|
||||
session_mode?: 'assigned' | 'auto';
|
||||
video_asset_id?: string;
|
||||
@@ -225,6 +227,7 @@ export interface BiometricPacket {
|
||||
|
||||
/** Emitted by FastAPI after NemoClaw processes a biometric packet batch */
|
||||
export interface QDScoreUpdate {
|
||||
person_id?: string;
|
||||
lead_id?: string;
|
||||
session_id?: string;
|
||||
qd_score: number;
|
||||
|
||||
Reference in New Issue
Block a user