Initial commit: Velocity-OS migration

This commit is contained in:
2026-05-01 12:32:19 +05:30
commit 407af828d4
283 changed files with 207782 additions and 0 deletions

View File

@@ -0,0 +1,19 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}

View File

@@ -0,0 +1,133 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/shared/lib/apiClient';
/**
* useClient360 — fetch unified client entity
* Feeds: CRM lead data + QD score + pipeline stage + contact info
*/
export function useClient360(personId: string) {
const query = useQuery({
queryKey: ['client360', personId],
queryFn: () => api.get<Client360Data>(`/crm/leads/${personId}/360`),
staleTime: 30_000,
enabled: !!personId,
});
return { client: query.data, isLoading: query.isLoading, error: query.error };
}
/**
* useConversations — unified comms feed for a lead
*/
export function useConversations(personId: string) {
const qc = useQueryClient();
const query = useQuery({
queryKey: ['conversations', personId],
queryFn: () => api.get<ConversationEvent[]>(`/comms/threads/${personId}`),
staleTime: 10_000,
enabled: !!personId,
});
const sendWhatsApp = (text: string) =>
api.post(`/comms/send`, { person_id: personId, channel: 'whatsapp', text })
.then(() => qc.invalidateQueries({ queryKey: ['conversations', personId] }));
return { events: query.data ?? [], isLoading: query.isLoading, sendWhatsApp };
}
/**
* useClientProperties — linked property interests
*/
export function useClientProperties(personId: string) {
const query = useQuery({
queryKey: ['client-properties', personId],
queryFn: () => api.get<PropertyInterest[]>(`/crm/leads/${personId}/properties`),
staleTime: 60_000,
enabled: !!personId,
});
return { properties: query.data ?? [], isLoading: query.isLoading };
}
/**
* useClientTasks — tasks for a specific lead
*/
export function useClientTasks(personId: string) {
const qc = useQueryClient();
const query = useQuery({
queryKey: ['client-tasks', personId],
queryFn: () => api.get<Task[]>(`/crm/leads/${personId}/tasks`),
staleTime: 30_000,
enabled: !!personId,
});
const markDone = (taskId: string) =>
api.patch(`/crm/tasks/${taskId}`, { status: 'done' })
.then(() => qc.invalidateQueries({ queryKey: ['client-tasks', personId] }));
const snooze = (taskId: string) =>
api.patch(`/crm/tasks/${taskId}`, { status: 'snoozed' })
.then(() => qc.invalidateQueries({ queryKey: ['client-tasks', personId] }));
// Group tasks
const all = query.data ?? [];
const tasks = all.map(t => ({
...t,
group: t.status === 'done' ? 'completed'
: t.isDueToday ? 'today'
: 'upcoming',
})) as any[];
return { tasks, isLoading: query.isLoading, markDone, snooze };
}
// ── Types ────────────────────────────────────────────────────
export interface Client360Data {
id: string;
name: string;
location?: string;
primaryPhone?: string;
avatarUrl?: string;
qdScore: number;
qdDelta: number;
stageName: string;
stageEmoji: string;
lastContactRelative: string;
lastContactChannel: string;
aiInsight?: string;
extractedFacts?: Record<string, string>;
objections?: string[];
qdHistory?: { date: string; score: number; label?: string }[];
}
interface ConversationEvent {
id: string;
type: 'whatsapp' | 'call' | 'email';
timestamp: string;
timestampRelative: string;
messages?: { sender: 'client' | 'you'; text: string; status?: '✓' | '✓✓' }[];
duration?: string;
direction?: 'inbound' | 'outbound';
keyMoments?: string[];
hasTranscript?: boolean;
subject?: string;
}
interface PropertyInterest {
id: string;
projectName: string;
unitName: string;
config: string;
area: string;
price: string;
thumbnailUrl?: string;
isPrimary: boolean;
engagementLevel: 'High' | 'Medium' | 'Low';
}
interface Task {
id: string;
label: string;
dueAt?: string;
status: 'pending' | 'done' | 'snoozed';
isDueToday?: boolean;
isAIGenerated?: boolean;
}

View File

