Initial commit: Velocity-OS migration
This commit is contained in:
19
webos/src/shared/hooks/use-mobile.ts
Normal file
19
webos/src/shared/hooks/use-mobile.ts
Normal 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
|
||||
}
|
||||
133
webos/src/shared/hooks/useClient360.ts
Normal file
133
webos/src/shared/hooks/useClient360.ts
Normal 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;
|
||||
}
|
||||
41
webos/src/shared/hooks/useCommandData.ts
Normal file
41
webos/src/shared/hooks/useCommandData.ts
Normal 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;
|
||||
}[];
|
||||
}
|
||||
146
webos/src/shared/hooks/useCrmBootstrap.ts
Normal file
146
webos/src/shared/hooks/useCrmBootstrap.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
34
webos/src/shared/hooks/useKanban.ts
Normal file
34
webos/src/shared/hooks/useKanban.ts
Normal 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;
|
||||
}
|
||||
100
webos/src/shared/hooks/useMediapipeFaceLandmarker.ts
Normal file
100
webos/src/shared/hooks/useMediapipeFaceLandmarker.ts
Normal 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 };
|
||||
}
|
||||
128
webos/src/shared/hooks/useSentinelWebSocket.ts
Normal file
128
webos/src/shared/hooks/useSentinelWebSocket.ts
Normal 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 };
|
||||
}
|
||||
51
webos/src/shared/hooks/useStudio.ts
Normal file
51
webos/src/shared/hooks/useStudio.ts
Normal 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[];
|
||||
}
|
||||
145
webos/src/shared/hooks/useVelocitySocket.ts
Normal file
145
webos/src/shared/hooks/useVelocitySocket.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user