@@ -0,0 +1,41 @@
import { useQuery } from '@tanstack/react-query';
import { api } from '@/shared/lib/apiClient';
/**
* useCommandData — Command Pillar data
* Fetches KPIs, AI priority cards, and pipeline stage summary.
*/
export function useCommandData() {
return useQuery({
queryKey: ['command-data'],
queryFn: () => api.get<CommandData>('/dashboard/morning-brief'),
staleTime: 60_000,
refetchInterval: 5 * 60_000, // background refresh every 5min
});
}
export interface CommandData {
kpis: {
label: string;
value: string;
delta?: string;
deltaPositive?: boolean;
sublabel?: string;
}[];
priorityCards: {
id: string;
type: 'qd_surge' | 'vault_engagement' | 'follow_up' | 'site_visit';
headline: string;
sublabel?: string;
personId?: string;
personName?: string;
cta: string;
urgency: 'high' | 'medium' | 'low';
}[];
pipelineStages: {
id: string;
label: string;
count: number;
value?: string;
}[];
}

View File

@@ -0,0 +1,146 @@
import { useEffect } from 'react';
import { getChatLogs, getLeads } from '@/lib/api';
import { mapLeadRecordToStoreLead } from '@/lib/crmMappers';
import { mapInventoryPropertySummaryToUnit } from '@/lib/platformMappers';
import { useStore } from '@/store/useStore';
import type { ChatMessage } from '@/types';
import type { LeadRecord } from '@/lib/api';
import { listInventoryProperties } from '@/lib/velocityPlatformClient';
export function useCrmBootstrap() {
const { setLeads, replaceMessages, setUnits, updateMetrics, setVelocityData, updateStatus } = useStore();
useEffect(() => {
let cancelled = false;
const hydrate = async () => {
updateStatus({
isConnected: false,
serverStatus: 'syncing',
});
try {
const leads = await getLeads();
if (cancelled) return;
setLeads(leads.map(mapLeadRecordToStoreLead));
const messageEntries = await Promise.all(
leads.slice(0, 25).map(async (lead) => {
const logs = await getChatLogs(lead.id);
return [
lead.id,
logs.map((log): ChatMessage => ({
id: log.id,
sender: log.sender === 'lead' ? 'user' : 'oracle',
content: log.content,
timestamp: new Date(log.created_at ?? Date.now()),
})),
] as const;
}),
);
if (!cancelled) {
replaceMessages(Object.fromEntries(messageEntries));
}
const inventoryResult = await listInventoryProperties(100).catch(() => null);
if (!cancelled) {
const units = inventoryResult?.properties.map(mapInventoryPropertySummaryToUnit) ?? [];
setUnits(units);
updateMetrics(buildDashboardMetrics(leads, messageEntries, units.length));
setVelocityData(buildVelocitySeries(leads));
updateStatus({
isConnected: true,
serverStatus: 'online',
lastSync: new Date(),
});
}
} catch {
if (!cancelled) {
setLeads([]);
replaceMessages({});
setUnits([]);
updateMetrics({
activeVisitors: 0,
todayLeads: 0,
closedDeals: 0,
conversionRate: 0,
sentiment: 0,
systemHealth: {
cpu: 0,
gpu: 0,
memory: 0,
temperature: 0,
},
});
setVelocityData([]);
updateStatus({
isConnected: false,
serverStatus: 'offline',
lastSync: new Date(),
});
}
}
};
void hydrate();
return () => {
cancelled = true;
};
}, [replaceMessages, setLeads, setUnits, setVelocityData, updateMetrics, updateStatus]);
}
function buildDashboardMetrics(
leads: LeadRecord[],
messageEntries: ReadonlyArray<readonly [string, ChatMessage[]]>,
inventoryCount: number,
) {
const closedDeals = leads.filter((lead) => lead.stage === 'closed').length;
const engagedLeads = leads.filter((lead) => lead.score >= 75 || lead.stage === 'negotiation' || lead.stage === 'qualified').length;
const averageScore = leads.length > 0
? Math.round(leads.reduce((sum, lead) => sum + lead.score, 0) / leads.length)
: 0;
const totalMessages = messageEntries.reduce((sum, [, messages]) => sum + messages.length, 0);
return {
activeVisitors: Math.min(999, totalMessages),
todayLeads: leads.length,
closedDeals,
conversionRate: leads.length > 0 ? Number(((closedDeals / leads.length) * 100).toFixed(1)) : 0,
sentiment: averageScore,
systemHealth: {
cpu: Math.min(100, 10 + leads.length * 2),
gpu: Math.min(100, 5 + Math.round(inventoryCount * 1.5)),
memory: Math.min(100, 15 + totalMessages),
temperature: Math.min(100, 20 + engagedLeads * 4),
},
};
}
function buildVelocitySeries(leads: LeadRecord[]) {
const buckets = new Map<string, { generated: number; closed: number }>();
for (let dayOffset = 6; dayOffset >= 0; dayOffset -= 1) {
const day = new Date();
day.setHours(0, 0, 0, 0);
day.setDate(day.getDate() - dayOffset);
const key = day.toISOString().slice(0, 10);
buckets.set(key, { generated: 0, closed: 0 });
}
for (const lead of leads) {
const createdKey = (lead.created_at ?? '').slice(0, 10);
const updatedKey = (lead.updated_at ?? lead.created_at ?? '').slice(0, 10);
if (buckets.has(createdKey)) {
buckets.get(createdKey)!.generated += 1;
}
if (lead.stage === 'closed' && buckets.has(updatedKey)) {
buckets.get(updatedKey)!.closed += 1;
}
}
return Array.from(buckets.entries()).map(([key, value]) => ({
time: key.slice(5),
generated: value.generated,
closed: value.closed,
}));
}

View File

@@ -0,0 +1,34 @@
import { useQuery } from '@tanstack/react-query';
import { api } from '@/shared/lib/apiClient';
/**
* useKanban — Pipeline Pillar kanban board data
* Returns leads grouped by stage.
*/
export function useKanban() {
const query = useQuery({
queryKey: ['kanban'],
queryFn: () => api.get<KanbanStage[]>('/crm/pipeline/kanban'),
staleTime: 30_000,
refetchInterval: 60_000,
});
return { stages: query.data ?? [], isLoading: query.isLoading };
}
export interface KanbanStage {
id: string;
label: string;
emoji: string;
leads: KanbanLead[];
}
export interface KanbanLead {
id: string;
name: string;
location?: string;
qdScore: number;
qdDelta?: number;
lastContactRelative: string;
lastContactChannel: string;
isVaultActive?: boolean;
}

View File

@@ -0,0 +1,100 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import {
FaceLandmarker,
FilesetResolver,
} from '@mediapipe/tasks-vision';
export interface BlendShapeCategory {
categoryName: string;
score: number;
displayName: string;
index: number;
}
export interface FaceLandmarkerResult {
faceBlendshapes: Array<{ categories: BlendShapeCategory[] }>;
faceLandmarks: Array<Array<{ x: number; y: number; z: number }>>;
}
const MODEL_URL =
import.meta.env.VITE_MEDIAPIPE_MODEL_URL ??
'/mediapipe/assets/face_landmarker.task';
const WASM_ROOT =
import.meta.env.VITE_MEDIAPIPE_WASM_ROOT ??
'/mediapipe/wasm';
interface UseFaceLandmarkerReturn {
isLoading: boolean;
isReady: boolean;
error: string | null;
detectFrame: (
videoElement: HTMLVideoElement,
timestampMs: number,
) => FaceLandmarkerResult | null;
}
export function useMediapipeFaceLandmarker(): UseFaceLandmarkerReturn {
const landmarkerRef = useRef<FaceLandmarker | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isReady, setIsReady] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
async function init() {
try {
const filesetResolver = await FilesetResolver.forVisionTasks(WASM_ROOT);
const landmarker = await FaceLandmarker.createFromOptions(filesetResolver, {
baseOptions: {
modelAssetPath: MODEL_URL,
delegate: 'GPU',
},
outputFaceBlendshapes: true,
runningMode: 'VIDEO',
numFaces: 3,
minFaceDetectionConfidence: 0.65,
minFacePresenceConfidence: 0.6,
minTrackingConfidence: 0.6,
});
if (cancelled) {
landmarker.close();
return;
}
landmarkerRef.current = landmarker;
setIsLoading(false);
setIsReady(true);
} catch (err) {
if (cancelled) return;
console.error('[MediaPipe] Initialization failed:', err);
setError(err instanceof Error ? err.message : 'MediaPipe failed to initialize.');
setIsLoading(false);
}
}
void init();
return () => {
cancelled = true;
landmarkerRef.current?.close();
landmarkerRef.current = null;
};
}, []);
const detectFrame = useCallback(
(videoElement: HTMLVideoElement, timestampMs: number): FaceLandmarkerResult | null => {
if (!landmarkerRef.current || !isReady) return null;
try {
return landmarkerRef.current.detectForVideo(videoElement, timestampMs) as FaceLandmarkerResult;
} catch {
return null;
}
},
[isReady],
);
return { isLoading, isReady, error, detectFrame };
}

View File

@@ -0,0 +1,128 @@
import { useEffect, useRef, useCallback } from 'react';
import { useSentinelStore } from '@/store/sentinelStore';
import { useAuthStore } from '@/store/authStore';
/**
* useSentinelWebSocket
* Connects to the Sentinel WebSocket endpoint on core-api.
* Translates raw CCTV events into store actions.
* The broker-facing UI NEVER sees: WebSocket status, connection state,
* raw event types, MediaPipe processing info, or frame IDs.
*
* Events handled:
* visitor_detected → setPendingAlert (triggers SentinelAlertBanner)
* session_start → setShowroomActive(true)
* qd_update → update session QD score
* session_end → setShowroomActive(false)
* ai_observation → update session AI observation text
*/
interface RawSentinelEvent {
type: 'visitor_detected' | 'session_start' | 'qd_update' | 'session_end' | 'ai_observation';
session_id?: string;
qd_score?: number;
qd_trend?: number;
zone?: string;
matched_person_id?: string;
matched_name?: string;
face_confidence?: number;
ai_observation?: string;
peak_qd?: number;
}
interface LiveSession {
sessionId: string;
qdScore: number;
qdTrend: number;
currentZone?: string;
aiObservation?: string;
peakQd?: number;
}
export function useSentinelWebSocket() {
const wsRef = useRef<WebSocket | null>(null);
const sessionRef = useRef<LiveSession | null>(null);
const { token } = useAuthStore.getState();
const {
setPendingAlert,
setShowroomActive,
setSessionDuration,
setHasInsights,
} = useSentinelStore();
const connect = useCallback(() => {
// wss:// on velocity.local, proxied by Traefik → core-api
const wsUrl = `wss://${window.location.host}/ws/sentinel?token=${token}`;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onmessage = (evt) => {
let event: RawSentinelEvent;
try { event = JSON.parse(evt.data); }
catch { return; }
switch (event.type) {
case 'visitor_detected':
setPendingAlert({
id: String(Date.now()),
matchedName: event.matched_name,
matchedPersonId:event.matched_person_id,
confidence: event.face_confidence,
zone: event.zone ?? 'Entrance',
timestamp: new Date(),
});
break;
case 'session_start':
sessionRef.current = {
sessionId: event.session_id!,
qdScore: 0,
qdTrend: 0,
};
setShowroomActive(true, event.session_id);
break;
case 'qd_update':
if (sessionRef.current) {
sessionRef.current = {
...sessionRef.current,
qdScore: event.qd_score ?? 0,
qdTrend: event.qd_trend ?? 0,
currentZone: event.zone,
peakQd: Math.max(sessionRef.current.peakQd ?? 0, event.qd_score ?? 0),
};
}
break;
case 'ai_observation':
if (sessionRef.current) {
sessionRef.current = {
...sessionRef.current,
aiObservation: event.ai_observation,
};
setHasInsights(true);
}
break;
case 'session_end':
setShowroomActive(false);
break;
}
};
ws.onerror = () => { /* silent — broker never sees connection errors */ };
ws.onclose = (e) => {
if (e.code !== 1000) {
// Reconnect after 3s on unexpected disconnect
setTimeout(connect, 3000);
}
};
}, [token, setPendingAlert, setShowroomActive, setHasInsights]);
useEffect(() => {
connect();
return () => { wsRef.current?.close(1000, 'component unmount'); };
}, [connect]);
return { session: sessionRef.current };
}

View File

@@ -0,0 +1,51 @@
import { useQuery } from '@tanstack/react-query';
import { api } from '@/shared/lib/apiClient';
/**
* useStudioProperties — Studio Pillar property listing
*/
export function useStudioProperties() {
const query = useQuery({
queryKey: ['studio-properties'],
queryFn: () => api.get<StudioProperty[]>('/inventory/properties'),
staleTime: 120_000,
});
return { properties: query.data ?? [], isLoading: query.isLoading };
}
/**
* useProperty — single property entity with full details
*/
export function useProperty(propertyId: string) {
const query = useQuery({
queryKey: ['property', propertyId],
queryFn: () => api.get<PropertyDetail>(`/inventory/properties/${propertyId}`),
staleTime: 120_000,
enabled: !!propertyId,
});
return { property: query.data, isLoading: query.isLoading };
}
// ── Types ────────────────────────────────────────────────────
export interface StudioProperty {
id: string;
name: string;
location: string;
priceRange?: string;
thumbnailUrl?: string;
availableUnits?: number;
}
export interface PropertyDetail {
id: string;
name: string;
config: string;
area: string;
price: string;
description?: string;
thumbnailUrl?: string;
interiorImageUrl?: string;
modelUrl?: string; // GLB/GLTF for R3F
images?: string[];
amenities?: string[];
}

View File

@@ -0,0 +1,145 @@
import { useEffect, useRef, useCallback } from 'react';
import { useStore } from '@/store/useStore';
import { WS_URL } from '@/lib/api';
import type { QDScoreUpdate, VaultOpenedEvent } from '@/types';
const SENTINEL_WS_ROOT = `${WS_URL}/api/sentinel/ws`;
type WsEventType =
| 'WS_ASSET_OPENED'
| 'QD_UPDATED'
| 'LEAD_TAGGED'
| 'system'
| 'ack';
interface WsMessage {
type: WsEventType;
data?: Record<string, unknown>;
timestamp?: string;
}
interface UseVelocitySocketOptions {
channel?: 'notifications' | 'perception';
onConnect?: () => void;
onDisconnect?: () => void;
onMessage?: (msg: WsMessage) => void;
}
export function useVelocitySocket(options: UseVelocitySocketOptions = {}) {
const { channel = 'notifications', onConnect, onDisconnect, onMessage } = options;
const { addNotification } = useStore();
const wsRef = useRef<WebSocket | null>(null);
const retryCountRef = useRef(0);
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingBufferRef = useRef<string[]>([]);
const isMountedRef = useRef(true);
const handleMessage = useCallback(
(event: MessageEvent) => {
let msg: WsMessage;
try {
msg = JSON.parse(event.data as string) as WsMessage;
} catch {
return;
}
onMessage?.(msg);
switch (msg.type) {
case 'WS_ASSET_OPENED': {
const d = msg.data as Partial<VaultOpenedEvent>;
addNotification({
type: 'velocity_link_opened',
title: 'Velocity Link Opened',
body: `${d.lead_name ?? 'A prospect'} just opened ${d.asset_name ?? 'your asset'}.`,
leadId: d.lead_id,
});
break;
}
case 'QD_UPDATED': {
const d = msg.data as Partial<QDScoreUpdate>;
if ((d.qd_score ?? 0) >= 75) {
addNotification({
type: 'qd_spike',
title: 'QD Score Spike',
body: `QD Score jumped to ${d.qd_score}. ${d.reasoning ?? ''}`.trim(),
leadId: d.lead_id,
qdScore: d.qd_score,
});
}
break;
}
case 'LEAD_TAGGED': {
const d = msg.data as { lead_id?: string; lead_name?: string; tags?: string[] };
if (d.tags?.length) {
addNotification({
type: 'lead_tagged',
title: 'Lead Tagged',
body: `${d.lead_name ?? 'Lead'} tagged as ${d.tags.join(', ')}.`,
leadId: d.lead_id,
tags: d.tags,
});
}
break;
}
default:
break;
}
},
[addNotification, onMessage],
);
const connect = useCallback(() => {
if (!isMountedRef.current) return;
const url = `${SENTINEL_WS_ROOT}/${channel}`;
const ws = new WebSocket(url);
wsRef.current = ws;
ws.onopen = () => {
retryCountRef.current = 0;
pendingBufferRef.current.forEach((msg) => ws.send(msg));
pendingBufferRef.current = [];
onConnect?.();
};
ws.onmessage = handleMessage;
ws.onclose = () => {
onDisconnect?.();
if (!isMountedRef.current) return;
if (retryCountRef.current >= 5) return;
const delay = Math.min(1000 * 2 ** retryCountRef.current, 30_000);
retryCountRef.current += 1;
retryTimerRef.current = setTimeout(connect, delay);
};
ws.onerror = () => ws.close();
}, [channel, handleMessage, onConnect, onDisconnect]);
useEffect(() => {
isMountedRef.current = true;
connect();
return () => {
isMountedRef.current = false;
if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
wsRef.current?.close();
};
}, [connect]);
const sendPacket = useCallback((payload: unknown) => {
const str = JSON.stringify(payload);
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(str);
} else {
pendingBufferRef.current.push(str);
if (pendingBufferRef.current.length > 100) {
pendingBufferRef.current.shift();
}
}
}, []);
return { sendPacket };
}