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

41
webos/Dockerfile Normal file
View File

@@ -0,0 +1,41 @@
# syntax=docker/dockerfile:1.4
# ============================================================
# Velocity-OS — webos (React 19 WebOS Frontend)
# Multi-stage: Vite build → Nginx static serve.
# ============================================================
# ── Stage 1: Node build ──────────────────────────────────────
FROM node:20-alpine AS builder
WORKDIR /app
# Cache node_modules layer separately from source
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci --prefer-offline
# Copy source and build
COPY . .
RUN npm run build
# ── Stage 2: Nginx runtime ───────────────────────────────────
FROM nginx:1.25-alpine AS runtime
LABEL org.opencontainers.image.title="velocity-os-webos" \
org.opencontainers.image.description="Velocity-OS React WebOS Frontend" \
org.opencontainers.image.vendor="Desineuron" \
org.opencontainers.image.version="2.0.0"
# Remove default config
RUN rm /etc/nginx/conf.d/default.conf
# Copy built assets and custom nginx config
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/velocity-os.conf
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
CMD wget -qO- http://localhost/health.txt || exit 1
CMD ["nginx", "-g", "daemon off;"]

27
webos/fix_dirs.ps1 Normal file
View File

@@ -0,0 +1,27 @@
$base = 'F:\Workin In Progress\DESINEURON\GITLAB\Velocity-OS\webos\src'
$doubleDirs = 'shared\hooks\hooks','shared\lib\lib','shared\ui\ui','shared\types\types','store\store'
foreach ($dir in $doubleDirs) {
$srcDir = Join-Path $base $dir
$dstDir = Split-Path $srcDir -Parent
if (Test-Path $srcDir) {
Write-Host "Flattening: $srcDir"
Get-ChildItem -Path $srcDir -File | ForEach-Object {
$target = Join-Path $dstDir $_.Name
if (-not (Test-Path $target)) {
Move-Item $_.FullName $dstDir -Force
Write-Host " Moved: $($_.Name)"
} else {
Write-Host " Skip (already exists): $($_.Name)"
}
}
if ((Get-ChildItem $srcDir -Force | Measure-Object).Count -eq 0) {
Remove-Item $srcDir -Force
Write-Host " Removed empty dir."
}
} else {
Write-Host "Not found (ok): $srcDir"
}
}
Write-Host "All done."

25
webos/index.html Normal file
View File

@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#1a1d2e" />
<!-- SEO -->
<title>Velocity-OS</title>
<meta name="description" content="AI-Augmented Real Estate Operating System for luxury property sales teams." />
<!-- Preload Google Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

37
webos/nginx.conf Normal file
View File

@@ -0,0 +1,37 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Health check endpoint (no logging)
location = /health.txt {
access_log off;
return 200 "ok\n";
add_header Content-Type text/plain;
}
# React Router — all non-asset paths serve index.html
location / {
try_files $uri $uri/ /index.html;
}
# Static assets — aggressive caching (Vite hashes filenames)
location ~* \.(js|css|woff2?|ttf|eot|svg|png|jpg|jpeg|gif|ico|wasm)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
# Security headers
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob:; connect-src 'self' wss:;" always;
# Gzip
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript application/wasm;
gzip_min_length 1000;
}

4233
webos/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

39
webos/package.json Normal file
View File

@@ -0,0 +1,39 @@
{
"name": "velocity-os-webos",
"version": "2.0.0",
"private": true,
"description": "Velocity-OS WebOS — 3-Pillar React Frontend",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@react-three/drei": "^10.0.0",
"@react-three/fiber": "^9.0.0",
"@tanstack/react-query": "^5.51.0",
"clsx": "^2.1.1",
"date-fns": "^3.6.0",
"framer-motion": "^11.3.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^6.26.0",
"three": "0.168.0",
"zustand": "^4.5.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@types/three": "^0.168.0",
"@typescript-eslint/eslint-plugin": "^7.0.0",
"@typescript-eslint/parser": "^7.0.0",
"@vitejs/plugin-react": "^4.3.0",
"eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^4.6.0",
"typescript": "^5.5.0",
"vite": "^5.4.0"
}
}

115
webos/src/App.tsx Normal file
View File

@@ -0,0 +1,115 @@
import { createBrowserRouter, RouterProvider, Navigate } from 'react-router-dom';
import { AuthGuard } from './shared/layout/AuthGuard';
import { AdminGuard } from './shared/layout/AdminGuard';
import { AuthenticatedShell } from './shared/layout/AuthenticatedShell';
import { LoginPage } from './shared/layout/LoginPage';
// ── Pillar pages (lazy loaded for performance) ────────────────
import { lazy, Suspense } from 'react';
import { PillarSkeleton } from './shared/layout/PillarSkeleton';
const CommandPillar = lazy(() => import('./pillars/command/CommandPillar'));
const PipelinePillar = lazy(() => import('./pillars/pipeline/PipelinePillar'));
const Client360 = lazy(() => import('./pillars/pipeline/client360/Client360'));
const ShowroomMode = lazy(() => import('./pillars/pipeline/ShowroomMode'));
const StudioPillar = lazy(() => import('./pillars/studio/StudioPillar'));
const PropertyEntity = lazy(() => import('./pillars/studio/PropertyEntity'));
const ControlRoom = lazy(() => import('./control-room/ControlRoom'));
const VaultPublicPage = lazy(() => import('./shared/layout/VaultPublicPage'));
// ── Lazy wrapper with branded skeleton ───────────────────────
const Lazy = ({ children }: { children: React.ReactNode }) => (
<Suspense fallback={<PillarSkeleton />}>{children}</Suspense>
);
// ── Router definition ─────────────────────────────────────────
const router = createBrowserRouter([
// ── Unauthenticated ──
{
path: '/login',
element: <LoginPage />,
},
// ── Authenticated WebOS Shell ──
{
path: '/',
element: (
<AuthGuard>
<AuthenticatedShell />
</AuthGuard>
),
children: [
// Default redirect
{ index: true, element: <Navigate to="/command" replace /> },
// Pillar 1: COMMAND — Morning Briefing (Dashboard + Oracle)
{
path: 'command',
element: <Lazy><CommandPillar /></Lazy>,
},
// Pillar 2: PIPELINE — Deal Intelligence (CRM + Comms + Sentinel)
{
path: 'pipeline',
element: <Lazy><PipelinePillar /></Lazy>,
},
// Client 360 entity — drills in over Pipeline
{
path: 'pipeline/:personId',
element: <Lazy><Client360 /></Lazy>,
},
// Showroom Mode — contextual full-screen overlay
{
path: 'showroom',
element: <Lazy><ShowroomMode /></Lazy>,
},
// Pillar 3: STUDIO — Asset + Marketing Hub (Inventory + Catalyst)
{
path: 'studio',
element: <Lazy><StudioPillar /></Lazy>,
},
// Property Entity — drills in over Studio
{
path: 'studio/:propertyId',
element: <Lazy><PropertyEntity /></Lazy>,
},
],
},
// ── Admin-Only Control Room (RBAC gated at component + API level) ──
{
path: '/control-room',
element: (
<AuthGuard>
<AdminGuard>
<Lazy><ControlRoom /></Lazy>
</AdminGuard>
</AuthGuard>
),
children: [
{ index: true, element: <Navigate to="/control-room/system" replace /> },
{ path: 'system', element: <Lazy><ControlRoom /></Lazy> },
{ path: 'oracle-admin', element: <Lazy><ControlRoom /></Lazy> },
{ path: 'comms-config', element: <Lazy><ControlRoom /></Lazy> },
{ path: 'users', element: <Lazy><ControlRoom /></Lazy> },
{ path: 'models', element: <Lazy><ControlRoom /></Lazy> },
],
},
// ── Public vault links (no auth) ──
{
path: '/vault/:trackingHash',
element: <Lazy><VaultPublicPage /></Lazy>,
},
// ── 404 fallback ──
{
path: '*',
element: <Navigate to="/command" replace />,
},
]);
export default function App() {
return <RouterProvider router={router} />;
}

View File

@@ -0,0 +1,60 @@
/* ControlRoom */
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
.header { display: flex; align-items: center; justify-content: space-between; padding: var(--space-5) var(--space-8); border-bottom: var(--glass-border); flex-shrink: 0; }
.headerLeft { display: flex; align-items: center; gap: var(--space-4); }
.headerIcon { font-size: 28px; color: var(--color-text-tertiary); }
.title { font-size: var(--text-xl); font-weight: var(--font-bold); color: var(--color-text-primary); margin: 0; }
.subtitle { font-size: var(--text-xs); color: var(--color-text-tertiary); margin: 0; }
.body { display: flex; flex: 1; overflow: hidden; }
/* Sidebar */
.sidebar { width: 200px; border-right: var(--glass-border); display: flex; flex-direction: column; padding: var(--space-4); gap: var(--space-1); flex-shrink: 0; }
.sideItem { position: relative; display: flex; align-items: center; gap: var(--space-3); padding: var(--space-3); border-radius: var(--radius-md); background: none; border: none; cursor: pointer; font-family: var(--font-sans); font-size: var(--text-sm); color: var(--color-text-secondary); text-align: left; transition: all var(--duration-fast) var(--ease-standard); width: 100%; }
.sideItem:hover { background: var(--glass-bg); color: var(--color-text-primary); }
.sideActive { color: var(--color-text-primary) !important; background: var(--glass-bg); }
.sideIcon { font-size: 14px; flex-shrink: 0; }
.indicator { position: absolute; left: 0; top: 20%; bottom: 20%; width: 2px; background: var(--color-violet); border-radius: 1px; }
/* Content */
.content { flex: 1; overflow-y: auto; padding: var(--space-8); }
.panelWrap { display: flex; flex-direction: column; gap: 0; }
/* Panel */
.panel { display: flex; flex-direction: column; gap: var(--space-6); max-width: 800px; }
.panelTitle { font-size: var(--text-xl); font-weight: var(--font-bold); color: var(--color-text-primary); margin: 0; }
.panelSubtitle { font-size: var(--text-sm); color: var(--color-text-secondary); margin: -var(--space-4) 0 0; }
.subTitle { font-size: var(--text-sm); font-weight: var(--font-semibold); color: var(--color-text-secondary); margin: 0; }
.adminSection { display: flex; flex-direction: column; gap: var(--space-3); padding: var(--space-5); background: var(--glass-bg); border: var(--glass-border); border-radius: var(--radius-lg); }
/* Service grid */
.serviceGrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: var(--space-3); }
.svcCard { display: flex; flex-direction: column; gap: var(--space-2); }
.svcTop { display: flex; align-items: center; gap: var(--space-2); }
.statusDot { width: 8px; height: 8px; border-radius: var(--radius-full); flex-shrink: 0; }
.svcName { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--color-text-primary); }
.svcMeta { display: flex; flex-direction: column; gap: 2px; }
.svcMeta span { font-size: 10px; color: var(--color-text-secondary); }
.svcMeta code { font-family: var(--font-mono); color: var(--color-text-primary); }
/* GPU */
.gpuCard { display: flex; flex-direction: column; gap: var(--space-4); }
.migSlices { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-3); }
.migSlice { display: flex; flex-direction: column; gap: var(--space-1); padding: var(--space-3); background: var(--glass-bg); border-radius: var(--radius-md); border: var(--glass-border); }
.migLabel { font-size: var(--text-xs); font-weight: var(--font-semibold); color: var(--color-violet-light); }
.migService { font-family: var(--font-mono); font-size: 10px; color: var(--color-text-secondary); }
.migStatus { font-size: 10px; font-weight: var(--font-semibold); }
/* Form */
.formGroup { display: flex; flex-direction: column; gap: var(--space-2); }
.formLabel { font-size: var(--text-xs); font-weight: var(--font-semibold); letter-spacing: var(--tracking-wider); text-transform: uppercase; color: var(--color-text-secondary); }
.formInput, .formSelect { background: var(--glass-bg); border: var(--glass-border); border-radius: var(--radius-lg); padding: var(--space-3) var(--space-4); font-family: var(--font-sans); font-size: var(--text-sm); color: var(--color-text-primary); outline: none; width: 100%; box-sizing: border-box; }
.formInput:focus, .formSelect:focus { border-color: rgba(124,58,237,0.4); box-shadow: 0 0 0 3px var(--color-violet-glow); }
/* Users */
.userToolbar { display: flex; gap: var(--space-3); }
.table { width: 100%; border-collapse: collapse; font-size: var(--text-sm); }
.table th { text-align: left; font-size: 10px; font-weight: var(--font-semibold); letter-spacing: var(--tracking-wider); text-transform: uppercase; color: var(--color-text-tertiary); padding: var(--space-2) var(--space-3); border-bottom: var(--glass-border); }
.table td { padding: var(--space-3); border-bottom: var(--glass-border); color: var(--color-text-secondary); }
.table code { font-family: var(--font-mono); font-size: 10px; color: var(--color-violet-light); }
.tableAction { background: none; border: none; color: var(--color-text-tertiary); font-size: var(--text-xs); cursor: pointer; }
/* Models */
.modelRow { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); margin-bottom: var(--space-2); }
.modelName { font-size: var(--text-sm); font-weight: var(--font-semibold); color: var(--color-text-primary); display: block; }
.modelPath { font-family: var(--font-mono); font-size: 10px; color: var(--color-text-tertiary); display: block; }
.modelRight { display: flex; align-items: center; gap: var(--space-3); flex-shrink: 0; }
.modelSize { font-size: var(--text-xs); color: var(--color-text-secondary); }
/* Meta */
.metaStatus { display: flex; align-items: center; gap: var(--space-3); }

View File

@@ -0,0 +1,297 @@
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { useNavigate } from 'react-router-dom';
import styles from './ControlRoom.module.css';
/**
* ControlRoom
* Admin-only surface. Accessible at /control-room.
* Protected by AdminGuard at router level AND FastAPI RBAC middleware.
*
* Panels (left sidebar → content area):
* System Health — service status, GPU utilization, queue depths
* Oracle Admin — schema catalog, canvas management, MCP tools
* Comms Config — WAHA/Evolution provider, webhook setup
* Users & Roles — broker roster, role assignment, audit log
* Model Hydration — s5cmd sync status, model inventory
* Meta Integration — OAuth, ad account, Lookalike sync
*
* Design: deliberately more "operator" than "broker" — still glassmorphic
* but denser information, monospace for technical values.
*/
type Panel =
| 'system'
| 'oracle-admin'
| 'comms-config'
| 'users'
| 'models'
| 'meta';
const PANELS: { id: Panel; label: string; icon: string }[] = [
{ id: 'system', label: 'System Health', icon: '⬡' },
{ id: 'oracle-admin', label: 'Oracle Admin', icon: '◈' },
{ id: 'comms-config', label: 'Comms Config', icon: '⟐' },
{ id: 'users', label: 'Users & Roles', icon: '⊛' },
{ id: 'models', label: 'Model Hydration', icon: '⊗' },
{ id: 'meta', label: 'Meta Integration', icon: '⊕' },
];
export default function ControlRoom() {
const navigate = useNavigate();
const [active, setActive] = useState<Panel>('system');
return (
<div className={styles.root}>
{/* Header */}
<div className={styles.header}>
<div className={styles.headerLeft}>
<span className={styles.headerIcon}></span>
<div>
<h1 className={styles.title}>Control Room</h1>
<p className={styles.subtitle}>System Administration · Admin Access Only</p>
</div>
</div>
<button className="btn-ghost" onClick={() => navigate('/command')}>
Back to Command
</button>
</div>
<div className={styles.body}>
{/* Left sidebar */}
<nav className={styles.sidebar}>
{PANELS.map(({ id, label, icon }) => (
<button
key={id}
className={`${styles.sideItem} ${active === id ? styles.sideActive : ''}`}
onClick={() => setActive(id)}
aria-current={active === id ? 'page' : undefined}
>
<span className={styles.sideIcon}>{icon}</span>
<span>{label}</span>
{active === id && (
<motion.div
layoutId="cr-indicator"
className={styles.indicator}
transition={{ type: 'spring', stiffness: 400, damping: 35 }}
/>
)}
</button>
))}
</nav>
{/* Panel content */}
<main className={styles.content}>
<AnimatePresence mode="wait">
<motion.div
key={active}
initial={{ opacity: 0, x: 12 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -8 }}
transition={{ duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
className={styles.panelWrap}
>
{active === 'system' && <SystemHealthPanel />}
{active === 'oracle-admin' && <OracleAdminPanel />}
{active === 'comms-config' && <CommsConfigPanel />}
{active === 'users' && <UsersPanel />}
{active === 'models' && <ModelHydrationPanel />}
{active === 'meta' && <MetaIntegrationPanel />}
</motion.div>
</AnimatePresence>
</main>
</div>
</div>
);
}
// ── System Health ─────────────────────────────────────────────
function SystemHealthPanel() {
const services = [
{ name: 'core-api', status: 'healthy', latency: '42ms', replicas: '2/2' },
{ name: 'webos', status: 'healthy', latency: '—', replicas: '2/2' },
{ name: 'media-engine', status: 'healthy', latency: '—', replicas: '1/1' },
{ name: 'postgres', status: 'healthy', latency: '3ms', replicas: '1/1' },
{ name: 'redis', status: 'healthy', latency: '0.4ms', replicas: '1/1' },
];
return (
<div className={styles.panel}>
<h2 className={styles.panelTitle}>System Health</h2>
<div className={styles.serviceGrid}>
{services.map(svc => (
<div key={svc.name} className={`${styles.svcCard} glass-card`}>
<div className={styles.svcTop}>
<span
className={styles.statusDot}
style={{ background: svc.status === 'healthy' ? 'var(--color-green)' : 'var(--color-red)' }}
/>
<code className={styles.svcName}>{svc.name}</code>
</div>
<div className={styles.svcMeta}>
<span>Replicas: <code>{svc.replicas}</code></span>
{svc.latency !== '—' && <span>Latency: <code>{svc.latency}</code></span>}
</div>
</div>
))}
</div>
{/* GPU MIG status */}
<div className={`${styles.gpuCard} glass-card`}>
<h3 className={styles.subTitle}>GPU · RTX 6000 Blackwell · MIG Active</h3>
<div className={styles.migSlices}>
<div className={styles.migSlice}>
<span className={styles.migLabel}>Slice 0 · 48GB</span>
<code className={styles.migService}>SGLang Qwen3.6 35B</code>
<span className={styles.migStatus} style={{ color: 'var(--color-green)' }}> Loaded</span>
</div>
<div className={styles.migSlice}>
<span className={styles.migLabel}>Slice 1 · 48GB</span>
<code className={styles.migService}>ComfyUI Wan 2.2 + Qwen-Image</code>
<span className={styles.migStatus} style={{ color: 'var(--color-green)' }}> Loaded</span>
</div>
</div>
</div>
</div>
);
}
// ── Oracle Admin ──────────────────────────────────────────────
function OracleAdminPanel() {
return (
<div className={styles.panel}>
<h2 className={styles.panelTitle}>Oracle Administration</h2>
<p className={styles.panelSubtitle}>
Canvas management, schema catalog, and MCP tools.
These controls are not visible to broker-role users.
</p>
<div className={styles.adminSection}>
<h3 className={styles.subTitle}>Active Canvas Sessions</h3>
<p className={styles.muted}>Query canvas history, fork/merge, and revision management available here.</p>
<button className="btn-ghost">Open Canvas Manager</button>
</div>
<div className={styles.adminSection}>
<h3 className={styles.subTitle}>Schema Catalog</h3>
<button className="btn-ghost">Browse Schema </button>
<button className="btn-ghost">Run Data Health Check </button>
</div>
<div className={styles.adminSection}>
<h3 className={styles.subTitle}>MCP Tools</h3>
<button className="btn-ghost">View Registered Tools </button>
</div>
</div>
);
}
// ── Comms Config ──────────────────────────────────────────────
function CommsConfigPanel() {
return (
<div className={styles.panel}>
<h2 className={styles.panelTitle}>Comms Configuration</h2>
<p className={styles.panelSubtitle}>
WhatsApp provider setup. Never visible to sales brokers.
</p>
<div className={styles.formGroup}>
<label className={styles.formLabel}>Provider</label>
<select className={styles.formSelect}>
<option value="waha">WAHA</option>
<option value="evolution">Evolution API</option>
</select>
</div>
<div className={styles.formGroup}>
<label className={styles.formLabel}>API Base URL</label>
<input type="text" className={styles.formInput} placeholder="https://waha.internal:3000" />
</div>
<div className={styles.formGroup}>
<label className={styles.formLabel}>API Key</label>
<input type="password" className={styles.formInput} placeholder="••••••••••••••••" />
</div>
<button className="btn-primary">Save Configuration</button>
</div>
);
}
// ── Users & Roles ─────────────────────────────────────────────
function UsersPanel() {
const roles = ['ADMIN', 'SALES_DIRECTOR', 'SALES_BROKER'];
return (
<div className={styles.panel}>
<h2 className={styles.panelTitle}>Users & Roles</h2>
<div className={styles.userToolbar}>
<button className="btn-primary">+ Invite User</button>
</div>
<table className={styles.table}>
<thead>
<tr>
<th>Name</th><th>Email</th><th>Role</th><th>Last Active</th><th></th>
</tr>
</thead>
<tbody>
<tr>
<td>Sagnik</td>
<td>sagnik@desineuron.in</td>
<td><code>ADMIN</code></td>
<td>Now</td>
<td><button className={styles.tableAction}>Edit</button></td>
</tr>
</tbody>
</table>
</div>
);
}
// ── Model Hydration ───────────────────────────────────────────
function ModelHydrationPanel() {
const models = [
{ name: 'Wan 2.2', size: '15 GB', status: 'synced', path: '/opt/models/comfy/wan2.2' },
{ name: 'Qwen-Image 2512', size: '20 GB', status: 'synced', path: '/opt/models/comfy/qwen-image-2512' },
{ name: 'Qwen3.6 35B A3B', size: '70 GB', status: 'synced', path: '/opt/models/llm/qwen3.6-35b-a3b' },
];
return (
<div className={styles.panel}>
<h2 className={styles.panelTitle}>Model Hydration</h2>
<p className={styles.panelSubtitle}>NVMe-backed model store. Re-sync from S3 via s5cmd.</p>
{models.map(m => (
<div key={m.name} className={`${styles.modelRow} glass-card`}>
<div>
<span className={styles.modelName}>{m.name}</span>
<code className={styles.modelPath}>{m.path}</code>
</div>
<div className={styles.modelRight}>
<span className={styles.modelSize}>{m.size}</span>
<span style={{ color: 'var(--color-green)', fontSize: 'var(--text-xs)' }}>
{m.status}
</span>
<button className="btn-ghost">Re-sync</button>
</div>
</div>
))}
<button className="btn-ghost" style={{ marginTop: 'var(--space-4)' }}>
Run Full Hydration
</button>
</div>
);
}
// ── Meta Integration ──────────────────────────────────────────
function MetaIntegrationPanel() {
return (
<div className={styles.panel}>
<h2 className={styles.panelTitle}>Meta Business Integration</h2>
<p className={styles.panelSubtitle}>
OAuth, Ad Account, and Lookalike Audience configuration.
Never visible to broker-role users.
</p>
<div className={styles.metaStatus}>
<span className={styles.statusDot} style={{ background: 'var(--color-amber)' }} />
<span>OAuth not connected</span>
<button className="btn-primary">Connect Meta Business</button>
</div>
<div className={styles.adminSection}>
<h3 className={styles.subTitle}>Ad Account</h3>
<input className={styles.formInput} placeholder="Meta Ad Account ID" />
</div>
<button className="btn-ghost">Save</button>
</div>
);
}

View File

@@ -0,0 +1,210 @@
/* ============================================================
Velocity-OS Design System — Glassmorphic Utility Classes
Import after tokens.css.
============================================================ */
/* ── Glass Panels ────────────────────────────────────────────── */
.glass {
background: var(--glass-bg);
border: var(--glass-border);
backdrop-filter: var(--glass-blur);
-webkit-backdrop-filter: var(--glass-blur);
box-shadow: var(--glass-shadow);
border-radius: var(--radius-xl);
}
.glass:hover {
background: var(--glass-bg-hover);
border-color: rgba(255, 255, 255, 0.15);
}
.glass-heavy {
background: rgba(255, 255, 255, 0.07);
border: var(--glass-border-hi);
backdrop-filter: var(--glass-blur-heavy);
-webkit-backdrop-filter: var(--glass-blur-heavy);
box-shadow: var(--glass-shadow-lg);
border-radius: var(--radius-xl);
}
.glass-card {
background: var(--glass-bg);
border: var(--glass-border);
backdrop-filter: var(--glass-blur);
-webkit-backdrop-filter: var(--glass-blur);
box-shadow: var(--glass-shadow);
border-radius: var(--radius-lg);
padding: var(--space-6);
transition:
background var(--duration-normal) var(--ease-standard),
box-shadow var(--duration-normal) var(--ease-standard),
transform var(--duration-normal) var(--ease-spring);
}
.glass-card:hover {
background: var(--glass-bg-hover);
box-shadow: var(--glass-shadow-lg);
transform: translateY(-1px);
}
/* ── Accent Glow Variants ────────────────────────────────────── */
.glass-violet {
box-shadow: var(--glass-shadow-violet);
border-color: rgba(124, 58, 237, 0.30);
}
.glass-amber {
box-shadow: var(--glass-shadow-amber);
border-color: rgba(245, 158, 11, 0.30);
}
/* ── Shimmer Loading State ───────────────────────────────────── */
.shimmer {
background: linear-gradient(
90deg,
transparent 0%,
rgba(255, 255, 255, 0.06) 50%,
transparent 100%
);
background-size: 200% 100%;
animation: shimmer-sweep 2s ease-in-out infinite;
}
@keyframes shimmer-sweep {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
/* ── Pulse Animations ────────────────────────────────────────── */
.pulse-amber {
animation: pulse-amber 2s ease-in-out infinite;
}
@keyframes pulse-amber {
0%, 100% { box-shadow: 0 0 0px var(--color-amber-glow); }
50% { box-shadow: 0 0 16px var(--color-amber-glow); }
}
.pulse-violet {
animation: pulse-violet 2s ease-in-out infinite;
}
@keyframes pulse-violet {
0%, 100% { box-shadow: 0 0 0px var(--color-violet-glow); }
50% { box-shadow: 0 0 16px var(--color-violet-glow); }
}
.pulse-green {
animation: pulse-green 3s ease-in-out infinite;
}
@keyframes pulse-green {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
/* ── Intent Dot (live indicator) ─────────────────────────────── */
.live-dot {
width: 8px;
height: 8px;
border-radius: var(--radius-full);
background: var(--color-green);
box-shadow: 0 0 8px var(--color-green-glow);
animation: pulse-green 2s ease-in-out infinite;
}
/* ── Typography Utilities ────────────────────────────────────── */
.text-gradient-violet {
background: linear-gradient(135deg, var(--color-violet-light), var(--color-violet));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.text-gradient-amber {
background: linear-gradient(135deg, var(--color-amber-light), var(--color-amber));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* ── Interactive Elements ────────────────────────────────────── */
.btn-primary {
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-6);
background: var(--color-violet);
color: white;
font-family: var(--font-sans);
font-size: var(--text-sm);
font-weight: var(--font-semibold);
border: none;
border-radius: var(--radius-lg);
cursor: pointer;
transition:
background var(--duration-fast) var(--ease-standard),
transform var(--duration-fast) var(--ease-spring),
box-shadow var(--duration-fast) var(--ease-standard);
}
.btn-primary:hover {
background: color-mix(in srgb, var(--color-violet) 85%, white);
box-shadow: var(--glass-shadow-violet);
transform: translateY(-1px);
}
.btn-primary:active {
transform: translateY(0);
}
.btn-ghost {
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-5);
background: transparent;
color: var(--color-text-secondary);
font-family: var(--font-sans);
font-size: var(--text-sm);
font-weight: var(--font-medium);
border: var(--glass-border);
border-radius: var(--radius-lg);
cursor: pointer;
transition: all var(--duration-fast) var(--ease-standard);
}
.btn-ghost:hover {
background: var(--glass-bg-hover);
color: var(--color-text-primary);
border-color: rgba(255, 255, 255, 0.20);
}
/* ── Badge / Chip ────────────────────────────────────────────── */
.badge {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-3);
font-size: var(--text-xs);
font-weight: var(--font-semibold);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
border-radius: var(--radius-full);
border: 1px solid transparent;
}
.badge-high-intent {
background: rgba(245, 158, 11, 0.15);
color: var(--color-amber-light);
border-color: rgba(245, 158, 11, 0.30);
}
.badge-live {
background: rgba(16, 185, 129, 0.15);
color: var(--color-green);
border-color: rgba(16, 185, 129, 0.30);
}
.badge-ai {
background: rgba(124, 58, 237, 0.15);
color: var(--color-violet-light);
border-color: rgba(124, 58, 237, 0.30);
}

View File

@@ -0,0 +1,128 @@
/* ============================================================
Velocity-OS Design System — Token Definitions
All CSS custom properties. Import this first in main.css.
============================================================ */
:root {
/* ── Color: Base Palette ─────────────────────────────────── */
--color-base-bg: hsl(225, 25%, 8%); /* Deep navy */
--color-base-surface: hsl(225, 20%, 11%); /* Card surface */
--color-base-raised: hsl(225, 18%, 14%); /* Elevated surface */
--color-base-border: rgba(255, 255, 255, 0.10);
--color-base-border-hi: rgba(255, 255, 255, 0.18);
/* ── Color: Text ─────────────────────────────────────────── */
--color-text-primary: hsl(220, 20%, 96%);
--color-text-secondary: hsl(220, 12%, 65%);
--color-text-tertiary: hsl(220, 10%, 45%);
--color-text-inverse: hsl(225, 25%, 8%);
/* ── Color: Brand Accents ────────────────────────────────── */
--color-violet: #7C3AED; /* Primary AI/action */
--color-violet-light: #A78BFA;
--color-violet-glow: rgba(124, 58, 237, 0.35);
--color-amber: #F59E0B; /* Intent / QD / alerts */
--color-amber-light: #FCD34D;
--color-amber-glow: rgba(245, 158, 11, 0.35);
--color-green: #10B981; /* Success / active */
--color-green-glow: rgba(16, 185, 129, 0.30);
--color-red: #EF4444; /* Error / critical */
--color-red-glow: rgba(239, 68, 68, 0.30);
/* ── Color: QD Score Semantic ────────────────────────────── */
--color-qd-high: var(--color-green); /* ≥70 */
--color-qd-mid: var(--color-amber); /* 4069 */
--color-qd-low: var(--color-red); /* <40 */
/* ── Glassmorphism ───────────────────────────────────────── */
--glass-bg: rgba(255, 255, 255, 0.05);
--glass-bg-hover: rgba(255, 255, 255, 0.08);
--glass-bg-active: rgba(255, 255, 255, 0.11);
--glass-border: 1px solid rgba(255, 255, 255, 0.10);
--glass-border-hi: 1px solid rgba(255, 255, 255, 0.20);
--glass-blur: blur(20px);
--glass-blur-heavy: blur(40px);
--glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.40);
--glass-shadow-lg: 0 16px 64px rgba(0, 0, 0, 0.50);
--glass-shadow-violet: 0 8px 32px var(--color-violet-glow);
--glass-shadow-amber: 0 8px 32px var(--color-amber-glow);
/* ── Typography ──────────────────────────────────────────── */
--font-sans: 'Inter', 'Outfit', system-ui, -apple-system, sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
--text-xs: 0.75rem; /* 12px */
--text-sm: 0.875rem; /* 14px */
--text-base: 1rem; /* 16px */
--text-lg: 1.125rem; /* 18px */
--text-xl: 1.25rem; /* 20px */
--text-2xl: 1.5rem; /* 24px */
--text-3xl: 1.875rem; /* 30px */
--text-4xl: 2.25rem; /* 36px */
--text-5xl: 3rem; /* 48px */
--font-light: 300;
--font-normal: 400;
--font-medium: 500;
--font-semibold: 600;
--font-bold: 700;
--leading-tight: 1.25;
--leading-normal: 1.5;
--leading-relaxed:1.75;
--tracking-tight: -0.025em;
--tracking-normal: 0em;
--tracking-wide: 0.05em;
--tracking-wider: 0.1em;
/* ── Spacing Scale ───────────────────────────────────────── */
--space-1: 0.25rem; /* 4px */
--space-2: 0.5rem; /* 8px */
--space-3: 0.75rem; /* 12px */
--space-4: 1rem; /* 16px */
--space-5: 1.25rem; /* 20px */
--space-6: 1.5rem; /* 24px */
--space-8: 2rem; /* 32px */
--space-10: 2.5rem; /* 40px */
--space-12: 3rem; /* 48px */
--space-16: 4rem; /* 64px */
--space-20: 5rem; /* 80px */
/* ── Border Radius ───────────────────────────────────────── */
--radius-sm: 0.375rem; /* 6px */
--radius-md: 0.625rem; /* 10px */
--radius-lg: 0.875rem; /* 14px */
--radius-xl: 1.25rem; /* 20px */
--radius-2xl: 1.5rem; /* 24px */
--radius-full: 9999px;
/* ── Motion ──────────────────────────────────────────────── */
--ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
--ease-in: cubic-bezier(0.4, 0, 1, 1);
--ease-out: cubic-bezier(0, 0, 0.2, 1);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); /* Overshoot */
--duration-fast: 150ms;
--duration-normal: 300ms;
--duration-slow: 500ms;
--duration-reveal: 600ms;
/* ── Z-Index Scale ───────────────────────────────────────── */
--z-base: 0;
--z-raised: 10;
--z-overlay: 100;
--z-modal: 200;
--z-toast: 300;
--z-nav: 400;
--z-intelligence-rail: 450;
/* ── Layout ──────────────────────────────────────────────── */
--nav-rail-width: 56px;
--intel-rail-width: 48px;
--sidebar-width: 280px;
--header-height: 64px;
}

View File

@@ -0,0 +1,81 @@
/* IntelligenceRail */
.rail {
width: var(--intel-rail-width);
height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-3);
padding: var(--space-6) 0;
background: var(--color-base-surface);
border-left: var(--glass-border);
flex-shrink: 0;
}
.railBtn {
position: relative;
width: 36px; height: 36px;
display: flex; align-items: center; justify-content: center;
background: transparent; border: none; cursor: pointer;
border-radius: var(--radius-md);
color: var(--color-text-tertiary);
font-size: 16px;
transition: all var(--duration-fast) var(--ease-standard);
}
.railBtn:hover { background: var(--glass-bg); color: var(--color-text-secondary); }
.activeDot {
position: absolute; top: 4px; right: 4px;
width: 6px; height: 6px;
background: var(--color-violet);
border-radius: var(--radius-full);
}
.alertBadge {
position: absolute; top: 2px; right: 2px;
width: 16px; height: 16px;
background: var(--color-red);
border-radius: var(--radius-full);
font-size: 9px; color: white; font-weight: var(--font-bold);
display: flex; align-items: center; justify-content: center;
}
.aiBreathing { animation: aiBreath 2s ease-in-out infinite; }
@keyframes aiBreath {
0%, 100% { color: var(--color-text-tertiary); }
50% { color: var(--color-violet-light); }
}
.showroomActive { color: var(--color-green) !important; }
.showroomDotIdle {
width: 8px; height: 8px;
border-radius: var(--radius-full);
background: var(--color-text-tertiary);
}
/* Panel */
.backdrop {
position: fixed; inset: 0;
background: rgba(0,0,0,0.3);
backdrop-filter: blur(4px);
z-index: calc(var(--z-intelligence-rail) - 1);
}
.panel {
position: fixed;
top: 0; right: var(--intel-rail-width); bottom: 0;
width: min(380px, 40vw);
display: flex; flex-direction: column;
z-index: var(--z-intelligence-rail);
border-radius: var(--radius-xl) 0 0 var(--radius-xl);
}
.panelHeader {
display: flex; align-items: center; justify-content: space-between;
padding: var(--space-5) var(--space-6);
border-bottom: var(--glass-border);
}
.panelTitle {
font-size: var(--text-sm);
font-weight: var(--font-semibold);
color: var(--color-text-primary);
}
.closeBtn {
background: none; border: none; cursor: pointer;
color: var(--color-text-tertiary); font-size: 14px;
padding: var(--space-1); border-radius: var(--radius-sm);
}
.closeBtn:hover { color: var(--color-text-primary); background: var(--glass-bg); }
.panelContent { flex: 1; overflow-y: auto; padding: var(--space-5) var(--space-6); }

View File

@@ -0,0 +1,182 @@
import { useState, useEffect, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { useSentinelStore } from "../store/sentinelStore";
import styles from './IntelligenceRail.module.css';
/**
* IntelligenceRail
* Always-visible 48px vertical strip on the far right of the shell.
* Present across all three pillars — never navigates away.
*
* Icons (top to bottom):
* ✦ AI pulse — breathing when insights available
* 🔔 Alerts — Sentinel events + intent signals
* 📊 KPI — sparkline on hover
* ◉ Showroom — green when CCTV session active
*
* Tapping any icon: 40%-width slide-in panel from right edge.
* Dismisses by clicking away. No modal. No new page.
*/
type RailPanel = 'ai' | 'alerts' | 'kpi' | 'showroom' | null;
export function IntelligenceRail() {
const [openPanel, setOpenPanel] = useState<RailPanel>(null);
const { isShowroomActive, alertCount, hasInsights } = useSentinelStore();
const panelRef = useRef<HTMLDivElement>(null);
// Click-away dismiss
useEffect(() => {
if (!openPanel) return;
const handler = (e: MouseEvent) => {
if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
setOpenPanel(null);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [openPanel]);
const toggle = (panel: RailPanel) =>
setOpenPanel(prev => prev === panel ? null : panel);
return (
<>
{/* ── Fixed right rail ─────────────────────────────── */}
<aside
className={styles.rail}
aria-label="Intelligence Rail"
style={{ zIndex: 'var(--z-intelligence-rail)' }}
>
{/* AI Insights */}
<button
className={`${styles.railBtn} ${hasInsights ? styles.aiBreathing : ''}`}
onClick={() => toggle('ai')}
aria-label="AI Insights"
title="AI Insights"
>
<span></span>
{hasInsights && <span className={styles.activeDot} />}
</button>
{/* Alert count */}
<button
className={styles.railBtn}
onClick={() => toggle('alerts')}
aria-label={`${alertCount} alerts`}
title="Alerts"
>
<span>🔔</span>
{alertCount > 0 && (
<span className={styles.alertBadge}>{alertCount}</span>
)}
</button>
{/* KPI sparkline */}
<button
className={styles.railBtn}
onClick={() => toggle('kpi')}
aria-label="KPI Overview"
title="KPI Overview"
>
<span>📊</span>
</button>
{/* Showroom live indicator */}
<button
className={`${styles.railBtn} ${isShowroomActive ? styles.showroomActive : ''}`}
onClick={() => toggle('showroom')}
aria-label={isShowroomActive ? 'Showroom: Live' : 'Showroom: Idle'}
title="Showroom"
>
<span className={isShowroomActive ? 'live-dot pulse-green' : styles.showroomDotIdle} />
</button>
</aside>
{/* ── Slide-in panel ────────────────────────────────── */}
<AnimatePresence>
{openPanel && (
<>
{/* Blurred backdrop (partial, does not cover nav) */}
<motion.div
className={styles.backdrop}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
/>
{/* Panel */}
<motion.div
ref={panelRef}
className={`${styles.panel} glass-heavy`}
initial={{ x: '100%', opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: '100%', opacity: 0 }}
transition={{ type: 'spring', stiffness: 320, damping: 32 }}
>
<div className={styles.panelHeader}>
<span className={styles.panelTitle}>
{openPanel === 'ai' && '✦ AI Insights'}
{openPanel === 'alerts' && '🔔 Alerts'}
{openPanel === 'kpi' && '📊 KPI Overview'}
{openPanel === 'showroom' && '◉ Showroom'}
</span>
<button
className={styles.closeBtn}
onClick={() => setOpenPanel(null)}
aria-label="Close panel"
></button>
</div>
<div className={styles.panelContent}>
{openPanel === 'ai' && <AIInsightsPanel />}
{openPanel === 'alerts' && <AlertsPanel />}
{openPanel === 'kpi' && <KPIPanel />}
{openPanel === 'showroom' && <ShowroomPanel />}
</div>
</motion.div>
</>
)}
</AnimatePresence>
</>
);
}
// ── Panel content stubs (expanded in P9 integration phase) ───
function AIInsightsPanel() {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
<p style={{ color: 'var(--color-text-secondary)', fontSize: 'var(--text-sm)' }}>
AI is analyzing your pipeline...
</p>
</div>
);
}
function AlertsPanel() {
return (
<div style={{ color: 'var(--color-text-secondary)', fontSize: 'var(--text-sm)' }}>
No active alerts.
</div>
);
}
function KPIPanel() {
return (
<div style={{ color: 'var(--color-text-secondary)', fontSize: 'var(--text-sm)' }}>
Loading KPI data...
</div>
);
}
function ShowroomPanel() {
const { isShowroomActive, currentSessionDuration } = useSentinelStore();
return (
<div style={{ color: 'var(--color-text-secondary)', fontSize: 'var(--text-sm)' }}>
{isShowroomActive
? `Live session: ${currentSessionDuration}`
: 'No active showroom session.'}
</div>
);
}

View File

@@ -0,0 +1,18 @@
/* SentinelAlertBanner */
.banner {
position: absolute;
top: var(--space-4); left: 50%;
transform: translateX(-50%);
z-index: var(--z-toast);
display: flex; align-items: center; justify-content: space-between;
gap: var(--space-4);
padding: var(--space-3) var(--space-5);
border-radius: var(--radius-xl);
min-width: 480px; max-width: 90vw;
box-shadow: var(--glass-shadow-amber);
}
.content { display: flex; align-items: center; gap: var(--space-3); }
.text { display: flex; flex-direction: column; gap: 2px; }
.title { font-size: var(--text-sm); font-weight: var(--font-semibold); color: var(--color-text-primary); }
.sub { font-size: var(--text-xs); color: var(--color-text-secondary); }
.actions { display: flex; gap: var(--space-2); flex-shrink: 0; }

View File

@@ -0,0 +1,99 @@
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { useSentinelStore } from '../store/sentinelStore';
import styles from './SentinelAlertBanner.module.css';
/**
* SentinelAlertBanner
* Non-blocking notification ribbon that slides from the top edge
* when a CCTV event fires (showroom visitor detected).
*
* Choreography from UX Master Plan §4.2:
* T+0ms — WebSocket event fires
* T+200ms — Ribbon slides in: translateY(-100%) → translateY(0)
* spring(stiffness:300, damping:28)
* Never blocks the current view.
* "Open Sentinel" → navigate to /showroom
* Auto-dismiss after 30s if broker does not interact.
*/
export function SentinelAlertBanner() {
const navigate = useNavigate();
const {
pendingAlert,
clearPendingAlert,
} = useSentinelStore();
// Auto-dismiss after 30 seconds
useEffect(() => {
if (!pendingAlert) return;
const timer = setTimeout(clearPendingAlert, 30_000);
return () => clearTimeout(timer);
}, [pendingAlert, clearPendingAlert]);
const handleOpenShowroom = () => {
clearPendingAlert();
navigate('/showroom');
};
return (
<AnimatePresence>
{pendingAlert && (
<motion.div
className={`${styles.banner} glass-amber`}
role="alert"
aria-live="assertive"
initial={{ y: '-100%', opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: '-100%', opacity: 0 }}
transition={{
type: 'spring',
stiffness: 300,
damping: 28,
}}
>
<div className={styles.content}>
{/* Live indicator */}
<span className="live-dot" style={{ background: 'var(--color-amber)' }} />
{/* Alert text */}
<div className={styles.text}>
<span className={styles.title}>
{pendingAlert.matchedName
? `${pendingAlert.matchedName} detected in showroom`
: 'Visitor detected in Showroom Zone A'
}
</span>
<span className={styles.sub}>
{pendingAlert.confidence
? `${pendingAlert.matchedName
? 'Match'
: 'Face'} · ${pendingAlert.confidence}% confidence`
: 'Unknown contact'
}
</span>
</div>
</div>
{/* Actions */}
<div className={styles.actions}>
<button
className="btn-primary"
onClick={handleOpenShowroom}
aria-label="Open Showroom Mode"
>
Open Sentinel
</button>
<button
className="btn-ghost"
onClick={clearPendingAlert}
aria-label="Dismiss alert"
>
Dismiss
</button>
</div>
</motion.div>
)}
</AnimatePresence>
);
}

29
webos/src/main.tsx Normal file
View File

@@ -0,0 +1,29 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import App from './App';
import './design-system/tokens.css';
import './design-system/glass.css';
// ── TanStack Query client ─────────────────────────────────────
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 2,
refetchOnWindowFocus: false,
throwOnError: false,
},
mutations: {
retry: 0,
},
},
});
// ── Root mount ────────────────────────────────────────────────
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</React.StrictMode>
);

View File

@@ -0,0 +1,12 @@
/* AIPriorityCards */
.section { display: flex; flex-direction: column; gap: var(--space-4); }
.sectionTitle { font-size: var(--text-xs); font-weight: var(--font-semibold); letter-spacing: var(--tracking-wider); text-transform: uppercase; color: var(--color-text-tertiary); margin: 0; display: flex; align-items: center; gap: var(--space-2); }
.aiStar { color: var(--color-violet-light); }
.grid { display: flex; flex-direction: column; gap: var(--space-3); }
.card { display: flex; align-items: center; gap: var(--space-4); padding: var(--space-4) var(--space-5); position: relative; }
.urgencyDot { width: 8px; height: 8px; border-radius: var(--radius-full); flex-shrink: 0; }
.content { flex: 1; display: flex; flex-direction: column; gap: var(--space-1); min-width: 0; }
.headline { font-size: var(--text-sm); font-weight: var(--font-medium); color: var(--color-text-primary); margin: 0; }
.sublabel { font-size: var(--text-xs); color: var(--color-text-secondary); margin: 0; }
.skeleton { height: 72px; border-radius: var(--radius-lg); }
.empty { font-size: var(--text-sm); color: var(--color-text-tertiary); }

View File

@@ -0,0 +1,106 @@
import { motion, AnimatePresence } from 'framer-motion';
import { useNavigate } from 'react-router-dom';
import styles from './AIPriorityCards.module.css';
interface PriorityCard {
id: string;
type: 'qd_surge' | 'vault_engagement' | 'follow_up' | 'site_visit';
headline: string; // e.g. "Arjun's QD jumped 22 points — call now"
sublabel?: string; // e.g. "QD 84 · Site Visit Tomorrow"
personId?: string;
personName?: string;
cta: string; // e.g. "Call Now" | "View Profile" | "Send WhatsApp"
urgency: 'high' | 'medium' | 'low';
}
interface AIPriorityCardsProps {
cards?: PriorityCard[];
isLoading: boolean;
}
const URGENCY_COLOR: Record<PriorityCard['urgency'], string> = {
high: 'var(--color-amber)',
medium: 'var(--color-violet-light)',
low: 'var(--color-text-secondary)',
};
/**
* AIPriorityCards
* 3 AI-surfaced signals. System decides what matters; broker acts.
* No configuration, no filter UI — pure intent-driven surface.
* Stagger-reveals on mount. Urgency color-codes the left accent.
*/
export function AIPriorityCards({ cards, isLoading }: AIPriorityCardsProps) {
const navigate = useNavigate();
if (isLoading) {
return (
<div className={styles.section}>
<h2 className={styles.sectionTitle}>Priority Signals</h2>
<div className={styles.grid}>
{[0, 1, 2].map(i => (
<div key={i} className={`${styles.skeleton} glass shimmer`} />
))}
</div>
</div>
);
}
if (!cards || cards.length === 0) {
return (
<div className={styles.section}>
<h2 className={styles.sectionTitle}>Priority Signals</h2>
<p className={styles.empty}>Pipeline is calm no urgent signals right now.</p>
</div>
);
}
const handleCta = (card: PriorityCard) => {
if (card.personId) navigate(`/pipeline/${card.personId}`);
};
return (
<div className={styles.section}>
<h2 className={styles.sectionTitle}>
<span className={styles.aiStar}></span> Priority Signals
</h2>
<div className={styles.grid}>
{cards.slice(0, 3).map((card, i) => (
<motion.div
key={card.id}
className={`${styles.card} glass-card`}
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.1, duration: 0.35, ease: [0.4, 0, 0.2, 1] }}
style={{
borderLeft: `2px solid ${URGENCY_COLOR[card.urgency]}`,
}}
>
{/* Urgency dot */}
<span
className={styles.urgencyDot}
style={{ background: URGENCY_COLOR[card.urgency] }}
/>
{/* Content */}
<div className={styles.content}>
<p className={styles.headline}>{card.headline}</p>
{card.sublabel && (
<p className={styles.sublabel}>{card.sublabel}</p>
)}
</div>
{/* CTA */}
<button
className="btn-ghost"
onClick={() => handleCta(card)}
aria-label={`${card.cta}${card.personName ?? 'client'}`}
>
{card.cta}
</button>
</motion.div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,5 @@
/* CommandPillar */
.root { padding: var(--space-8); display: flex; flex-direction: column; gap: var(--space-8); max-width: 1100px; margin: 0 auto; }
.greeting { display: flex; flex-direction: column; gap: var(--space-1); }
.greetingText { font-size: var(--text-3xl); font-weight: var(--font-bold); color: var(--color-text-primary); letter-spacing: var(--tracking-tight); margin: 0; }
.greetingDate { font-size: var(--text-sm); color: var(--color-text-tertiary); }

View File

@@ -0,0 +1,71 @@
import { useState, useEffect, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { KpiHero } from './KpiHero';
import { OracleBar } from './OracleBar';
import { AIPriorityCards } from './AIPriorityCards';
import { PipelinePulse } from './PipelinePulse';
import { useCommandData } from '../../shared/hooks/useCommandData';
import styles from './CommandPillar.module.css';
/**
* CommandPillar — Pillar 1: The Morning Briefing
* Merges: Dashboard + Oracle
* Core question: "What matters today?"
*
* Layout (top to bottom):
* 1. KpiHero — 3 primary KPIs in confident typography
* 2. OracleBar — NL prompt (persistent, prominent)
* 3. AIPriorityCards — 3 AI-surfaced priority signals
* 4. PipelinePulse — Journey River widget (scrollable)
*/
export default function CommandPillar() {
const { data, isLoading } = useCommandData();
const greeting = useGreeting();
return (
<div className={styles.root}>
{/* Greeting header */}
<motion.div
className={styles.greeting}
initial={{ opacity: 0, y: -12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, ease: [0.4, 0, 0.2, 1] }}
>
<h1 className={styles.greetingText}>{greeting}</h1>
<span className={styles.greetingDate}>
{new Date().toLocaleDateString('en-IN', {
weekday: 'long', month: 'long', day: 'numeric',
})}
</span>
</motion.div>
{/* KPI Hero — always visible, never loading skeleton */}
<KpiHero
kpis={data?.kpis}
isLoading={isLoading}
/>
{/* Oracle NL prompt bar — prominent, persistent */}
<OracleBar />
{/* AI Priority Cards — system-surfaced signals */}
<AIPriorityCards
cards={data?.priorityCards}
isLoading={isLoading}
/>
{/* Pipeline Pulse — Journey River widget */}
<PipelinePulse
stages={data?.pipelineStages}
isLoading={isLoading}
/>
</div>
);
}
function useGreeting(): string {
const hour = new Date().getHours();
if (hour < 12) return 'Good morning.';
if (hour < 17) return 'Good afternoon.';
return 'Good evening.';
}

View File

@@ -0,0 +1,10 @@
/* KpiHero */
.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--space-4); }
@media (max-width: 700px) { .grid { grid-template-columns: 1fr; } }
.card { display: flex; flex-direction: column; gap: var(--space-2); }
.label { font-size: var(--text-xs); font-weight: var(--font-semibold); letter-spacing: var(--tracking-wider); text-transform: uppercase; color: var(--color-text-tertiary); }
.valueRow { display: flex; align-items: baseline; gap: var(--space-3); }
.value { font-size: var(--text-4xl); font-weight: var(--font-bold); color: var(--color-text-primary); letter-spacing: var(--tracking-tight); }
.delta { font-size: var(--text-sm); font-weight: var(--font-medium); }
.sublabel { font-size: var(--text-xs); color: var(--color-text-tertiary); }
.skeleton { height: 48px; width: 120px; border-radius: var(--radius-md); }

View File

@@ -0,0 +1,62 @@
import { motion } from 'framer-motion';
import styles from './KpiHero.module.css';
interface Kpi {
label: string;
value: string;
delta?: string;
deltaPositive?: boolean;
sublabel?: string;
}
interface KpiHeroProps {
kpis?: Kpi[];
isLoading: boolean;
}
const DEFAULT_KPIS: Kpi[] = [
{ label: 'Active Leads', value: '—', sublabel: 'in pipeline' },
{ label: 'Site Visits', value: '—', sublabel: 'this month' },
{ label: 'Revenue Pipeline', value: '—', sublabel: 'projected' },
];
/**
* KpiHero
* Three primary KPIs rendered in large, confident typography.
* Stagger-animates on mount. Delta shown with directional color.
*/
export function KpiHero({ kpis = DEFAULT_KPIS, isLoading }: KpiHeroProps) {
return (
<div className={styles.grid}>
{kpis.map((kpi, i) => (
<motion.div
key={kpi.label}
className={`${styles.card} glass-card`}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.08, duration: 0.4, ease: [0.4, 0, 0.2, 1] }}
>
<span className={styles.label}>{kpi.label}</span>
<div className={styles.valueRow}>
{isLoading ? (
<div className={`${styles.skeleton} shimmer`} />
) : (
<span className={styles.value}>{kpi.value}</span>
)}
{kpi.delta && !isLoading && (
<span
className={styles.delta}
style={{ color: kpi.deltaPositive ? 'var(--color-green)' : 'var(--color-red)' }}
>
{kpi.deltaPositive ? '↑' : '↓'} {kpi.delta}
</span>
)}
</div>
{kpi.sublabel && (
<span className={styles.sublabel}>{kpi.sublabel}</span>
)}
</motion.div>
))}
</div>
);
}

View File

@@ -0,0 +1,12 @@
/* OracleBar */
.wrapper { display: flex; flex-direction: column; gap: var(--space-3); }
.bar { display: flex; align-items: center; gap: var(--space-3); padding: 0 var(--space-4); border-radius: var(--radius-xl); cursor: text; transition: all var(--duration-normal) var(--ease-standard); }
.focused { border-color: rgba(124,58,237,0.40) !important; box-shadow: 0 0 0 3px var(--color-violet-glow), var(--glass-shadow) !important; }
.logoMark { color: var(--color-violet-light); font-size: 18px; flex-shrink: 0; }
.inputWrap { flex: 1; position: relative; display: flex; align-items: center; }
.ghostText { position: absolute; left: 0; top: 50%; transform: translateY(-50%); color: var(--color-text-tertiary); font-size: var(--text-base); pointer-events: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; width: 100%; }
.input { width: 100%; background: none; border: none; outline: none; font-family: var(--font-sans); font-size: var(--text-base); color: var(--color-text-primary); resize: none; min-height: 24px; }
.submitBtn { width: 32px; height: 32px; border-radius: var(--radius-md); background: var(--color-violet); border: none; color: white; font-size: 16px; cursor: pointer; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.clearBtn { background: none; border: none; color: var(--color-text-tertiary); cursor: pointer; font-size: 14px; padding: var(--space-1); border-radius: var(--radius-sm); }
.clearBtn:hover { color: var(--color-text-primary); }
.placeholderCard { height: 120px; border-radius: var(--radius-xl); }

View File

@@ -0,0 +1,170 @@
import { useState, useRef, useCallback, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { OracleResultCard } from './OracleResultCard';
import styles from './OracleBar.module.css';
type QueryState = 'idle' | 'thinking' | 'result';
/**
* OracleBar
* The persistent natural-language prompt bar for Pillar 1.
* UX Master Plan §4.5 choreography:
* - Bar expands 48px → 80px on focus (200ms)
* - Ghost-text autocomplete suggestion
* - "Thinking" state: Velocity logo pulses inside bar
* - Placeholder result card at opacity 0.3, scale 0.98 (layout reserved)
* - Response: stagger-fades each data row at 100ms intervals
* - Oracle machinery (canvas ID, fork/merge, schema catalog) NEVER shown
*/
const SUGGESTIONS = [
'Which leads are closest to closing this week?',
'Show me site visit trends for April',
'Who hasn\'t been contacted in 7 days?',
'What is the average QD score by project?',
];
export function OracleBar() {
const [queryState, setQueryState] = useState<QueryState>('idle');
const [query, setQuery] = useState('');
const [isFocused, setIsFocused] = useState(false);
const [suggestion, setSuggestion] = useState('');
const [result, setResult] = useState<any>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
// Rotate ghost-text suggestions
useEffect(() => {
const idx = Math.floor(Math.random() * SUGGESTIONS.length);
setSuggestion(SUGGESTIONS[idx]);
}, []);
const handleSubmit = useCallback(async () => {
if (!query.trim() || queryState === 'thinking') return;
setQueryState('thinking');
setResult(null);
try {
const resp = await fetch('/api/oracle/query', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, context: 'command_pillar' }),
});
const data = await resp.json();
setResult(data);
setQueryState('result');
} catch {
setQueryState('idle');
}
}, [query, queryState]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
if (e.key === 'Escape') {
setIsFocused(false);
inputRef.current?.blur();
}
};
const handleReset = () => {
setQuery('');
setResult(null);
setQueryState('idle');
inputRef.current?.focus();
};
return (
<div className={styles.wrapper}>
{/* ── Prompt bar ─────────────────────────────────────── */}
<motion.div
className={`${styles.bar} ${isFocused ? styles.focused : ''} glass`}
animate={{ height: isFocused ? 80 : 48 }}
transition={{ duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
>
{/* Velocity logo / thinking pulse */}
<motion.div
className={styles.logoMark}
animate={queryState === 'thinking'
? { opacity: [0.4, 1, 0.4], scale: [0.95, 1.05, 0.95] }
: { opacity: 1, scale: 1 }
}
transition={queryState === 'thinking'
? { duration: 1.5, repeat: Infinity }
: { duration: 0.2 }
}
>
<span></span>
</motion.div>
{/* Text input */}
<div className={styles.inputWrap}>
{!query && !isFocused && (
<span className={styles.ghostText}>{suggestion}</span>
)}
<textarea
ref={inputRef}
className={styles.input}
value={query}
onChange={e => setQuery(e.target.value)}
onFocus={() => setIsFocused(true)}
onBlur={() => !query && setIsFocused(false)}
onKeyDown={handleKeyDown}
placeholder=""
rows={1}
disabled={queryState === 'thinking'}
aria-label="Ask Oracle anything about your pipeline"
/>
</div>
{/* Submit / clear */}
<AnimatePresence mode="wait">
{query && queryState !== 'thinking' && (
<motion.button
className={styles.submitBtn}
onClick={handleSubmit}
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0, opacity: 0 }}
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
aria-label="Submit query"
>
</motion.button>
)}
{(result || queryState === 'result') && (
<motion.button
className={styles.clearBtn}
onClick={handleReset}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
aria-label="Clear result"
>
</motion.button>
)}
</AnimatePresence>
</motion.div>
{/* ── Placeholder card (layout reserved before content) ─ */}
<AnimatePresence mode="wait">
{queryState === 'thinking' && (
<motion.div
className={`${styles.placeholderCard} glass shimmer`}
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 0.35, scale: 0.99 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
aria-hidden
/>
)}
{/* ── Result card ──────────────────────────────────── */}
{queryState === 'result' && result && (
<OracleResultCard result={result} query={query} />
)}
</AnimatePresence>
</div>
);
}

View File

@@ -0,0 +1,12 @@
/* OracleResultCard */
.card { display: flex; flex-direction: column; gap: var(--space-4); }
.summary { font-size: var(--text-base); color: var(--color-text-primary); margin: 0; line-height: var(--leading-relaxed); }
.aiStar { color: var(--color-violet-light); margin-right: var(--space-2); }
.list { display: flex; flex-direction: column; gap: var(--space-2); }
.listRow { display: flex; align-items: center; justify-content: space-between; padding: var(--space-2) 0; border-bottom: var(--glass-border); }
.rowLabel { font-size: var(--text-sm); color: var(--color-text-secondary); }
.rowValue { font-size: var(--text-sm); font-weight: var(--font-semibold); }
.metric { display: flex; flex-direction: column; align-items: center; gap: var(--space-2); padding: var(--space-4) 0; }
.metricValue { font-size: var(--text-5xl); font-weight: var(--font-bold); color: var(--color-text-primary); }
.metricLabel { font-size: var(--text-sm); color: var(--color-text-secondary); }
.actionRow { display: flex; gap: var(--space-3); flex-wrap: wrap; }

View File

@@ -0,0 +1,113 @@
import { motion } from 'framer-motion';
import { useNavigate } from 'react-router-dom';
import styles from './OracleResultCard.module.css';
/**
* OracleResultCard
* Renders the Oracle response. Each data row stagger-fades at 100ms intervals.
* Always shows an AI summary line above the data.
* NEVER shows: canvas ID, fork/merge controls, schema catalog, MCP tools,
* revision history, query plan, execution stats, or raw SQL.
*/
interface OracleResult {
summary: string; // AI-generated plain-English summary
visualization?: {
type: 'bar' | 'list' | 'metric' | 'table';
data: any[];
xKey?: string;
yKey?: string;
columns?: string[];
};
actions?: {
label: string;
personId?: string;
type: 'view_client' | 'generic';
}[];
}
interface OracleResultCardProps {
result: OracleResult;
query: string;
}
export function OracleResultCard({ result, query }: OracleResultCardProps) {
const navigate = useNavigate();
const rows = result.visualization?.data ?? [];
return (
<motion.div
className={`${styles.card} glass-card`}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: [0.4, 0, 0.2, 1] }}
>
{/* AI summary line — always first */}
<motion.p
className={styles.summary}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.1 }}
>
<span className={styles.aiStar}></span> {result.summary}
</motion.p>
{/* Data rows stagger-fade */}
{result.visualization?.type === 'list' && (
<div className={styles.list}>
{rows.map((row: any, i: number) => (
<motion.div
key={i}
className={styles.listRow}
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.15 + i * 0.1, duration: 0.25 }}
>
<span className={styles.rowLabel}>{row.label ?? row.name}</span>
<span className={styles.rowValue}
style={{ color: 'var(--color-violet-light)' }}>
{row.value ?? row.score ?? row.count}
</span>
</motion.div>
))}
</div>
)}
{result.visualization?.type === 'metric' && rows[0] && (
<motion.div
className={styles.metric}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.2 }}
>
<span className={styles.metricValue}>{rows[0].value}</span>
<span className={styles.metricLabel}>{rows[0].label}</span>
</motion.div>
)}
{/* Contextual actions */}
{result.actions && result.actions.length > 0 && (
<motion.div
className={styles.actionRow}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.15 + rows.length * 0.1 }}
>
{result.actions.map((action, i) => (
<button
key={i}
className="btn-ghost"
onClick={() => {
if (action.type === 'view_client' && action.personId) {
navigate(`/pipeline/${action.personId}`);
}
}}
>
{action.label}
</button>
))}
</motion.div>
)}
</motion.div>
);
}

View File

@@ -0,0 +1,13 @@
/* PipelinePulse */
.section { display: flex; flex-direction: column; gap: var(--space-4); }
.sectionTitle { font-size: var(--text-xs); font-weight: var(--font-semibold); letter-spacing: var(--tracking-wider); text-transform: uppercase; color: var(--color-text-tertiary); margin: 0; }
.flow { position: relative; display: flex; align-items: flex-end; gap: var(--space-6); padding: var(--space-4) 0; overflow-x: auto; }
.stageCol { display: flex; flex-direction: column; align-items: center; gap: var(--space-2); min-width: 60px; }
.barTrack { height: 80px; display: flex; align-items: flex-end; }
.bar { width: 32px; border-radius: var(--radius-md) var(--radius-md) 0 0; min-height: 4px; }
.count { font-size: var(--text-lg); font-weight: var(--font-bold); }
.label { font-size: var(--text-xs); color: var(--color-text-secondary); text-align: center; max-width: 60px; }
.value { font-size: 10px; color: var(--color-text-tertiary); }
.arrows { position: absolute; bottom: 50px; left: 0; right: 0; display: flex; justify-content: space-evenly; pointer-events: none; }
.arrow { color: var(--color-text-tertiary); font-size: 20px; }
.stageSkeleton { height: 80px; width: 60px; border-radius: var(--radius-md); }

View File

@@ -0,0 +1,98 @@
import { motion } from 'framer-motion';
import styles from './PipelinePulse.module.css';
/**
* PipelinePulse — Journey River widget for Command Pillar.
* Shows lead count per pipeline stage as a horizontal flow.
* Contextual, scrollable — never navigates away.
*/
interface PipelineStage {
id: string;
label: string;
count: number;
value?: string;
}
interface PipelinePulseProps {
stages?: PipelineStage[];
isLoading: boolean;
}
const STAGE_COLORS = [
'var(--color-text-tertiary)',
'var(--color-amber)',
'var(--color-violet)',
'var(--color-violet-light)',
'var(--color-green)',
];
export function PipelinePulse({ stages, isLoading }: PipelinePulseProps) {
if (isLoading || !stages || stages.length === 0) {
return (
<div className={styles.section}>
<h2 className={styles.sectionTitle}>Pipeline Pulse</h2>
<div className={styles.flow}>
{[0, 1, 2, 3].map(i => (
<div key={i} className={`${styles.stageSkeleton} shimmer`} />
))}
</div>
</div>
);
}
const maxCount = Math.max(...stages.map(s => s.count), 1);
return (
<div className={styles.section}>
<h2 className={styles.sectionTitle}>Pipeline Pulse</h2>
<div className={styles.flow}>
{stages.map((stage, i) => {
const barHeight = Math.max((stage.count / maxCount) * 80, 4);
const color = STAGE_COLORS[i % STAGE_COLORS.length];
return (
<motion.div
key={stage.id}
className={styles.stageCol}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.07, duration: 0.3 }}
>
{/* Bar */}
<div className={styles.barTrack}>
<motion.div
className={styles.bar}
style={{ background: color }}
initial={{ height: 0 }}
animate={{ height: barHeight }}
transition={{ delay: i * 0.07 + 0.2, duration: 0.5, ease: [0.4, 0, 0.2, 1] }}
/>
</div>
{/* Count */}
<span className={styles.count} style={{ color }}>
{stage.count}
</span>
{/* Label */}
<span className={styles.label}>{stage.label}</span>
{/* Value (revenue) */}
{stage.value && (
<span className={styles.value}>{stage.value}</span>
)}
</motion.div>
);
})}
{/* Flow arrows between stages */}
<div className={styles.arrows} aria-hidden>
{stages.slice(0, -1).map((_, i) => (
<span key={i} className={styles.arrow}></span>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,201 @@
/**
* BranchBar — Top-of-page branch status bar
* Shows: page title, branch identity badge, revision number, execution status,
* share action, merge request indicator, rollback affordance.
* Must remain visible at all times (sticky).
*/
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
GitBranch, GitMerge, Share2, RotateCcw, Dot, Users,
CheckCircle2, Clock, AlertCircle, Loader2, GitFork
} from 'lucide-react';
import type { CanvasPage, PromptExecution, MergeRequest } from '../types/canvas';
interface BranchBarProps {
page: CanvasPage | null;
inFlightExecution: PromptExecution | null;
mergeRequests?: MergeRequest[];
isConnected: boolean;
onShare: () => void;
onRollback: () => void;
onOpenMergeReview: () => void;
}
const STATUS_CONFIG = {
received: { icon: Clock, color: '#94a3b8', label: 'Received' },
planning: { icon: Loader2, color: '#60a5fa', label: 'Planning…', spin: true },
validated: { icon: CheckCircle2, color: '#34d399', label: 'Validated' },
executing: { icon: Loader2, color: '#a78bfa', label: 'Executing…', spin: true },
completed: { icon: CheckCircle2, color: '#34d399', label: 'Completed' },
failed: { icon: AlertCircle, color: '#f87171', label: 'Failed' },
clarification_required: { icon: AlertCircle, color: '#fbbf24', label: 'Clarification needed' },
} as const;
export function BranchBar({
page,
inFlightExecution,
mergeRequests = [],
isConnected,
onShare,
onRollback,
onOpenMergeReview,
}: BranchBarProps) {
const [shareHovered, setShareHovered] = useState(false);
const openMRCount = mergeRequests.filter((mr) => mr.status === 'open').length;
const isFork = page?.pageType === 'fork';
const executionStatus = inFlightExecution?.status;
const statusCfg = executionStatus ? STATUS_CONFIG[executionStatus] : null;
const StatusIcon = statusCfg?.icon;
return (
<div
className="relative z-20 px-4 pt-3 pb-2 flex-shrink-0"
style={{ borderBottom: '1px solid rgba(255,255,255,0.06)' }}
>
<div
className="flex items-center justify-between gap-4 px-4 py-2.5 rounded-2xl"
style={{
background: 'rgba(10, 11, 18, 0.85)',
border: '1px solid rgba(255,255,255,0.08)',
backdropFilter: 'blur(24px)',
WebkitBackdropFilter: 'blur(24px)',
}}
>
{/* Left: Page title + branch identity */}
<div className="flex items-center gap-3 min-w-0">
<div className="flex items-center gap-2 flex-shrink-0">
{isFork ? (
<GitFork className="w-4 h-4 text-violet-400" />
) : (
<GitBranch className="w-4 h-4 text-blue-400" />
)}
<span
className="text-xs font-semibold px-2 py-0.5 rounded-full flex-shrink-0"
style={{
background: isFork ? 'rgba(139,92,246,0.15)' : 'rgba(59,130,246,0.15)',
color: isFork ? '#c4b5fd' : '#93c5fd',
border: `1px solid ${isFork ? 'rgba(139,92,246,0.3)' : 'rgba(59,130,246,0.3)'}`,
}}
>
{page?.branchName ?? 'main'}
</span>
</div>
<div className="flex items-center gap-1.5 min-w-0">
<span className="text-sm font-medium text-zinc-200 truncate">
{page?.title ?? 'Oracle Canvas'}
</span>
{page && (
<span className="text-xs text-zinc-600 flex-shrink-0 font-mono">
rev.{page.headRevision}
</span>
)}
</div>
</div>
{/* Center: execution status */}
<AnimatePresence mode="wait">
{statusCfg && StatusIcon && (
<motion.div
key={executionStatus}
initial={{ opacity: 0, scale: 0.85 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.85 }}
transition={{ duration: 0.2 }}
className="flex items-center gap-1.5 flex-shrink-0"
>
<StatusIcon
className="w-3.5 h-3.5"
style={{
color: statusCfg.color,
// @ts-expect-error spin is a custom property
animation: statusCfg.spin ? 'spin 1s linear infinite' : undefined,
}}
/>
<span className="text-xs font-medium" style={{ color: statusCfg.color }}>
{statusCfg.label}
</span>
</motion.div>
)}
</AnimatePresence>
{/* Right: actions */}
<div className="flex items-center gap-2 flex-shrink-0">
{/* Presence indicator */}
{page && page.presence.activeViewers > 1 && (
<div className="flex items-center gap-1 text-xs text-zinc-500">
<Users className="w-3.5 h-3.5" />
<span>{page.presence.activeViewers}</span>
</div>
)}
{/* Connection dot */}
<div className="flex items-center gap-1">
<Dot
className="w-5 h-5 -mx-1.5"
style={{ color: isConnected ? '#34d399' : '#6b7280' }}
/>
</div>
{/* Rollback */}
<button
onClick={onRollback}
title="View revision history and rollback"
className="flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg transition-all"
style={{
color: '#71717a',
background: 'transparent',
border: '1px solid rgba(255,255,255,0.07)',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(255,255,255,0.05)';
e.currentTarget.style.color = '#a1a1aa';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
e.currentTarget.style.color = '#71717a';
}}
>
<RotateCcw className="w-3.5 h-3.5" />
<span className="hidden sm:inline">History</span>
</button>
{/* Merge Requests */}
{openMRCount > 0 && (
<button
onClick={onOpenMergeReview}
className="flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg transition-all"
style={{
background: 'rgba(251,191,36,0.12)',
color: '#fbbf24',
border: '1px solid rgba(251,191,36,0.25)',
}}
>
<GitMerge className="w-3.5 h-3.5" />
<span>{openMRCount} open</span>
</button>
)}
{/* Share */}
<motion.button
onClick={onShare}
onMouseEnter={() => setShareHovered(true)}
onMouseLeave={() => setShareHovered(false)}
className="flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-lg font-medium transition-all"
style={{
background: shareHovered ? 'rgba(59,130,246,0.25)' : 'rgba(59,130,246,0.15)',
color: '#93c5fd',
border: '1px solid rgba(59,130,246,0.3)',
}}
whileTap={{ scale: 0.96 }}
>
<Share2 className="w-3.5 h-3.5" />
<span className="hidden sm:inline">Share</span>
</motion.button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,212 @@
/**
* CanvasViewport — Virtualized scrolling canvas.
* Renders CanvasComponent objects sorted by layout.orderIndex.
* Uses CSS content-visibility for large component lists (virtualization-lite).
* Stable scroll anchoring: new components append without scroll jump.
*/
import { useRef } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import type { CanvasComponent, PromptExecution } from '../types/canvas';
import { ComponentRegistry, type ComponentRenderContext } from './ComponentRegistry';
import { Loader2 } from 'lucide-react';
interface CanvasViewportProps {
components: CanvasComponent[];
ctx: ComponentRenderContext;
inFlightExecution: PromptExecution | null;
selectedComponentId?: string | null;
onSelectComponent: (id: string | null) => void;
}
const SECTION_LABELS: Record<string, string> = {
sec_pipeline: 'Pipeline',
sec_leads: 'Lead Intelligence',
sec_team: 'Team Performance',
sec_actions: 'Action Queue',
sec_prompt_generated: 'Oracle Responses',
sec_geography: 'Geographic',
sec_forecast: 'Forecasting',
};
/** Groups components by sectionId, preserving orderIndex sort */
function groupBySection(components: CanvasComponent[]): Array<{ sectionId: string; components: CanvasComponent[] }> {
const sorted = [...components].sort((a, b) => a.layout.orderIndex - b.layout.orderIndex);
const sectionMap = new Map<string, CanvasComponent[]>();
for (const comp of sorted) {
const sid = comp.layout.sectionId;
if (!sectionMap.has(sid)) sectionMap.set(sid, []);
sectionMap.get(sid)!.push(comp);
}
return Array.from(sectionMap.entries())
.map(([sectionId, comps]) => ({ sectionId, components: comps }))
.sort((a, b) => {
const aPrompt = a.sectionId.startsWith('sec_prompt_generated');
const bPrompt = b.sectionId.startsWith('sec_prompt_generated');
if (aPrompt && bPrompt) {
const aCreated = Math.max(...a.components.map((comp) => Date.parse(comp.provenance.createdAt || '1970-01-01T00:00:00Z')));
const bCreated = Math.max(...b.components.map((comp) => Date.parse(comp.provenance.createdAt || '1970-01-01T00:00:00Z')));
return bCreated - aCreated;
}
if (aPrompt !== bPrompt) return aPrompt ? -1 : 1;
return Math.min(...a.components.map((comp) => comp.layout.orderIndex)) - Math.min(...b.components.map((comp) => comp.layout.orderIndex));
});
}
function getSectionLabel(sectionId: string, sectionComps: CanvasComponent[]): string {
if (SECTION_LABELS[sectionId]) return SECTION_LABELS[sectionId];
if (sectionId.startsWith('sec_prompt_generated')) {
const planning = sectionComps.find((comp) => comp.type === 'textCanvas');
const content = planning?.visualizationParameters?.content;
if (typeof content === 'string') {
const firstLine = content.split('\n')[0]?.trim();
if (firstLine?.startsWith('Oracle received:')) {
return firstLine.replace('Oracle received:', '').trim();
}
}
return 'Oracle Response';
}
return sectionId.replace(/^sec_/, '').replace(/_/g, ' ');
}
/** CSS content-visibility wrapper for off-screen components, applying width mode to the flex item */
function ComponentFlexWrapper({ comp, children }: { comp: CanvasComponent; children: React.ReactNode }) {
const styles: Record<string, string> = {
full: 'w-full',
half: 'w-full xl:w-[calc(50%-8px)]',
third: 'w-full xl:w-[calc(33.333%-11px)]',
};
return (
<div
className={styles[comp.layout.widthMode] ?? 'w-full'}
style={{
contentVisibility: 'auto',
containIntrinsicSize: `0 ${comp.renderingHints.estimatedHeightPx}px`,
}}
>
{children}
</div>
);
}
export function CanvasViewport({
components,
ctx,
inFlightExecution,
selectedComponentId,
onSelectComponent,
}: CanvasViewportProps) {
const viewportRef = useRef<HTMLDivElement>(null);
const sections = groupBySection(components);
return (
<div
ref={viewportRef}
className="flex-1 overflow-y-auto overflow-x-hidden custom-scrollbar"
style={{ scrollBehavior: 'smooth' }}
onClick={(e) => {
// Click outside component to deselect
if (e.currentTarget === e.target) onSelectComponent(null);
}}
>
<div className="px-4 py-5 space-y-8">
{sections.length === 0 && !inFlightExecution && (
<EmptyCanvasState />
)}
{sections.map(({ sectionId, components: sectionComps }) => (
<section key={sectionId} className="space-y-4">
{/* Section header */}
<div className="flex items-center gap-3">
<div className="w-1 h-4 rounded-full bg-gradient-to-b from-blue-400 to-cyan-500" />
<h2 className="text-xs font-semibold uppercase tracking-widest text-zinc-500">
{getSectionLabel(sectionId, sectionComps)}
</h2>
<div className="flex-1 h-[1px]" style={{ background: 'rgba(255,255,255,0.05)' }} />
<span className="text-[10px] text-zinc-700">{sectionComps.length}</span>
</div>
{/* Flex wrap for half/third width components */}
<div className="flex flex-wrap gap-4">
{sectionComps.map((comp) => (
<ComponentFlexWrapper
key={`${comp.componentId}-${comp.version}`}
comp={comp}
>
<ComponentRegistry
component={comp}
ctx={{
...ctx,
isSelected: selectedComponentId === comp.componentId,
onSelect: onSelectComponent,
}}
/>
</ComponentFlexWrapper>
))}
</div>
</section>
))}
{/* In-flight execution placeholder */}
<AnimatePresence>
{inFlightExecution && (
<motion.div
key="in-flight-placeholder"
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
className="w-full rounded-2xl p-6 flex items-center gap-4"
style={{
background: 'rgba(59,130,246,0.06)',
border: '1px dashed rgba(59,130,246,0.25)',
backdropFilter: 'blur(12px)',
}}
>
<Loader2 className="w-5 h-5 text-blue-400 animate-spin flex-shrink-0" />
<div>
<p className="text-sm font-medium text-blue-300">Oracle is analyzing your prompt</p>
<p className="text-xs text-zinc-500 mt-0.5 line-clamp-1">
"{inFlightExecution.prompt}"
</p>
</div>
<div
className="ml-auto text-xs font-mono px-2 py-1 rounded-lg"
style={{ background: 'rgba(59,130,246,0.12)', color: '#60a5fa' }}
>
{inFlightExecution.status}
</div>
</motion.div>
)}
</AnimatePresence>
{/* Bottom padding for floating prompt bar */}
<div className="h-32" />
</div>
</div>
);
}
function EmptyCanvasState() {
return (
<div className="flex flex-col items-center justify-center py-24 gap-6">
<div
className="w-20 h-20 rounded-2xl flex items-center justify-center"
style={{ background: 'rgba(59,130,246,0.08)', border: '1px solid rgba(59,130,246,0.15)' }}
>
<svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg" className="w-10 h-10">
<rect x="6" y="6" width="15" height="15" rx="3" stroke="#3B82F6" strokeWidth="1.5" />
<rect x="27" y="6" width="15" height="15" rx="3" stroke="#22D3EE" strokeWidth="1.5" />
<rect x="6" y="27" width="15" height="15" rx="3" stroke="#A78BFA" strokeWidth="1.5" />
<rect x="27" y="27" width="15" height="15" rx="3" stroke="#3B82F6" strokeWidth="1.5" opacity="0.5" strokeDasharray="3 2" />
</svg>
</div>
<div className="text-center">
<h3 className="text-base font-medium text-zinc-200 mb-2">Canvas is empty</h3>
<p className="text-sm text-zinc-500 max-w-sm">
Ask Oracle anything type a prompt below to generate analytical components on your canvas.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,392 @@
import { useEffect, useMemo, useState } from 'react';
import {
Activity,
AlertTriangle,
ArrowRight,
Check,
Clock,
Database,
Mail,
Phone,
Search,
Sparkles,
UserRound,
Zap,
} from 'lucide-react';
import { Input } from '@/components/ui/input';
import {
fetchOracleClientData,
fetchOracleClientDataDetail,
patchOracleClientData,
} from '@/lib/crmApi';
import type { OracleClientDataDetail, OracleClientDataListItem } from '@/types/crmTypes';
const STAGES = [
'new',
'contacted',
'qualified',
'site_visit_scheduled',
'site_visited',
'negotiation',
'booking_initiated',
'booked',
];
function fmt(value: unknown): string {
if (value == null || value === '') return '-';
if (typeof value === 'number') return Number.isInteger(value) ? String(value) : value.toFixed(2);
return String(value);
}
function shortDate(value: unknown): string {
if (!value) return '-';
const date = new Date(String(value));
if (Number.isNaN(date.getTime())) return String(value);
return date.toLocaleString();
}
function FieldRow({ label, value }: { label: string; value: unknown }) {
return (
<div className="grid grid-cols-[150px_1fr] gap-3 border-b border-white/[0.06] py-2.5 text-sm">
<span className="text-zinc-500">{label}</span>
<span className="min-w-0 break-words text-zinc-200">{fmt(value)}</span>
</div>
);
}
function EmptyDiagnostic({ error, loading }: { error: string | null; loading: boolean }) {
return (
<div className="flex h-full min-h-[420px] items-center justify-center p-8">
<div className="max-w-xl rounded-3xl border border-amber-400/20 bg-amber-500/[0.08] p-6">
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-2xl border border-amber-300/25 bg-amber-400/10">
{loading ? <Database className="h-5 w-5 animate-pulse text-amber-200" /> : <AlertTriangle className="h-5 w-5 text-amber-200" />}
</div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-amber-200">Client data unavailable</p>
<h3 className="mt-2 text-xl font-semibold text-zinc-50">
{loading ? 'Loading Velocity CRM data...' : 'The CRM lens has no rows to show.'}
</h3>
<p className="mt-3 text-sm leading-6 text-zinc-400">
{error
? error
: 'The local frontend is alive, but the CRM API returned no clients. Start the local backend, start Docker/Postgres, apply the canonical schema, and seed synthetic_crm_v2 before verifying this tab.'}
</p>
<div className="mt-5 rounded-2xl border border-white/10 bg-black/25 p-4 text-xs leading-6 text-zinc-400">
<p className="font-semibold text-zinc-200">Expected local stack</p>
<p>Backend: http://127.0.0.1:8001</p>
<p>DB: 127.0.0.1:54329 / velocity_local</p>
<p>Dataset: db assets/synthetic_crm_v2/csv</p>
</div>
</div>
</div>
);
}
export function ClientDataLens() {
const [query, setQuery] = useState('');
const [items, setItems] = useState<OracleClientDataListItem[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [detail, setDetail] = useState<OracleClientDataDetail | null>(null);
const [loadingList, setLoadingList] = useState(false);
const [loadingDetail, setLoadingDetail] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [draft, setDraft] = useState({
full_name: '',
primary_email: '',
primary_phone: '',
buyer_type: '',
communication_preference: '',
best_contact_time: '',
budget_band: '',
urgency: '',
});
useEffect(() => {
const handle = window.setTimeout(() => {
setLoadingList(true);
setError(null);
void fetchOracleClientData({ search: query, limit: 80 })
.then(({ items: rows }) => {
setItems(rows);
setSelectedId((current) => current ?? rows[0]?.person_id ?? null);
if (!rows.length) {
setDetail(null);
}
})
.catch((err) => {
setItems([]);
setSelectedId(null);
setDetail(null);
setError(err instanceof Error ? err.message : 'Failed to reach the CRM client data API.');
})
.finally(() => setLoadingList(false));
}, 180);
return () => window.clearTimeout(handle);
}, [query]);
useEffect(() => {
if (!selectedId) return;
setLoadingDetail(true);
setError(null);
void fetchOracleClientDataDetail(selectedId)
.then((data) => {
const profile = data.profile ?? {};
setDetail(data);
setDraft({
full_name: String(profile.full_name ?? ''),
primary_email: String(profile.primary_email ?? ''),
primary_phone: String(profile.primary_phone ?? ''),
buyer_type: String(profile.buyer_type ?? ''),
communication_preference: String(profile.communication_preference ?? ''),
best_contact_time: String(profile.best_contact_time ?? ''),
budget_band: String(profile.budget_band ?? ''),
urgency: String(profile.urgency ?? ''),
});
})
.catch((err) => {
setDetail(null);
setError(err instanceof Error ? err.message : 'Failed to load the selected client record.');
})
.finally(() => setLoadingDetail(false));
}, [selectedId]);
const selected = useMemo(
() => items.find((item) => item.person_id === selectedId) ?? items[0] ?? null,
[items, selectedId],
);
const profile = detail?.profile ?? {};
const currentStage = String(profile.lead_status ?? selected?.lead_status ?? 'new');
const qdScore = Number(selected?.qd_score ?? 0);
const activeStageIndex = Math.max(0, STAGES.indexOf(currentStage));
async function saveDraft() {
if (!selectedId) return;
setSaving(true);
setError(null);
try {
await patchOracleClientData(selectedId, draft);
const fresh = await fetchOracleClientDataDetail(selectedId);
setDetail(fresh);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save client data.');
} finally {
setSaving(false);
}
}
return (
<div className="relative z-10 grid h-full min-h-0 grid-cols-[360px_minmax(0,1fr)] gap-4 overflow-hidden px-5 pb-5 pt-3">
<aside className="flex min-h-0 flex-col overflow-hidden rounded-3xl border border-cyan-400/15 bg-[linear-gradient(180deg,rgba(8,47,73,0.16),rgba(0,0,0,0.22))]">
<div className="border-b border-white/10 p-5">
<div className="mb-4 flex items-start justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-cyan-300">Client Data</p>
<h2 className="mt-1 text-xl font-semibold text-zinc-50">Velocity CRM Lens</h2>
<p className="mt-1 text-xs text-zinc-500">Search, inspect, edit, and follow the client signal trail.</p>
</div>
<Sparkles className="h-5 w-5 text-cyan-200" />
</div>
<div className="relative">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-zinc-500" />
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search name, phone, email, project..."
className="h-10 border-white/10 bg-black/25 pl-9 text-sm text-zinc-100"
/>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-2">
{items.map((item) => (
<button
key={item.person_id}
type="button"
onClick={() => setSelectedId(item.person_id)}
className={`mb-2 w-full rounded-2xl border p-3 text-left transition ${
item.person_id === selectedId
? 'border-cyan-300/40 bg-cyan-500/12 shadow-[0_0_24px_rgba(34,211,238,0.08)]'
: 'border-white/8 bg-white/[0.03] hover:bg-white/[0.06]'
}`}
>
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-2xl border border-blue-400/20 bg-blue-500/15 text-sm font-semibold text-blue-100">
{item.full_name.slice(0, 1)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-zinc-100">{item.full_name}</p>
<p className="truncate text-xs text-zinc-500">{item.projects || item.buyer_type || 'No project interest'}</p>
<div className="mt-2 flex flex-wrap items-center gap-2 text-[11px] text-zinc-400">
<span className="rounded-full bg-cyan-500/10 px-2 py-0.5 text-cyan-300">QD {Math.round((item.qd_score || 0) * 100)}</span>
<span>{item.days_since_contact == null ? 'No contact clock' : `${item.days_since_contact}d since contact`}</span>
</div>
</div>
</div>
</button>
))}
{!items.length && (
<div className="px-4 py-7 text-sm text-zinc-500">
{loadingList ? 'Loading clients...' : 'No clients found.'}
</div>
)}
</div>
</aside>
<main className="min-w-0 overflow-hidden rounded-3xl border border-white/10 bg-black/20">
{!selected ? (
<EmptyDiagnostic error={error} loading={loadingList} />
) : (
<div className="flex h-full min-h-0 flex-col">
<section className="border-b border-white/10 bg-[linear-gradient(135deg,rgba(14,116,144,0.14),rgba(15,23,42,0.35))] p-5">
{error && (
<div className="mb-4 rounded-2xl border border-amber-400/20 bg-amber-500/10 px-4 py-3 text-sm text-amber-100">
{error}
</div>
)}
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<p className="text-xs uppercase tracking-[0.24em] text-zinc-500">Contact Record</p>
<h1 className="mt-1 text-3xl font-semibold text-zinc-50">{selected.full_name}</h1>
<div className="mt-3 flex flex-wrap gap-4 text-sm text-zinc-400">
<span className="flex items-center gap-1.5"><Phone className="h-4 w-4" />{fmt(selected.primary_phone)}</span>
<span className="flex items-center gap-1.5"><Mail className="h-4 w-4" />{fmt(selected.primary_email)}</span>
<span className="flex items-center gap-1.5"><UserRound className="h-4 w-4" />{fmt(selected.broker_name)}</span>
</div>
</div>
<div className="grid grid-cols-3 gap-3">
<div className="rounded-2xl border border-cyan-400/20 bg-cyan-500/10 px-4 py-3 text-right">
<p className="text-xs text-cyan-300">QD Signal</p>
<p className="text-3xl font-semibold text-cyan-100">{Math.round(qdScore * 100)}</p>
</div>
<div className="rounded-2xl border border-white/10 bg-black/25 px-4 py-3">
<p className="text-xs text-zinc-500">Last Contact</p>
<p className="mt-1 max-w-[160px] truncate text-sm font-semibold text-zinc-100">{shortDate(selected.last_contact_at)}</p>
</div>
<div className="rounded-2xl border border-white/10 bg-black/25 px-4 py-3">
<p className="text-xs text-zinc-500">Next Action</p>
<p className="mt-1 max-w-[180px] truncate text-sm font-semibold text-zinc-100">{fmt(selected.next_best_action)}</p>
</div>
</div>
</div>
<div className="mt-5 grid grid-cols-8 overflow-hidden rounded-full border border-white/10 bg-white/[0.04]">
{STAGES.map((stage, index) => {
const active = activeStageIndex >= index;
return (
<div key={stage} className={`py-2 text-center text-[11px] font-semibold ${active ? 'bg-blue-500/70 text-white' : 'text-zinc-500'}`}>
{stage.replace(/_/g, ' ')}
</div>
);
})}
</div>
</section>
<section className="min-h-0 flex-1 overflow-y-auto p-5">
{loadingDetail && <p className="mb-4 text-sm text-zinc-500">Loading record...</p>}
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_410px]">
<div className="space-y-5">
<div className="rounded-3xl border border-white/10 bg-white/[0.03] p-5">
<div className="mb-4 flex items-center justify-between">
<div>
<h3 className="text-base font-semibold text-zinc-100">Editable Details</h3>
<p className="text-xs text-zinc-500">Typed API writes only. Oracle SQL remains read-only.</p>
</div>
<button
type="button"
onClick={() => void saveDraft()}
disabled={saving}
className="rounded-xl bg-blue-500 px-4 py-2 text-xs font-semibold text-white disabled:opacity-50"
>
<Check className="mr-1 inline h-3.5 w-3.5" />
{saving ? 'Saving...' : 'Save changes'}
</button>
</div>
<div className="grid gap-3 md:grid-cols-2">
{Object.entries(draft).map(([key, value]) => (
<label key={key} className="text-xs text-zinc-500">
{key.replace(/_/g, ' ')}
<Input
value={value}
onChange={(event) => setDraft((current) => ({ ...current, [key]: event.target.value }))}
className="mt-1 h-9 border-white/10 bg-black/25 text-sm text-zinc-100"
/>
</label>
))}
</div>
</div>
<div className="rounded-3xl border border-white/10 bg-white/[0.03] p-5">
<h3 className="mb-4 text-base font-semibold text-zinc-100">Property Interests</h3>
<div className="grid gap-3 md:grid-cols-2">
{(detail?.property_interests ?? []).slice(0, 8).map((interest, index) => (
<div key={String(interest.interest_id ?? index)} className="rounded-2xl border border-white/10 bg-black/25 p-4">
<p className="font-medium text-zinc-100">{fmt(interest.project_name)}</p>
<p className="mt-1 text-xs text-zinc-500">{fmt(interest.configuration)} | {fmt(interest.budget_min)}-{fmt(interest.budget_max)}</p>
<p className="mt-2 text-xs text-cyan-300">Priority {fmt(interest.priority)}</p>
</div>
))}
{!(detail?.property_interests ?? []).length && <p className="text-sm text-zinc-500">No property interests loaded for this client.</p>}
</div>
</div>
<div className="rounded-3xl border border-white/10 bg-white/[0.03] p-5">
<h3 className="mb-4 text-base font-semibold text-zinc-100">Unified Engagement Timeline</h3>
<div className="space-y-3">
{(detail?.timeline ?? []).slice(0, 24).map((event) => (
<div key={event.id} className="flex gap-3 rounded-2xl border border-white/8 bg-black/25 p-3">
<Activity className="mt-1 h-4 w-4 text-blue-300" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-zinc-100">{fmt(event.title || event.type)}</p>
<p className="mt-1 text-xs text-zinc-500">{fmt(event.summary)}</p>
</div>
<span className="whitespace-nowrap text-[11px] text-zinc-600">{shortDate(event.date)}</span>
</div>
))}
{!(detail?.timeline ?? []).length && <p className="text-sm text-zinc-500">No timeline events loaded. Seed v2 interactions/messages/calls/visits to populate this rail.</p>}
</div>
</div>
</div>
<aside className="space-y-5">
<div className="rounded-3xl border border-blue-400/20 bg-blue-500/10 p-5">
<p className="text-xs uppercase tracking-[0.24em] text-blue-300">Next Best Action</p>
<p className="mt-2 text-lg font-semibold text-zinc-100">{fmt(selected.next_best_action ?? profile.recommended_action)}</p>
<p className="mt-3 text-sm leading-6 text-zinc-400">{fmt(profile.rationale)}</p>
</div>
<div className="rounded-3xl border border-white/10 bg-white/[0.03] p-5">
<h3 className="mb-3 flex items-center gap-2 text-base font-semibold text-zinc-100"><Clock className="h-4 w-4" /> Engagement Intelligence</h3>
<FieldRow label="Last Contact" value={shortDate(selected.last_contact_at)} />
<FieldRow label="Channel" value={selected.last_channel} />
<FieldRow label="Days Since" value={selected.days_since_contact} />
<FieldRow label="Preference" value={selected.communication_preference} />
<FieldRow label="Best Time" value={profile.best_contact_time} />
</div>
<div className="rounded-3xl border border-white/10 bg-white/[0.03] p-5">
<h3 className="mb-3 flex items-center gap-2 text-base font-semibold text-zinc-100"><Zap className="h-4 w-4 text-cyan-300" /> Extracted Facts</h3>
{(detail?.extracted_facts ?? []).slice(0, 10).map((fact, index) => (
<div key={String(fact.fact_id ?? index)} className="mb-2 rounded-2xl bg-black/25 p-3 text-xs">
<p className="font-semibold text-cyan-200">{fmt(fact.fact_type)}</p>
<p className="mt-1 text-zinc-400">{fmt(fact.fact_value)}</p>
</div>
))}
{!(detail?.extracted_facts ?? []).length && <p className="text-sm text-zinc-500">No extracted facts loaded.</p>}
</div>
<div className="rounded-3xl border border-white/10 bg-white/[0.03] p-5">
<h3 className="mb-3 text-base font-semibold text-zinc-100">Open Opportunities</h3>
{(detail?.opportunities ?? []).slice(0, 8).map((opp, index) => (
<div key={String(opp.opportunity_id ?? index)} className="mb-2 flex items-center justify-between rounded-2xl bg-black/25 p-3 text-xs">
<span className="text-zinc-200">{fmt(opp.project_name ?? opp.stage)}</span>
<ArrowRight className="h-3.5 w-3.5 text-zinc-500" />
</div>
))}
{!(detail?.opportunities ?? []).length && <p className="text-sm text-zinc-500">No opportunities loaded.</p>}
</div>
</aside>
</div>
</section>
</div>
)}
</main>
</div>
);
}

View File

@@ -0,0 +1,167 @@
/**
* ComponentRegistry — maps CanvasComponent.type to renderer implementations.
* Supports lazy loading for expensive renderers (GeoMap, Table, PipelineBoard).
* Falls back to ErrorNoticeRenderer for unknown or revoked types.
*/
import { lazy, Suspense } from 'react';
import type { CanvasComponent } from '../types/canvas';
// ── Eager renderers (lightweight) ─────────────────────────────────────────────
import { KpiTileRenderer } from './renderers/KpiTileRenderer';
import { ErrorNoticeRenderer } from './renderers/ErrorNoticeRenderer';
import { TimelineRenderer } from './renderers/TimelineRenderer';
import { TextCanvasRenderer } from './renderers/TextCanvasRenderer';
// ── Lazy renderers (heavier) ──────────────────────────────────────────────────
const BarChartRenderer = lazy(() => import('./renderers/BarChartRenderer').then((m) => ({ default: m.BarChartRenderer })));
const LineChartRenderer = lazy(() => import('./renderers/LineChartRenderer').then((m) => ({ default: m.LineChartRenderer })));
const GeoMapRenderer = lazy(() => import('./renderers/GeoMapRenderer').then((m) => ({ default: m.GeoMapRenderer })));
const TableRenderer = lazy(() => import('./renderers/TableRenderer').then((m) => ({ default: m.TableRenderer })));
const PipelineBoardRenderer = lazy(() => import('./renderers/PipelineBoardRenderer').then((m) => ({ default: m.PipelineBoardRenderer })));
const ActivityStreamRenderer = lazy(() => import('./renderers/ActivityStreamRenderer').then((m) => ({ default: m.ActivityStreamRenderer })));
// ── Render context ────────────────────────────────────────────────────────────
export interface ComponentRenderContext {
tenantId: string;
actorRole: string;
showLineageBadges: boolean;
density: 'compact' | 'comfortable';
isSelected?: boolean;
onSelect?: (componentId: string) => void;
}
// ── Skeleton ──────────────────────────────────────────────────────────────────
function ComponentSkeleton({ variant }: { variant: string }) {
const heights: Record<string, number> = {
chart: 280, map: 380, table: 300, kpi: 120, pipeline: 360, timeline: 300, generic: 240,
};
const h = heights[variant] ?? 240;
return (
<div
className="rounded-2xl animate-pulse"
style={{
height: h,
background: 'rgba(255,255,255,0.03)',
border: '1px solid rgba(255,255,255,0.06)',
}}
/>
);
}
// ── Registry resolver ─────────────────────────────────────────────────────────
interface RegistryRendererProps {
component: CanvasComponent;
ctx: ComponentRenderContext;
}
export function ComponentRegistry({ component, ctx }: RegistryRendererProps) {
const skeleton = <ComponentSkeleton variant={component.renderingHints.skeletonVariant} />;
if (component.lifecycleState === 'revoked') {
return (
<ErrorNoticeRenderer
component={{
...component,
title: 'Component Revoked',
visualizationParameters: {
errorCode: 'component_revoked',
message: 'This component has been revoked and can no longer be rendered.',
},
}}
ctx={ctx}
/>
);
}
switch (component.type) {
case 'textCanvas':
return <TextCanvasRenderer component={component} ctx={ctx} />;
case 'kpiTile':
return <KpiTileRenderer component={component} ctx={ctx} />;
case 'errorNotice':
return <ErrorNoticeRenderer component={component} ctx={ctx} />;
case 'timeline':
return <TimelineRenderer component={component} ctx={ctx} />;
case 'barChart':
return (
<Suspense fallback={skeleton}>
<BarChartRenderer component={component} ctx={ctx} />
</Suspense>
);
case 'lineChart':
case 'forecastChart':
return (
<Suspense fallback={skeleton}>
<LineChartRenderer component={component} ctx={ctx} />
</Suspense>
);
case 'geoMap':
case 'heatmap':
return (
<Suspense fallback={skeleton}>
<GeoMapRenderer component={component} ctx={ctx} />
</Suspense>
);
case 'table':
return (
<Suspense fallback={skeleton}>
<TableRenderer component={component} ctx={ctx} />
</Suspense>
);
case 'pipelineBoard':
return (
<Suspense fallback={skeleton}>
<PipelineBoardRenderer component={component} ctx={ctx} />
</Suspense>
);
case 'activityStream':
return (
<Suspense fallback={skeleton}>
<ActivityStreamRenderer component={component} ctx={ctx} />
</Suspense>
);
case 'scatterPlot':
case 'customMLVisualization':
// Phase 2 renderers — show a meaningful placeholder with the right visual treatment
return (
<ErrorNoticeRenderer
component={{
...component,
visualizationParameters: {
errorCode: 'renderer_pending',
message: `The ${component.type} renderer is scheduled for Phase 2 synthesis. Data has been captured and is available.`,
severity: 'info',
},
}}
ctx={ctx}
/>
);
default:
return (
<ErrorNoticeRenderer
component={{
...component,
visualizationParameters: {
errorCode: 'unknown_type',
message: `Unknown component type: ${String(component.type)}`,
},
}}
ctx={ctx}
/>
);
}
}

View File

@@ -0,0 +1,205 @@
/**
* PromptRail — Durable prompt execution history rail.
* Each turn shows: prompt text → revision produced → components created → assumptions made.
* Replaces ephemeral chat; turns are durable (stored as PromptExecution objects).
*/
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { ChevronDown, CheckCircle2, AlertCircle, Loader2, Clock, BarChart2, GitCommit } from 'lucide-react';
import type { ExecutionEntry } from '../hooks/useOracleExecution';
import type { PromptExecution } from '../types/canvas';
interface PromptRailProps {
history: ExecutionEntry[];
inFlight: PromptExecution | null;
isOpen: boolean;
onToggle: () => void;
}
const STATUS_ICONS = {
completed: { Icon: CheckCircle2, color: '#34d399' },
failed: { Icon: AlertCircle, color: '#f87171' },
planning: { Icon: Loader2, color: '#60a5fa', spin: true },
executing: { Icon: Loader2, color: '#a78bfa', spin: true },
received: { Icon: Clock, color: '#94a3b8' },
validated: { Icon: CheckCircle2, color: '#34d399' },
clarification_required: { Icon: AlertCircle, color: '#fbbf24' },
};
function ExecutionTurnCard({ entry }: { entry: ExecutionEntry }) {
const [expanded, setExpanded] = useState(false);
const { execution } = entry;
const cfg = STATUS_ICONS[execution.status] ?? STATUS_ICONS.received;
const Icon = cfg.Icon;
return (
<div
className="rounded-xl overflow-hidden"
style={{ background: 'rgba(255,255,255,0.025)', border: '1px solid rgba(255,255,255,0.07)' }}
>
<button
className="w-full flex items-start gap-3 p-3 text-left hover:bg-white/[0.02] transition-colors"
onClick={() => setExpanded((p) => !p)}
>
<Icon
className="w-3.5 h-3.5 mt-0.5 flex-shrink-0"
style={{ color: cfg.color, animation: 'spin' in cfg && cfg.spin ? 'spin 1s linear infinite' : undefined }}
/>
<div className="min-w-0 flex-1">
<p className="text-xs text-zinc-200 line-clamp-2 leading-relaxed">
{execution.prompt}
</p>
{execution.summary && (
<p className="text-[10px] text-zinc-500 mt-1 line-clamp-1">{execution.summary}</p>
)}
</div>
<ChevronDown
className="w-3.5 h-3.5 text-zinc-600 flex-shrink-0 mt-0.5 transition-transform"
style={{ transform: expanded ? 'rotate(180deg)' : 'rotate(0deg)' }}
/>
</button>
<AnimatePresence>
{expanded && (
<motion.div
initial={{ height: 0 }}
animate={{ height: 'auto' }}
exit={{ height: 0 }}
className="overflow-hidden"
>
<div className="px-3 pb-3 space-y-2.5 border-t border-white/[0.06] pt-2.5">
{/* Revision produced */}
<div className="flex items-center gap-2">
<GitCommit className="w-3 h-3 text-zinc-600" />
<span className="text-[10px] text-zinc-500">
Produced revision on <span className="text-zinc-400">{execution.branchId}</span>
</span>
</div>
{/* Components created */}
{entry.componentsCreated.length > 0 && (
<div>
<div className="flex items-center gap-2 mb-1">
<BarChart2 className="w-3 h-3 text-blue-500" />
<span className="text-[10px] text-zinc-500">Components created</span>
</div>
{entry.componentsCreated.map((cid) => (
<div key={cid} className="text-[10px] text-blue-300 font-mono pl-5 truncate">
{cid}
</div>
))}
</div>
)}
{/* Warnings */}
{execution.warnings.length > 0 && (
<div>
<p className="text-[10px] text-amber-500 uppercase tracking-wider mb-1">Warnings</p>
{execution.warnings.map((w, i) => (
<p key={i} className="text-[10px] text-amber-300/80 pl-2 border-l border-amber-500/30 mb-0.5">
{w}
</p>
))}
</div>
)}
{/* Timestamps */}
<div className="flex items-center justify-between text-[10px] text-zinc-600 pt-1">
<span className="font-mono">{new Date(execution.createdAt).toLocaleTimeString()}</span>
{execution.completedAt && (
<span>
{Math.round((new Date(execution.completedAt).getTime() - new Date(execution.createdAt).getTime()) / 1000)}s
</span>
)}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
export function PromptRail({ history, inFlight, isOpen, onToggle }: PromptRailProps) {
return (
<div
className="flex-shrink-0 flex flex-col"
style={{
width: isOpen ? 280 : 0,
overflow: 'hidden',
transition: 'width 0.25s cubic-bezier(0.4,0,0.2,1)',
borderLeft: isOpen ? '1px solid rgba(255,255,255,0.07)' : 'none',
}}
>
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="flex flex-col h-full overflow-hidden"
style={{ width: 280 }}
>
{/* Rail 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>
<p className="text-xs font-semibold text-zinc-200">Prompt History</p>
<p className="text-[10px] text-zinc-600">{history.length} execution{history.length !== 1 ? 's' : ''}</p>
</div>
<button
onClick={onToggle}
className="text-zinc-600 hover:text-zinc-400 transition-colors"
>
<ChevronDown className="w-4 h-4 rotate-90" />
</button>
</div>
{/* Scrollable turns */}
<div className="flex-1 overflow-y-auto custom-scrollbar px-3 py-3 space-y-2">
{/* In-flight */}
<AnimatePresence>
{inFlight && (
<motion.div
key="inflight"
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
className="rounded-xl p-3"
style={{
background: 'rgba(59,130,246,0.08)',
border: '1px dashed rgba(59,130,246,0.25)',
}}
>
<div className="flex items-start gap-2">
<Loader2 className="w-3.5 h-3.5 text-blue-400 animate-spin flex-shrink-0 mt-0.5" />
<div>
<p className="text-[10px] text-blue-300 font-medium uppercase tracking-wider mb-0.5">
{inFlight.status}
</p>
<p className="text-xs text-zinc-300 line-clamp-2">{inFlight.prompt}</p>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Historical turns — newest first */}
{[...history].reverse().map((entry, i) => (
<ExecutionTurnCard key={entry.execution.executionId + i} entry={entry} />
))}
{history.length === 0 && !inFlight && (
<div className="text-center py-8">
<p className="text-xs text-zinc-600">Submit a prompt to see execution history here.</p>
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -0,0 +1,208 @@
/**
* RollbackConfirmModal — Shows revision history and confirms rollback.
* Rollback creates a new revision (non-destructive) per spec §15.3.
*/
import { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { X, RotateCcw, GitCommit, AlertTriangle, CheckCircle2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import type { CanvasPage, CanvasPageRevision } from '../types/canvas';
interface RollbackConfirmModalProps {
page: CanvasPage | null;
revisions: CanvasPageRevision[];
isLoading: boolean;
isOpen: boolean;
onClose: () => void;
onRollback: (targetRevision: number) => Promise<void>;
}
export function RollbackConfirmModal({ page, revisions, isLoading, isOpen, onClose, onRollback }: RollbackConfirmModalProps) {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const [selected, setSelected] = useState<number | null>(null);
const [submitting, setSubmitting] = useState(false);
const [success, setSuccess] = useState(false);
const handleRollback = async () => {
if (!selected) return;
setSubmitting(true);
try {
await onRollback(selected);
setSuccess(true);
setTimeout(() => {
setSuccess(false);
onClose();
setSelected(null);
}, 1800);
} catch {
// stay open
} finally {
setSubmitting(false);
}
};
const commitKindColors: Record<string, string> = {
prompt: '#60a5fa',
merge: '#a78bfa',
rollback: '#fbbf24',
manual_edit: '#34d399',
};
const content = (
<AnimatePresence>
{isOpen && (
<>
<motion.div
key="rollback-backdrop"
className="fixed inset-0 z-40"
style={{ background: 'rgba(0,0,0,0.65)', backdropFilter: 'blur(4px)' }}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
/>
<motion.div
key="rollback-modal"
className="fixed z-50 left-1/2 top-1/2 w-full max-w-lg"
initial={{ opacity: 0, scale: 0.92, x: '-50%', y: '-50%' }}
animate={{ opacity: 1, scale: 1, x: '-50%', y: '-50%' }}
exit={{ opacity: 0, scale: 0.92, x: '-50%', y: '-50%' }}
transition={{ duration: 0.2 }}
>
<div
className="rounded-2xl p-6"
style={{
background: 'rgba(12, 13, 20, 0.98)',
border: '1px solid rgba(255,255,255,0.1)',
boxShadow: '0 24px 80px rgba(0,0,0,0.8)',
maxHeight: '80vh',
display: 'flex',
flexDirection: 'column',
}}
>
<div className="flex items-center justify-between mb-4 flex-shrink-0">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-xl bg-amber-500/15 border border-amber-500/25 flex items-center justify-center">
<RotateCcw className="w-4 h-4 text-amber-400" />
</div>
<div>
<h2 className="text-sm font-semibold text-zinc-100">Revision History</h2>
<p className="text-xs text-zinc-500">Select a revision to roll back to</p>
</div>
</div>
<button onClick={onClose} className="text-zinc-600 hover:text-zinc-300 transition-colors">
<X className="w-4 h-4" />
</button>
</div>
{/* Non-destructive note */}
<div
className="flex items-start gap-3 p-3 rounded-xl mb-4 flex-shrink-0"
style={{ background: 'rgba(251,191,36,0.07)', border: '1px solid rgba(251,191,36,0.18)' }}
>
<AlertTriangle className="w-4 h-4 text-amber-400 flex-shrink-0 mt-0.5" />
<p className="text-xs text-zinc-300 leading-relaxed">
Rollback is <span className="text-amber-300 font-medium">non-destructive</span> it creates a new
revision restoring the canvas to that state. Current revision {page?.headRevision} is preserved in history.
</p>
</div>
{success ? (
<div className="flex flex-col items-center gap-3 py-8">
<CheckCircle2 className="w-10 h-10 text-green-400" />
<p className="text-sm text-zinc-200 font-medium">Rolled back to revision {selected}</p>
</div>
) : (
<>
{/* Revision list */}
<div className="flex-1 overflow-y-auto custom-scrollbar space-y-2 mb-4">
{isLoading && (
<div className="p-4 text-xs text-zinc-500">Loading revision history</div>
)}
{!isLoading && revisions.map((rev) => {
const color = commitKindColors[rev.commitKind] ?? '#60a5fa';
const isCurrent = rev.revisionNumber === page?.headRevision;
const isSelected = selected === rev.revisionNumber;
return (
<button
key={rev.revisionId}
disabled={isCurrent}
onClick={() => setSelected(isSelected ? null : rev.revisionNumber)}
className="w-full flex items-start gap-3 p-3.5 rounded-xl text-left transition-all"
style={{
background: isSelected
? 'rgba(59,130,246,0.1)'
: 'rgba(255,255,255,0.025)',
border: `1px solid ${isSelected ? 'rgba(59,130,246,0.35)' : 'rgba(255,255,255,0.07)'}`,
opacity: isCurrent ? 0.6 : 1,
cursor: isCurrent ? 'default' : 'pointer',
}}
>
<GitCommit className="w-3.5 h-3.5 flex-shrink-0 mt-0.5" style={{ color }} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-xs font-mono font-semibold text-zinc-200">
rev.{rev.revisionNumber}
</span>
{isCurrent && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-blue-500/15 text-blue-300 border border-blue-500/25">
current
</span>
)}
<span
className="text-[10px] px-1.5 rounded-full"
style={{ background: `${color}18`, color }}
>
{rev.commitKind}
</span>
</div>
<p className="text-xs text-zinc-300 mt-0.5 truncate">{rev.commitSummary ?? 'Revision event'}</p>
<p className="text-[10px] text-zinc-600 mt-0.5">
{rev.actorId} · {new Date(rev.createdAt).toLocaleString()}
</p>
</div>
{isSelected && (
<div className="w-4 h-4 rounded-full bg-blue-500/25 border border-blue-400 flex items-center justify-center flex-shrink-0 mt-0.5">
<div className="w-2 h-2 rounded-full bg-blue-400" />
</div>
)}
</button>
);
})}
{!isLoading && revisions.length === 0 && (
<div className="p-4 text-xs text-zinc-500">No revisions have been committed yet.</div>
)}
</div>
{/* Actions */}
<div className="flex gap-2 flex-shrink-0">
<Button
variant="outline"
onClick={onClose}
className="flex-1 border-white/10 bg-white/5 hover:bg-white/10 text-zinc-300"
>
Cancel
</Button>
<Button
onClick={() => void handleRollback()}
disabled={!selected || submitting}
className="flex-1 bg-amber-600 hover:bg-amber-500 text-white"
>
{submitting ? 'Rolling back…' : selected ? `Restore rev.${selected}` : 'Select a revision'}
</Button>
</div>
</>
)}
</div>
</motion.div>
</>
)}
</AnimatePresence>
);
if (!mounted) return null;
return createPortal(content, document.body);
}

View File

@@ -0,0 +1,355 @@
import { useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { X, Share2, GitFork, Lock, Users, MessageSquare, ChevronDown } from 'lucide-react';
import { Button } from '@/components/ui/button';
import type { CanvasPage } from '../types/canvas';
import { listVelocityUsers, type VelocityActiveUser } from '@/lib/velocityPlatformClient';
interface ShareModalProps {
page: CanvasPage | null;
isOpen: boolean;
onClose: () => void;
currentUserId?: string | null;
onShare: (params: {
recipientUserId: string;
visibility: 'private' | 'team';
message: string;
sourceRevision: number;
}) => Promise<void>;
}
function getDisplayName(member: VelocityActiveUser): string {
return member.full_name?.trim() || member.email?.trim() || member.user_id;
}
function getRoleLabel(member: VelocityActiveUser): string {
return member.role
.toLowerCase()
.split('_')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
}
function getInitials(member: VelocityActiveUser): string {
const basis = getDisplayName(member);
return basis
.split(/[\s@._-]+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part.charAt(0).toUpperCase())
.join('') || 'U';
}
export function ShareModal({ page, isOpen, onClose, currentUserId, onShare }: ShareModalProps) {
const [mounted, setMounted] = useState(false);
const [teamMembers, setTeamMembers] = useState<VelocityActiveUser[]>([]);
const [loadingMembers, setLoadingMembers] = useState(false);
const [membersError, setMembersError] = useState<string | null>(null);
const [recipient, setRecipient] = useState<VelocityActiveUser | null>(null);
const [visibility, setVisibility] = useState<'private' | 'team'>('private');
const [message, setMessage] = useState('');
const [submitting, setSubmitting] = useState(false);
const [success, setSuccess] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [memberDropOpen, setMemberDropOpen] = useState(false);
useEffect(() => setMounted(true), []);
useEffect(() => {
if (!isOpen) {
setMemberDropOpen(false);
setSubmitError(null);
return;
}
let cancelled = false;
setLoadingMembers(true);
setMembersError(null);
void listVelocityUsers()
.then((users) => {
if (cancelled) return;
setTeamMembers(users);
})
.catch((error) => {
if (cancelled) return;
setMembersError(error instanceof Error ? error.message : 'Failed to load team members.');
setTeamMembers([]);
})
.finally(() => {
if (!cancelled) setLoadingMembers(false);
});
return () => {
cancelled = true;
};
}, [isOpen]);
const availableMembers = useMemo(
() => teamMembers.filter((member) => member.user_id !== currentUserId),
[teamMembers, currentUserId],
);
useEffect(() => {
if (recipient && recipient.user_id === currentUserId) {
setRecipient(null);
}
}, [recipient, currentUserId]);
const selectedRecipientLabel = useMemo(
() => (recipient ? getDisplayName(recipient) : 'Select verified teammate...'),
[recipient],
);
const handleShare = async () => {
if (!recipient || !page) return;
setSubmitting(true);
setSubmitError(null);
try {
await onShare({
recipientUserId: recipient.user_id,
visibility,
message,
sourceRevision: page.headRevision,
});
setSuccess(true);
setTimeout(() => {
setSuccess(false);
onClose();
setRecipient(null);
setMessage('');
}, 1800);
} catch (error) {
setSubmitError(error instanceof Error ? error.message : 'Share failed.');
} finally {
setSubmitting(false);
}
};
const content = (
<AnimatePresence>
{isOpen && (
<>
<motion.div
key="share-backdrop"
className="fixed inset-0 z-40"
style={{ background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(4px)' }}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
/>
<motion.div
key="share-modal"
className="fixed z-50 left-1/2 top-1/2 w-full max-w-md"
initial={{ opacity: 0, scale: 0.92, x: '-50%', y: '-50%' }}
animate={{ opacity: 1, scale: 1, x: '-50%', y: '-50%' }}
exit={{ opacity: 0, scale: 0.92, x: '-50%', y: '-50%' }}
transition={{ duration: 0.2 }}
>
<div
className="rounded-2xl p-6"
style={{
background: 'rgba(12, 13, 20, 0.98)',
border: '1px solid rgba(255,255,255,0.1)',
boxShadow: '0 24px 80px rgba(0,0,0,0.8)',
}}
>
<div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-xl bg-blue-500/15 border border-blue-500/25 flex items-center justify-center">
<Share2 className="w-4 h-4 text-blue-400" />
</div>
<div>
<h2 className="text-sm font-semibold text-zinc-100">Share Canvas</h2>
<p className="text-xs text-zinc-500">
{page?.title ?? 'Oracle Canvas'} · rev.{page?.headRevision}
</p>
</div>
</div>
<button onClick={onClose} className="text-zinc-600 hover:text-zinc-300 transition-colors">
<X className="w-4 h-4" />
</button>
</div>
<div
className="flex items-start gap-3 p-3 rounded-xl mb-5"
style={{ background: 'rgba(59,130,246,0.07)', border: '1px solid rgba(59,130,246,0.18)' }}
>
<GitFork className="w-4 h-4 text-blue-400 flex-shrink-0 mt-0.5" />
<p className="text-xs text-zinc-300 leading-relaxed">
The recipient gets a <span className="text-blue-300 font-medium">fork</span> of this canvas at the selected revision.
They can edit their copy and later open a merge request back into the source branch.
</p>
</div>
{success ? (
<div className="flex flex-col items-center gap-3 py-6">
<div className="w-12 h-12 rounded-full bg-green-500/15 border border-green-500/30 flex items-center justify-center">
<Share2 className="w-6 h-6 text-green-400" />
</div>
<p className="text-sm text-zinc-200 font-medium">Fork created successfully.</p>
<p className="text-xs text-zinc-500">{recipient ? getDisplayName(recipient) : 'Recipient'} can access the shared copy.</p>
</div>
) : (
<div className="space-y-4">
{submitError && (
<div
className="rounded-xl px-3 py-2 text-xs text-red-300"
style={{
background: 'rgba(239,68,68,0.08)',
border: '1px solid rgba(239,68,68,0.2)',
}}
>
{submitError}
</div>
)}
<div>
<label className="text-xs font-medium text-zinc-400 mb-1.5 block">Recipient</label>
<div className="relative">
<button
onClick={() => setMemberDropOpen((prev) => !prev)}
className="w-full flex items-center justify-between px-3 py-2.5 rounded-xl text-sm"
style={{
background: 'rgba(255,255,255,0.04)',
border: '1px solid rgba(255,255,255,0.1)',
color: recipient ? '#e4e4e7' : '#71717a',
}}
>
<div className="flex items-center gap-2">
<Users className="w-3.5 h-3.5 text-zinc-500" />
<span>{selectedRecipientLabel}</span>
</div>
<ChevronDown
className="w-3.5 h-3.5 text-zinc-600"
style={{ transform: memberDropOpen ? 'rotate(180deg)' : 'none' }}
/>
</button>
<AnimatePresence>
{memberDropOpen && (
<motion.div
className="absolute top-full left-0 right-0 mt-1 z-50 rounded-xl py-1 overflow-hidden shadow-2xl"
style={{ background: 'rgb(14,15,22)', border: '1px solid rgba(255,255,255,0.12)' }}
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
>
{loadingMembers && (
<div className="px-3 py-3 text-xs text-zinc-500">Loading verified accounts...</div>
)}
{!loadingMembers && membersError && (
<div className="px-3 py-3 text-xs text-red-400">{membersError}</div>
)}
{!loadingMembers && !membersError && availableMembers.length === 0 && (
<div className="px-3 py-3 text-xs text-zinc-500">No verified users available.</div>
)}
{!loadingMembers && !membersError && availableMembers.map((member) => (
<button
key={member.user_id}
className="w-full flex items-center gap-3 px-3 py-2.5 hover:bg-white/5 transition-colors text-left"
onClick={() => {
setRecipient(member);
setMemberDropOpen(false);
}}
>
{member.avatar_url ? (
<img
src={member.avatar_url}
alt={getDisplayName(member)}
className="w-7 h-7 rounded-full object-cover"
/>
) : (
<div className="w-7 h-7 rounded-full bg-blue-500/15 border border-blue-500/25 text-[10px] font-semibold text-blue-300 flex items-center justify-center">
{getInitials(member)}
</div>
)}
<div className="min-w-0">
<p className="text-sm text-zinc-200 truncate">{getDisplayName(member)}</p>
<p className="text-[10px] text-zinc-500 truncate">
{member.email || member.user_id} · {getRoleLabel(member)}
</p>
</div>
</button>
))}
</motion.div>
)}
</AnimatePresence>
</div>
</div>
<div>
<label className="text-xs font-medium text-zinc-400 mb-1.5 block">Fork visibility</label>
<div className="grid grid-cols-2 gap-2">
{([
{ value: 'private', icon: Lock, label: 'Private', desc: 'Only recipient' },
{ value: 'team', icon: Users, label: 'Team', desc: 'Whole team' },
] as const).map(({ value, icon: Icon, label, desc }) => (
<button
key={value}
onClick={() => setVisibility(value)}
className="flex items-center gap-2 p-3 rounded-xl text-left transition-all"
style={{
background: visibility === value ? 'rgba(59,130,246,0.12)' : 'rgba(255,255,255,0.03)',
border: `1px solid ${visibility === value ? 'rgba(59,130,246,0.35)' : 'rgba(255,255,255,0.08)'}`,
}}
>
<Icon className="w-4 h-4" style={{ color: visibility === value ? '#60a5fa' : '#52525b' }} />
<div>
<p className="text-xs font-medium" style={{ color: visibility === value ? '#93c5fd' : '#a1a1aa' }}>{label}</p>
<p className="text-[10px] text-zinc-600">{desc}</p>
</div>
</button>
))}
</div>
</div>
<div>
<label className="text-xs font-medium text-zinc-400 mb-1.5 flex items-center gap-1.5">
<MessageSquare className="w-3 h-3" />
Message <span className="text-zinc-600">(optional)</span>
</label>
<textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Add context for the recipient..."
rows={3}
className="w-full px-3 py-2.5 text-sm rounded-xl resize-none focus:outline-none"
style={{
background: 'rgba(255,255,255,0.04)',
border: '1px solid rgba(255,255,255,0.1)',
color: '#e4e4e7',
}}
/>
</div>
<div className="flex gap-2 pt-1">
<Button
variant="outline"
onClick={onClose}
className="flex-1 border-white/10 bg-white/5 hover:bg-white/10 text-zinc-300"
>
Cancel
</Button>
<Button
onClick={() => void handleShare()}
disabled={!recipient || submitting}
className="flex-1 bg-blue-600 hover:bg-blue-500 text-white"
>
{submitting ? 'Creating fork...' : 'Share (Create Fork)'}
</Button>
</div>
</div>
)}
</div>
</motion.div>
</>
)}
</AnimatePresence>
);
if (!mounted) return null;
return createPortal(content, document.body);
}

View File

@@ -0,0 +1,135 @@
import { motion } from 'framer-motion';
import type { CanvasComponent } from '../../types/canvas';
import { RendererWrapper, NoDataPlaceholder, type ComponentRenderContext } from './RendererWrapper';
import type { FC } from 'react';
import { Mail, Phone, Users, Calendar, MessageCircle, Clock, ArrowRight } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
interface Props { component: CanvasComponent; ctx: ComponentRenderContext }
const URGENCY_COLORS = {
critical: { bg: 'rgba(239,68,68,0.1)', border: 'rgba(239,68,68,0.25)', text: '#f87171', label: 'Critical' },
high: { bg: 'rgba(251,191,36,0.1)', border: 'rgba(251,191,36,0.25)', text: '#fbbf24', label: 'High' },
medium: { bg: 'rgba(59,130,246,0.1)', border: 'rgba(59,130,246,0.2)', text: '#60a5fa', label: 'Medium' },
low: { bg: 'rgba(255,255,255,0.03)', border: 'rgba(255,255,255,0.07)', text: '#71717a', label: 'Low' },
};
const TYPE_ICONS: Record<string, FC<{ className?: string }>> = {
Email: Mail,
Call: Phone,
Meeting: Users,
Appointment: Calendar,
WhatsApp: MessageCircle,
};
export function ActivityStreamRenderer({ component, ctx }: Props) {
const rows = (component.dataRows ?? []) as Array<Record<string, unknown>>;
if (!rows.length) return (
<RendererWrapper component={component} ctx={ctx} minHeight={320}>
<NoDataPlaceholder message="No activities found matching your filters." />
</RendererWrapper>
);
const hasUrgency = rows.some((r) => r.urgency !== undefined);
const hasTimeline = rows.some((r) => r.type !== undefined && r.date !== undefined);
return (
<RendererWrapper component={component} ctx={ctx} minHeight={340}>
{hasTimeline ? (
// Timeline layout
<div className="relative space-y-4 pl-6">
<div className="absolute left-[17px] top-1 bottom-4 w-[2px] bg-gradient-to-b from-blue-500 via-cyan-500/40 to-transparent" />
{rows.map((row, i) => {
const TypeIcon = TYPE_ICONS[String(row.type ?? '')] ?? Calendar;
return (
<motion.div
key={i}
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: i * 0.07 }}
className="relative pl-5 group cursor-pointer"
>
<div className="absolute -left-[3px] top-1.5 h-3.5 w-3.5 rounded-full bg-zinc-950 border-2 border-blue-400 shadow-[0_0_10px_rgba(59,130,246,0.6)]" />
<div
className="p-3.5 rounded-xl"
style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.06)' }}
>
<div className="flex items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2 mb-1">
<TypeIcon className="w-3.5 h-3.5 text-blue-400" />
<span className="text-xs font-semibold uppercase tracking-wider text-blue-300">
{String(row.type ?? '')}
</span>
</div>
<p className="text-sm text-zinc-200 font-medium">
{String(row.title ?? row.summary ?? '')}
</p>
{row.actor !== undefined && (
<p className="text-xs text-zinc-500 mt-0.5">{String(row.actor)}</p>
)}
</div>
<span className="text-xs text-zinc-600 font-mono whitespace-nowrap flex-shrink-0">
{String(row.date ?? row.when ?? '')}
</span>
</div>
</div>
</motion.div>
);
})}
</div>
) : hasUrgency ? (
// Follow-up queue layout
<div className="space-y-2">
{rows.map((row, i) => {
const urgency = String(row.urgency ?? 'low') as keyof typeof URGENCY_COLORS;
const uc = URGENCY_COLORS[urgency] ?? URGENCY_COLORS.low;
const hoursAgo = Number(row.last_contact_hours_ago ?? 0);
return (
<motion.div
key={i}
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.06 }}
className="flex items-center gap-3 p-3 rounded-xl group cursor-pointer"
style={{ background: uc.bg, border: `1px solid ${uc.border}` }}
>
<Avatar className="h-9 w-9 border border-white/10 flex-shrink-0">
<AvatarImage src={String(row.avatar ?? '')} />
<AvatarFallback className="bg-zinc-800 text-zinc-400 text-xs">
{String(row.name ?? '').slice(0, 2)}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-zinc-100 truncate">{String(row.name ?? '')}</p>
<div className="flex items-center gap-2 mt-0.5">
<Clock className="w-3 h-3" style={{ color: uc.text }} />
<span className="text-xs" style={{ color: uc.text }}>
{hoursAgo}h without contact
</span>
<span className="text-xs text-zinc-500"> {String(row.assigned_broker ?? '')}</span>
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<Badge
className="text-[10px] h-5 px-1.5"
style={{ background: uc.bg, color: uc.text, border: `1px solid ${uc.border}` }}
>
{uc.label}
</Badge>
<span className="text-xs text-zinc-500 font-mono">QD:{Number(row.qd_score ?? 0).toFixed(0)}</span>
<ArrowRight className="w-4 h-4 text-zinc-600 group-hover:text-zinc-400 transition-colors" />
</div>
</motion.div>
);
})}
</div>
) : (
<NoDataPlaceholder />
)}
</RendererWrapper>
);
}

View File

@@ -0,0 +1,71 @@
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, Cell } from 'recharts';
import type { CanvasComponent } from '../../types/canvas';
import { RendererWrapper, GlassTooltip, NoDataPlaceholder, type ComponentRenderContext } from './RendererWrapper';
interface Props { component: CanvasComponent; ctx: ComponentRenderContext }
const PALETTE = ['#0EA5E9', '#22D3EE', '#3B82F6', '#8B5CF6', '#06B6D4', '#A78BFA'];
export function BarChartRenderer({ component, ctx }: Props) {
const params = component.visualizationParameters as {
xAxis?: string;
yAxis?: string;
sort?: 'asc' | 'desc';
showLabels?: boolean;
legend?: boolean;
colorScale?: string[];
};
const rows = (component.dataRows ?? []) as Array<Record<string, number | string>>;
const xKey = params.xAxis ?? component.dataBindings.dimensions[0] ?? 'category';
const yKey = params.yAxis ?? component.dataBindings.measures[0] ?? 'value';
const colors = params.colorScale ?? PALETTE;
if (!rows.length) return (
<RendererWrapper component={component} ctx={ctx}>
<NoDataPlaceholder />
</RendererWrapper>
);
const sorted = params.sort
? [...rows].sort((a, b) =>
params.sort === 'desc'
? Number(b[yKey]) - Number(a[yKey])
: Number(a[yKey]) - Number(b[yKey]),
)
: rows;
return (
<RendererWrapper component={component} ctx={ctx} minHeight={300}>
<div className="h-[220px] w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={sorted} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}>
<XAxis
dataKey={xKey}
stroke="#3f3f46"
tick={{ fontSize: 11, fill: '#71717a' }}
axisLine={false}
tickLine={false}
dy={6}
/>
<YAxis
stroke="#3f3f46"
tick={{ fontSize: 11, fill: '#71717a' }}
axisLine={false}
tickLine={false}
/>
<Tooltip content={<GlassTooltip />} cursor={{ fill: 'rgba(59,130,246,0.06)' }} />
<Bar dataKey={yKey} radius={[6, 6, 0, 0]} maxBarSize={48}>
{sorted.map((_, index) => (
<Cell
key={`cell-${index}`}
fill={colors[index % colors.length]}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</RendererWrapper>
);
}

View File

@@ -0,0 +1,61 @@
import { motion } from 'framer-motion';
import type { CanvasComponent } from '../../types/canvas';
import { RendererWrapper, type ComponentRenderContext } from './RendererWrapper';
import { AlertTriangle, AlertCircle, Info, CheckCircle2 } from 'lucide-react';
interface Props { component: CanvasComponent; ctx: ComponentRenderContext }
const SEVERITY_MAP = {
error: { icon: AlertTriangle, bg: 'rgba(239,68,68,0.08)', border: 'rgba(239,68,68,0.25)', text: '#f87171', accent: '#ef4444' },
warning: { icon: AlertCircle, bg: 'rgba(251,191,36,0.08)', border: 'rgba(251,191,36,0.25)', text: '#fbbf24', accent: '#f59e0b' },
info: { icon: Info, bg: 'rgba(59,130,246,0.08)', border: 'rgba(59,130,246,0.2)', text: '#60a5fa', accent: '#3b82f6' },
success: { icon: CheckCircle2, bg: 'rgba(52,211,153,0.08)', border: 'rgba(52,211,153,0.25)', text: '#34d399', accent: '#10b981' },
};
export function ErrorNoticeRenderer({ component, ctx }: Props) {
const params = component.visualizationParameters as {
errorCode?: string;
message?: string;
severity?: 'error' | 'warning' | 'info' | 'success';
retryable?: boolean;
};
const severity = params.severity ?? 'warning';
const { icon: Icon, bg, border, text, accent } = SEVERITY_MAP[severity] ?? SEVERITY_MAP.warning;
return (
<RendererWrapper component={component} ctx={ctx} minHeight={120}>
<motion.div
className="flex items-start gap-4 p-4 rounded-xl h-full"
style={{ background: bg, border: `1px solid ${border}` }}
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
>
<div
className="w-9 h-9 rounded-xl flex items-center justify-center flex-shrink-0 mt-0.5"
style={{ background: `${accent}20` }}
>
<Icon className="w-5 h-5" style={{ color: text }} />
</div>
<div className="min-w-0 flex-1">
{params.errorCode && (
<p className="text-[10px] font-mono uppercase tracking-widest mb-1" style={{ color: `${text}80` }}>
{params.errorCode}
</p>
)}
<p className="text-sm font-medium" style={{ color: text }}>
{params.message ?? 'An error occurred rendering this component.'}
</p>
{params.retryable && (
<button
className="text-xs mt-2 underline underline-offset-2 hover:opacity-80 transition-opacity"
style={{ color: text }}
>
Retry
</button>
)}
</div>
</motion.div>
</RendererWrapper>
);
}

View File

@@ -0,0 +1,115 @@
import { motion } from 'framer-motion';
import type { CanvasComponent } from '../../types/canvas';
import { RendererWrapper, NoDataPlaceholder, type ComponentRenderContext } from './RendererWrapper';
interface Props { component: CanvasComponent; ctx: ComponentRenderContext }
interface GeoRow {
district: string;
lat?: number;
lng?: number;
lead_count?: number;
count?: number;
avg_qd_score?: number;
x?: number;
y?: number;
}
export function GeoMapRenderer({ component, ctx }: Props) {
const rows = (component.dataRows ?? []) as unknown as GeoRow[];
const intensityField = (component.visualizationParameters as { intensityField?: string }).intensityField ?? 'lead_count';
const tooltipFields = (component.visualizationParameters as { tooltipFields?: string[] }).tooltipFields ?? ['district', 'lead_count'];
if (!rows.length) return (
<RendererWrapper component={component} ctx={ctx} minHeight={360}>
<NoDataPlaceholder message="No geographic data available." />
</RendererWrapper>
);
const maxVal = Math.max(...rows.map((r) => Number(r[intensityField as keyof GeoRow] ?? r.count ?? r.lead_count ?? 0)));
return (
<RendererWrapper component={component} ctx={ctx} minHeight={400}>
<div className="relative w-full h-[280px] rounded-xl overflow-hidden" style={{
background: 'radial-gradient(ellipse at center, rgba(14,116,144,0.12) 0%, rgba(10,12,20,0.95) 100%)',
border: '1px solid rgba(59,130,246,0.12)',
}}>
{/* Dubai grid lines (decorative) */}
<svg className="absolute inset-0 w-full h-full opacity-10" xmlns="http://www.w3.org/2000/svg">
<defs>
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
<path d="M 40 0 L 0 0 0 40" fill="none" stroke="#3B82F6" strokeWidth="0.5" />
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#grid)" />
</svg>
{/* Coastline glow */}
<div
className="absolute bottom-0 left-0 right-0 h-24 pointer-events-none"
style={{ background: 'linear-gradient(to top, rgba(14,116,144,0.15) 0%, transparent 100%)' }}
/>
{/* Legend */}
<div
className="absolute left-3 top-3 z-10 px-3 py-2.5 rounded-xl text-xs"
style={{ background: 'rgba(10,12,20,0.88)', border: '1px solid rgba(255,255,255,0.08)', backdropFilter: 'blur(8px)' }}
>
<p className="text-zinc-500 uppercase tracking-wider text-[10px] mb-2">Lead Intensity</p>
{[
{ label: 'High', color: '#0EA5E9' },
{ label: 'Medium', color: '#22D3EE' },
{ label: 'Low', color: '#164E63' },
].map(({ label, color }) => (
<div key={label} className="flex items-center gap-2 mb-1">
<div className="w-2 h-2 rounded-full" style={{ background: color, boxShadow: `0 0 8px ${color}` }} />
<span className="text-zinc-300">{label}</span>
</div>
))}
</div>
{/* District pins */}
{rows.map((row, i) => {
const val = Number(row[intensityField as keyof GeoRow] ?? row.count ?? row.lead_count ?? 0);
const ratio = maxVal > 0 ? val / maxVal : 0;
const color = ratio > 0.7 ? '#0EA5E9' : ratio > 0.4 ? '#22D3EE' : '#164E63';
const size = 6 + ratio * 14;
const x = row.x ?? (20 + i * 12);
const y = row.y ?? (30 + i * 8);
return (
<motion.div
key={row.district}
className="absolute group cursor-pointer"
style={{ left: `${x}%`, top: `${y}%`, transform: 'translate(-50%, -50%)' }}
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: i * 0.08, type: 'spring', damping: 10 }}
>
<motion.div
className="rounded-full"
style={{
width: size,
height: size,
background: color,
boxShadow: `0 0 ${size * 2}px ${color}88`,
}}
whileHover={{ scale: 1.5 }}
/>
{/* Tooltip */}
<div
className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2.5 py-1.5 rounded-lg text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-30"
style={{ background: 'rgba(10,12,20,0.95)', border: '1px solid rgba(59,130,246,0.2)', backdropFilter: 'blur(8px)' }}
>
<p className="font-semibold text-zinc-100">{row.district}</p>
{tooltipFields.map((f) => (
<p key={f} className="text-zinc-400 capitalize">{f.replace(/_/g, ' ')}: {String(row[f as keyof GeoRow] ?? '—')}</p>
))}
</div>
</motion.div>
);
})}
</div>
</RendererWrapper>
);
}

View File

@@ -0,0 +1,69 @@
import type { CanvasComponent } from '../../types/canvas';
import { RendererWrapper, type ComponentRenderContext } from './RendererWrapper';
import { TrendingUp, TrendingDown, Minus } from 'lucide-react';
interface Props { component: CanvasComponent; ctx: ComponentRenderContext }
export function KpiTileRenderer({ component, ctx }: Props) {
const params = component.visualizationParameters as {
value?: number | string;
label?: string;
trend?: string;
unit?: string;
comparisonLabel?: string;
comparisonValue?: string;
};
const trendValue = params.trend ?? '';
const isPositive = trendValue.startsWith('+') || (parseFloat(trendValue) > 0);
const isNegative = trendValue.startsWith('-') || (parseFloat(trendValue) < 0);
const TrendIcon = isPositive ? TrendingUp : isNegative ? TrendingDown : Minus;
const trendColor = isPositive ? '#34d399' : isNegative ? '#f87171' : '#71717a';
const dataRow = component.dataRows?.[0];
const displayValue = dataRow
? String(Object.values(dataRow)[0] ?? params.value ?? '—')
: String(params.value ?? '—');
return (
<RendererWrapper component={component} ctx={ctx} minHeight={140}>
<div className="flex flex-col items-start justify-center h-full gap-2 py-2">
<div className="flex items-baseline gap-2">
<span
className="text-4xl font-bold tracking-tight"
style={{
background: 'linear-gradient(135deg, #fff 0%, #94a3b8 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
}}
>
{displayValue}
</span>
{params.unit && (
<span className="text-lg text-zinc-500 font-medium">{params.unit}</span>
)}
</div>
{params.label && (
<p className="text-sm text-zinc-400">{params.label}</p>
)}
{trendValue && (
<div className="flex items-center gap-1.5 mt-1">
<TrendIcon className="w-4 h-4" style={{ color: trendColor }} />
<span className="text-sm font-medium" style={{ color: trendColor }}>{trendValue}</span>
{params.comparisonLabel && (
<span className="text-xs text-zinc-600">{params.comparisonLabel}</span>
)}
</div>
)}
{/* Ambient glow */}
<div
className="absolute bottom-4 right-4 w-24 h-24 rounded-full pointer-events-none"
style={{ background: 'radial-gradient(circle, rgba(59,130,246,0.12) 0%, transparent 70%)' }}
/>
</div>
</RendererWrapper>
);
}

View File

@@ -0,0 +1,54 @@
import { ResponsiveContainer, LineChart, Line, XAxis, YAxis, Tooltip, CartesianGrid } from 'recharts';
import type { CanvasComponent } from '../../types/canvas';
import { RendererWrapper, GlassTooltip, NoDataPlaceholder, type ComponentRenderContext } from './RendererWrapper';
interface Props { component: CanvasComponent; ctx: ComponentRenderContext }
export function LineChartRenderer({ component, ctx }: Props) {
const rows = (component.dataRows ?? []) as Array<Record<string, number | string>>;
const dims = component.dataBindings.dimensions;
const measures = component.dataBindings.measures;
const xKey = dims[0] ?? 'date';
const LINE_COLORS = ['#3B82F6', '#22D3EE', '#A78BFA', '#34D399'];
if (!rows.length) return (
<RendererWrapper component={component} ctx={ctx}>
<NoDataPlaceholder />
</RendererWrapper>
);
return (
<RendererWrapper component={component} ctx={ctx} minHeight={300}>
<div className="h-[220px] w-full">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={rows} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}>
<defs>
{measures.map((m, i) => (
<linearGradient key={m} id={`grad_${m}`} x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stopColor={LINE_COLORS[i % LINE_COLORS.length]} />
<stop offset="100%" stopColor={LINE_COLORS[(i + 1) % LINE_COLORS.length]} />
</linearGradient>
))}
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.04)" vertical={false} />
<XAxis dataKey={xKey} stroke="#3f3f46" tick={{ fontSize: 11, fill: '#71717a' }} axisLine={false} tickLine={false} dy={6} />
<YAxis stroke="#3f3f46" tick={{ fontSize: 11, fill: '#71717a' }} axisLine={false} tickLine={false} />
<Tooltip content={<GlassTooltip />} />
{measures.map((m, i) => (
<Line
key={m}
type="monotone"
dataKey={m}
stroke={`url(#grad_${m})`}
strokeWidth={2.5}
dot={{ r: 3, fill: '#0a0c14', stroke: LINE_COLORS[i % LINE_COLORS.length], strokeWidth: 2 }}
activeDot={{ r: 5, fill: LINE_COLORS[i % LINE_COLORS.length] }}
/>
))}
</LineChart>
</ResponsiveContainer>
</div>
</RendererWrapper>
);
}

View File

@@ -0,0 +1,121 @@
import { motion } from 'framer-motion';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { useCurrency } from '@/store/useCurrencyStore';
import type { CanvasComponent } from '../../types/canvas';
import { RendererWrapper, NoDataPlaceholder, type ComponentRenderContext } from './RendererWrapper';
interface Props { component: CanvasComponent; ctx: ComponentRenderContext }
interface PipelineRow {
stage: string;
count: number;
value?: number;
leads?: Array<{ id: string; name: string; company: string; value: string; avatar?: string }>;
}
const STAGE_COLORS: Record<string, string> = {
'New Leads': '#3B82F6',
'Qualified': '#22D3EE',
'Proposal Sent': '#A78BFA',
'Negotiation': '#F59E0B',
'Closed': '#34D399',
};
export function PipelineBoardRenderer({ component, ctx }: Props) {
const { formatAmount, formatText } = useCurrency();
const rows = (component.dataRows ?? []) as unknown as PipelineRow[];
if (!rows.length) return (
<RendererWrapper component={component} ctx={ctx} minHeight={360}>
<NoDataPlaceholder message="No pipeline data available." />
</RendererWrapper>
);
return (
<RendererWrapper component={component} ctx={ctx} minHeight={380}>
<div className="grid gap-3" style={{ gridTemplateColumns: `repeat(${Math.min(rows.length, 4)}, 1fr)` }}>
{rows.map((stageData, si) => {
const color = STAGE_COLORS[stageData.stage] ?? '#60a5fa';
return (
<motion.div
key={stageData.stage}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: si * 0.07 }}
className="flex flex-col rounded-xl p-3"
style={{ background: 'rgba(255,255,255,0.025)', border: '1px solid rgba(255,255,255,0.07)' }}
>
{/* Stage header */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full" style={{ background: color, boxShadow: `0 0 8px ${color}` }} />
<span className="text-xs font-medium uppercase tracking-wider text-zinc-400">{stageData.stage}</span>
</div>
<Badge
variant="outline"
className="border-white/10 bg-white/5 text-zinc-400 text-[10px] h-5 px-1.5"
>
{stageData.count}
</Badge>
</div>
{/* Lead cards */}
<div className="space-y-2 flex-1">
{(stageData.leads ?? []).slice(0, 3).map((lead, li) => (
<motion.div
key={lead.id}
initial={{ opacity: 0, x: -4 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: si * 0.07 + li * 0.05 }}
className="group p-2.5 rounded-lg relative"
style={{
background: 'rgba(255,255,255,0.02)',
border: '1px solid rgba(255,255,255,0.05)',
cursor: 'pointer',
}}
whileHover={{ background: `${color}12`, borderColor: `${color}30` }}
>
{/* Accent bar */}
<div
className="absolute left-0 top-2 bottom-2 w-0.5 rounded-r-full opacity-0 group-hover:opacity-100 transition-opacity"
style={{ background: color }}
/>
<div className="flex items-center gap-2 pl-1">
<Avatar className="h-7 w-7 border border-white/10 flex-shrink-0">
<AvatarImage src={lead.avatar} />
<AvatarFallback className="bg-zinc-800 text-zinc-400 text-[10px]">
{lead.name.slice(0, 2)}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-zinc-100 truncate">{lead.name}</p>
<p className="text-[10px] text-zinc-500 truncate">{formatText(lead.value)}</p>
</div>
</div>
</motion.div>
))}
{stageData.count > 3 && (
<p className="text-[10px] text-zinc-600 text-center pt-1">
+{stageData.count - 3} more
</p>
)}
</div>
{/* Stage total */}
{stageData.value !== undefined && (
<div className="mt-2 pt-2" style={{ borderTop: '1px solid rgba(255,255,255,0.06)' }}>
<p className="text-[10px] text-zinc-600 uppercase tracking-wider">Total</p>
<p className="text-sm font-semibold text-zinc-200">
{formatAmount(stageData.value, { compact: true })}
</p>
</div>
)}
</motion.div>
);
})}
</div>
</RendererWrapper>
);
}

View File

@@ -0,0 +1,184 @@
/**
* Shared renderer utilities and wrapper — used by all canvas component renderers.
*/
import { useState } from 'react';
import { motion } from 'framer-motion';
import { Database, Shield, BarChart2, Info, Users, Lock } from 'lucide-react';
import { toast } from 'sonner';
import type { CanvasComponent } from '../../types/canvas';
import type { ComponentRenderContext } from '../ComponentRegistry';
export { type ComponentRenderContext };
export const GLASS_PANEL = {
background: 'rgba(10, 12, 20, 0.72)',
border: '1px solid rgba(59,130,246,0.12)',
backdropFilter: 'blur(20px)',
WebkitBackdropFilter: 'blur(20px)',
boxShadow: '0 6px 24px rgba(0,0,0,0.45), 0 0 0 1px rgba(255,255,255,0.03)',
borderRadius: '16px',
} as const;
const PRIVACY_COLORS = {
standard: { color: '#22d3ee', label: 'Standard' },
restricted: { color: '#fbbf24', label: 'Restricted' },
sensitive: { color: '#f87171', label: 'Sensitive' },
};
interface RendererWrapperProps {
component: CanvasComponent;
ctx: ComponentRenderContext;
children: React.ReactNode;
minHeight?: number;
}
export function RendererWrapper({ component, ctx, children, minHeight = 280 }: RendererWrapperProps) {
const [localVisibility, setLocalVisibility] = useState(component.accessControls.visibilityScope);
const privacy = PRIVACY_COLORS[component.dataSourceDescriptor.privacyTier] ?? PRIVACY_COLORS.standard;
return (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.28, ease: [0.4, 0, 0.2, 1] }}
className="group relative w-full"
style={{ minHeight }}
onClick={() => ctx.onSelect?.(component.componentId)}
>
<div
className="h-full p-5 flex flex-col"
style={{
...GLASS_PANEL,
...(ctx.isSelected ? {
border: '1px solid rgba(59,130,246,0.45)',
boxShadow: '0 0 0 2px rgba(59,130,246,0.2), 0 6px 24px rgba(0,0,0,0.45)',
} : {}),
}}
>
{/* Header */}
<div className="flex items-start justify-between gap-3 mb-4 flex-shrink-0">
<div className="min-w-0">
<h3 className="text-sm font-semibold text-zinc-100 truncate">{component.title}</h3>
{component.description && (
<p className="text-xs text-zinc-500 mt-0.5 line-clamp-1">{component.description}</p>
)}
</div>
{/* Badges */}
<div className="flex items-center gap-1.5 flex-shrink-0">
{ctx.showLineageBadges && (
<span
className="flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded-full"
style={{ background: `${privacy.color}18`, color: privacy.color, border: `1px solid ${privacy.color}30` }}
title={`Privacy tier: ${privacy.label}`}
>
<Shield className="w-2.5 h-2.5" />
{privacy.label}
</span>
)}
<span
className="flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded-full"
style={{ background: 'rgba(255,255,255,0.04)', color: '#52525b', border: '1px solid rgba(255,255,255,0.07)' }}
>
v{component.version}
</span>
</div>
</div>
{/* Content */}
<div className="flex-1 min-h-0">
{children}
</div>
{/* Footer — provenance + dataset */}
<div className="flex items-center justify-between mt-3 pt-3 flex-shrink-0" style={{ borderTop: '1px solid rgba(255,255,255,0.05)' }}>
<div className="flex items-center gap-2">
<Database className="w-3 h-3 text-zinc-600" />
<span className="text-[10px] text-zinc-600 font-mono truncate max-w-[140px]">
{component.dataSourceDescriptor.dataset}
</span>
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
const isPrivate = localVisibility === 'private';
const nextVisibility = isPrivate ? 'tenant_team' : 'private';
setLocalVisibility(nextVisibility);
if (nextVisibility === 'tenant_team') {
toast.success('Visibility set to Team', {
description: 'This component is now visible to other members on this fork according to team policy.'
});
} else {
toast.info('Visibility set to Private', {
description: 'This component is now restricted and visible only to you.'
});
}
}}
className="flex items-center gap-1.5 text-[10px] text-zinc-500 hover:text-zinc-200 transition-colors px-1.5 py-0.5 rounded hover:bg-white/10"
>
{localVisibility === 'private' ? (
<>
<Lock className="w-3 h-3" />
<span>private</span>
</>
) : (
<>
<Users className="w-3 h-3" />
<span>team</span>
</>
)}
</button>
</div>
</div>
</motion.div>
);
}
/** Tooltip for chart hover states */
export function GlassTooltip({ active, payload, label }: { active?: boolean; payload?: Array<{ name: string; value: number | string; color: string }>; label?: string }) {
if (!active || !payload?.length) return null;
return (
<div
className="rounded-xl px-3 py-2 text-sm shadow-2xl"
style={{
background: 'rgba(10,12,20,0.95)',
border: '1px solid rgba(59,130,246,0.2)',
backdropFilter: 'blur(8px)',
}}
>
{label && <p className="text-xs text-zinc-400 mb-1">{label}</p>}
{payload.map((p) => (
<div key={p.name} className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full flex-shrink-0" style={{ background: p.color }} />
<span className="text-zinc-200 font-medium">{p.value}</span>
</div>
))}
</div>
);
}
/** Lineage badge */
export function LineageBadge({ promptExecutionId }: { promptExecutionId?: string }) {
if (!promptExecutionId) return null;
return (
<div
className="flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded-full"
style={{ background: 'rgba(59,130,246,0.1)', color: '#60a5fa', border: '1px solid rgba(59,130,246,0.2)' }}
title={`Prompt execution: ${promptExecutionId}`}
>
<Info className="w-2.5 h-2.5" />
Prompt-generated
</div>
);
}
/** No-data placeholder */
export function NoDataPlaceholder({ message = 'No data available for the selected filters.' }: { message?: string }) {
return (
<div className="h-full flex flex-col items-center justify-center gap-3 py-8">
<BarChart2 className="w-10 h-10 text-zinc-700" />
<p className="text-sm text-zinc-500 text-center max-w-xs">{message}</p>
</div>
);
}

View File

@@ -0,0 +1,97 @@
import { motion } from 'framer-motion';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import type { CanvasComponent } from '../../types/canvas';
import { RendererWrapper, NoDataPlaceholder, type ComponentRenderContext } from './RendererWrapper';
import { Trophy } from 'lucide-react';
import { useCurrency } from '@/store/useCurrencyStore';
interface Props { component: CanvasComponent; ctx: ComponentRenderContext }
export function TableRenderer({ component, ctx }: Props) {
const { formatText } = useCurrency();
const rows = (component.dataRows ?? []) as Array<Record<string, unknown>>;
const columns = (component.visualizationParameters as { columns?: string[] }).columns
?? (rows[0] ? Object.keys(rows[0]).filter((k) => !['avatar', 'rank'].includes(k)) : []);
if (!rows.length) return (
<RendererWrapper component={component} ctx={ctx} minHeight={260}>
<NoDataPlaceholder />
</RendererWrapper>
);
const hasAvatar = rows.some((r) => r.avatar);
const hasRank = rows.some((r) => r.rank !== undefined);
return (
<RendererWrapper component={component} ctx={ctx} minHeight={280}>
<div className="overflow-hidden rounded-xl" style={{ border: '1px solid rgba(255,255,255,0.06)' }}>
{/* Header */}
<div
className="grid gap-3 px-4 py-2.5 text-[10px] uppercase tracking-widest text-zinc-500"
style={{
gridTemplateColumns: hasRank
? `28px ${hasAvatar ? '40px' : ''} 1fr repeat(${columns.filter((c) => !['name', 'avatar', 'rank'].includes(c)).length}, minmax(80px, 1fr))`
: `${hasAvatar ? '40px' : ''} 1fr repeat(${columns.filter((c) => !['name', 'avatar', 'rank'].includes(c)).length}, minmax(80px, 1fr))`,
background: 'rgba(255,255,255,0.03)',
borderBottom: '1px solid rgba(255,255,255,0.06)',
}}
>
{hasRank && <span>#</span>}
{hasAvatar && <span />}
{columns.map((c) => (
<span key={c} className="capitalize">{c.replace(/_/g, ' ')}</span>
))}
</div>
{/* Rows */}
<div className="divide-y divide-white/[0.04]">
{rows.map((row, i) => (
<motion.div
key={i}
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: i * 0.05 }}
className="grid gap-3 px-4 py-3 items-center hover:bg-white/[0.02] transition-colors group cursor-pointer"
style={{
gridTemplateColumns: hasRank
? `28px ${hasAvatar ? '40px' : ''} 1fr repeat(${columns.filter((c) => !['name', 'avatar', 'rank'].includes(c)).length}, minmax(80px, 1fr))`
: `${hasAvatar ? '40px' : ''} 1fr repeat(${columns.filter((c) => !['name', 'avatar', 'rank'].includes(c)).length}, minmax(80px, 1fr))`,
}}
>
{hasRank && (
<div className="flex items-center justify-center">
{Number(row.rank) === 1 ? (
<Trophy className="w-4 h-4 text-amber-400" />
) : (
<span className="text-xs text-zinc-500 font-mono">{String(row.rank)}</span>
)}
</div>
)}
{hasAvatar && (
<Avatar className="h-8 w-8 border border-white/10">
<AvatarImage src={String(row.avatar ?? '')} />
<AvatarFallback className="bg-zinc-800 text-zinc-400 text-xs">
{String(row.name ?? '').slice(0, 2)}
</AvatarFallback>
</Avatar>
)}
{columns.map((col) => (
<div key={col} className="min-w-0">
{col === 'name' ? (
<span className="text-sm text-zinc-100 font-medium group-hover:text-blue-100 transition-colors truncate block">
{formatText(String(row[col] ?? '—'))}
</span>
) : (
<span className="text-sm text-zinc-300 font-mono truncate block">
{formatText(String(row[col] ?? '—'))}
</span>
)}
</div>
))}
</motion.div>
))}
</div>
</div>
</RendererWrapper>
);
}

View File

@@ -0,0 +1,35 @@
import type { CanvasComponent } from '../../types/canvas';
import { RendererWrapper, type ComponentRenderContext } from './RendererWrapper';
interface Props {
component: CanvasComponent;
ctx: ComponentRenderContext;
}
export function TextCanvasRenderer({ component, ctx }: Props) {
const params = component.visualizationParameters as {
content?: string;
};
const content = String(params.content ?? '').trim();
const paragraphs = content
.split(/\n{2,}/)
.map((block) => block.trim())
.filter(Boolean);
return (
<RendererWrapper component={component} ctx={ctx} minHeight={180}>
<div className="flex h-full flex-col gap-3 text-sm leading-7 text-zinc-200">
{paragraphs.length ? (
paragraphs.map((paragraph, index) => (
<p key={`${component.componentId}-${index}`} className="whitespace-pre-wrap text-zinc-300">
{paragraph}
</p>
))
) : (
<p className="text-zinc-500">No planning notes were generated for this prompt.</p>
)}
</div>
</RendererWrapper>
);
}

View File

@@ -0,0 +1,75 @@
import { motion } from 'framer-motion';
import type { CanvasComponent } from '../../types/canvas';
import { RendererWrapper, type ComponentRenderContext } from './RendererWrapper';
import { CalendarClock, Mail, Phone, Users, MessageCircle } from 'lucide-react';
interface Props { component: CanvasComponent; ctx: ComponentRenderContext }
const ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
email: Mail,
call: Phone,
meeting: Users,
whatsapp: MessageCircle,
appointment: CalendarClock,
};
export function TimelineRenderer({ component, ctx }: Props) {
const rows = (component.dataRows ?? []) as Array<Record<string, unknown>>;
return (
<RendererWrapper component={component} ctx={ctx} minHeight={300}>
<div className="relative space-y-4 pl-6">
{/* Timeline spine */}
<div className="absolute left-[18px] top-2 bottom-4 w-[2px] bg-gradient-to-b from-blue-500 via-cyan-400/40 to-transparent shadow-[0_0_8px_#3b82f6]" />
{rows.map((row, i) => {
const typeKey = String(row.type ?? '').toLowerCase();
const Icon = ICONS[typeKey] ?? CalendarClock;
return (
<motion.div
key={i}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: i * 0.09 }}
className="relative pl-5 group cursor-pointer"
>
{/* Timeline dot */}
<div className="absolute -left-[3px] top-1.5 h-3.5 w-3.5 rounded-full bg-zinc-950 border-2 border-blue-400 shadow-[0_0_12px_#3b82f6] z-10" />
<div
className="p-4 rounded-xl relative overflow-hidden"
style={{ background: 'rgba(255,255,255,0.025)', border: '1px solid rgba(255,255,255,0.07)' }}
>
{/* Hover shimmer */}
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/[0.04] to-transparent -translate-x-full group-hover:translate-x-full transition-transform duration-700" />
<div className="flex items-start justify-between gap-3 relative z-10">
<div>
<div className="flex items-center gap-2 mb-1.5">
<Icon className="w-3.5 h-3.5 text-blue-400" />
<span className="text-[10px] font-bold uppercase tracking-widest text-blue-300">
{String(row.type ?? row.title ?? '')}
</span>
</div>
<p className="text-sm text-zinc-200 font-medium leading-tight">
{String(row.summary ?? row.title ?? '')}
</p>
{row.actor !== undefined && (
<p className="text-xs text-zinc-500 mt-1">{String(row.actor)}</p>
)}
</div>
<span className="text-[11px] font-mono text-zinc-500 whitespace-nowrap flex-shrink-0 pt-0.5">
{String(row.date ?? row.when ?? '')}
</span>
</div>
</div>
</motion.div>
);
})}
{rows.length === 0 && (
<p className="text-sm text-zinc-600 pl-2">No timeline events found.</p>
)}
</div>
</RendererWrapper>
);
}

View File

@@ -0,0 +1,386 @@
/**
* MergeReviewDrawer — Full merge request review interface.
* Shows: diff summary, conflict cards per conflict class, resolution controls.
* Supports approve / reject / request-changes decisions.
*/
import { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { X, GitMerge, Plus, Edit2, ArrowUpDown, Trash2, AlertTriangle, CheckCircle2, ChevronDown } from 'lucide-react';
import { Button } from '@/components/ui/button';
import type { MergeRequest, ConflictRecord } from '../../types/canvas';
interface MergeReviewDrawerProps {
mergeRequest: MergeRequest | null;
isOpen: boolean;
onClose: () => void;
onReview: (decision: 'approve' | 'reject' | 'changes_requested', comment?: string) => Promise<void>;
}
const CONFLICT_CLASS_CONFIG: Record<string, {
icon: typeof AlertTriangle;
label: string;
color: string;
bg: string;
description: string;
severity: 'none' | 'low' | 'medium' | 'high';
}> = {
safe_append: {
icon: Plus, label: 'Safe Append', color: '#34d399', bg: 'rgba(52,211,153,0.1)',
description: 'New component added in source, not present in target. Will be appended.',
severity: 'none',
},
safe_reorder: {
icon: ArrowUpDown, label: 'Safe Reorder', color: '#60a5fa', bg: 'rgba(96,165,250,0.1)',
description: 'Component order differs between branches. Will be merged using longest-common-subsequence.',
severity: 'none',
},
component_content_conflict: {
icon: Edit2, label: 'Content Conflict', color: '#fbbf24', bg: 'rgba(251,191,36,0.1)',
description: 'Both branches edited the same component content. Manual resolution required.',
severity: 'high',
},
query_descriptor_conflict: {
icon: AlertTriangle, label: 'Query Conflict', color: '#f87171', bg: 'rgba(248,113,113,0.1)',
description: 'Data source or filter parameters conflict. Requires reviewer decision.',
severity: 'high',
},
layout_slot_conflict: {
icon: ArrowUpDown, label: 'Layout Conflict', color: '#fbbf24', bg: 'rgba(251,191,36,0.1)',
description: 'Same layout slot claimed by different components.',
severity: 'medium',
},
access_policy_conflict: {
icon: AlertTriangle, label: 'Policy Conflict', color: '#f87171', bg: 'rgba(248,113,113,0.1)',
description: 'Access control policies diverge. Stricter policy will prevail.',
severity: 'high',
},
delete_edit_conflict: {
icon: Trash2, label: 'Delete-Edit Conflict', color: '#f87171', bg: 'rgba(248,113,113,0.1)',
description: 'Component deleted in one branch, edited in another.',
severity: 'high',
},
};
function ConflictCard({ conflict, resolution, onResolve }: {
conflict: ConflictRecord;
resolution?: string;
onResolve: (id: string, decision: string) => void;
}) {
const [expanded, setExpanded] = useState(false);
const cfg = CONFLICT_CLASS_CONFIG[conflict.conflictClass] ?? CONFLICT_CLASS_CONFIG.component_content_conflict;
const Icon = cfg.icon;
const isResolved = !!resolution;
return (
<div
className="rounded-xl overflow-hidden"
style={{ background: cfg.bg, border: `1px solid ${cfg.color}30` }}
>
<button
className="w-full flex items-center gap-3 p-3.5 text-left"
onClick={() => setExpanded((p) => !p)}
>
<Icon className="w-4 h-4 flex-shrink-0" style={{ color: cfg.color }} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold" style={{ color: cfg.color }}>{cfg.label}</span>
{isResolved && (
<CheckCircle2 className="w-3 h-3 text-green-400" />
)}
</div>
<p className="text-[10px] text-zinc-500 truncate font-mono">
{conflict.componentId}{conflict.field ? `${conflict.field}` : ''}
</p>
</div>
<ChevronDown
className="w-3.5 h-3.5 text-zinc-600 flex-shrink-0 transition-transform"
style={{ transform: expanded ? 'rotate(180deg)' : 'none' }}
/>
</button>
<AnimatePresence>
{expanded && (
<motion.div
initial={{ height: 0 }}
animate={{ height: 'auto' }}
exit={{ height: 0 }}
className="overflow-hidden border-t"
style={{ borderColor: `${cfg.color}25` }}
>
<div className="p-3.5 space-y-3">
<p className="text-xs text-zinc-300">{cfg.description}</p>
{conflict.description && (
<p className="text-xs text-zinc-400 italic">"{conflict.description}"</p>
)}
{/* Show values if present */}
{(conflict.sourceValue !== undefined || conflict.targetValue !== undefined) && (
<div className="grid grid-cols-2 gap-2">
{conflict.sourceValue !== undefined && (
<div className="p-2 rounded-lg" style={{ background: 'rgba(255,255,255,0.04)' }}>
<p className="text-[10px] text-zinc-500 mb-1">Source</p>
<p className="text-xs text-zinc-300 font-mono break-all">
{JSON.stringify(conflict.sourceValue).slice(0, 80)}
</p>
</div>
)}
{conflict.targetValue !== undefined && (
<div className="p-2 rounded-lg" style={{ background: 'rgba(255,255,255,0.04)' }}>
<p className="text-[10px] text-zinc-500 mb-1">Target</p>
<p className="text-xs text-zinc-300 font-mono break-all">
{JSON.stringify(conflict.targetValue).slice(0, 80)}
</p>
</div>
)}
</div>
)}
{/* Resolution buttons */}
{cfg.severity !== 'none' && !isResolved && (
<div className="flex gap-2">
<button
className="text-[10px] px-2.5 py-1.5 rounded-lg transition-all flex-1"
style={{ background: 'rgba(59,130,246,0.15)', color: '#60a5fa', border: '1px solid rgba(59,130,246,0.3)' }}
onClick={() => onResolve(conflict.conflictId, 'source_wins')}
>
Use Source
</button>
<button
className="text-[10px] px-2.5 py-1.5 rounded-lg transition-all flex-1"
style={{ background: 'rgba(255,255,255,0.06)', color: '#a1a1aa', border: '1px solid rgba(255,255,255,0.1)' }}
onClick={() => onResolve(conflict.conflictId, 'target_wins')}
>
Keep Target
</button>
</div>
)}
{isResolved && (
<div className="flex items-center gap-2">
<CheckCircle2 className="w-3.5 h-3.5 text-green-400" />
<span className="text-xs text-green-300">Resolved: {resolution}</span>
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
export function MergeReviewDrawer({ mergeRequest, isOpen, onClose, onReview }: MergeReviewDrawerProps) {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const [comment, setComment] = useState('');
const [submitting, setSubmitting] = useState(false);
const [resolutions, setResolutions] = useState<Record<string, string>>({});
const [success, setSuccess] = useState<string | null>(null);
const conflicts: ConflictRecord[] = mergeRequest?.conflicts ?? [];
const diff = mergeRequest?.diffSummary;
const highSeverityConflicts = conflicts.filter(
(c) => (CONFLICT_CLASS_CONFIG[c.conflictClass]?.severity ?? 'none') === 'high'
);
const unresolvedHigh = highSeverityConflicts.filter((c) => !resolutions[c.conflictId]);
const canApprove = unresolvedHigh.length === 0;
const handleReview = async (decision: 'approve' | 'reject' | 'changes_requested') => {
setSubmitting(true);
try {
await onReview(decision, comment);
setSuccess(decision);
setTimeout(() => {
setSuccess(null);
onClose();
}, 2000);
} finally {
setSubmitting(false);
}
};
const content = (
<AnimatePresence>
{isOpen && (
<>
<motion.div
key="mr-backdrop"
className="fixed inset-0 z-40"
style={{ background: 'rgba(0,0,0,0.65)', backdropFilter: 'blur(4px)' }}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
/>
<motion.div
key="mr-drawer"
className="fixed z-50 right-0 top-0 h-full"
style={{ width: 480 }}
initial={{ x: 480 }}
animate={{ x: 0 }}
exit={{ x: 480 }}
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
>
<div
className="h-full flex flex-col"
style={{
background: 'rgba(10, 11, 18, 0.99)',
borderLeft: '1px solid rgba(255,255,255,0.1)',
}}
>
{/* Header */}
<div
className="flex items-center justify-between px-5 py-4 flex-shrink-0"
style={{ borderBottom: '1px solid rgba(255,255,255,0.08)' }}
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-xl bg-violet-500/15 border border-violet-500/25 flex items-center justify-center">
<GitMerge className="w-4 h-4 text-violet-400" />
</div>
<div>
<h2 className="text-sm font-semibold text-zinc-100">
{mergeRequest?.title ?? 'Merge Request'}
</h2>
<p className="text-xs text-zinc-500">
{mergeRequest?.sourceBranchId ?? 'fork'} {mergeRequest?.targetBranchId ?? 'main'}
</p>
</div>
</div>
<button onClick={onClose} className="text-zinc-600 hover:text-zinc-300 transition-colors">
<X className="w-4 h-4" />
</button>
</div>
{success ? (
<div className="flex-1 flex flex-col items-center justify-center gap-4">
<CheckCircle2 className="w-12 h-12 text-green-400" />
<p className="text-base font-medium text-zinc-100 capitalize">
{success === 'approve' ? 'Merge approved!' : success === 'reject' ? 'Review rejected' : 'Changes requested'}
</p>
</div>
) : (
<>
<div className="flex-1 overflow-y-auto custom-scrollbar px-5 py-4 space-y-5">
{/* Diff summary */}
{diff && (
<div>
<h3 className="text-xs font-semibold text-zinc-400 uppercase tracking-wide mb-3">Changes</h3>
<div className="grid grid-cols-4 gap-2">
{[
{ label: 'Added', val: diff.componentsAdded, color: '#34d399', icon: Plus },
{ label: 'Edited', val: diff.componentsEdited, color: '#60a5fa', icon: Edit2 },
{ label: 'Reordered', val: diff.componentsReordered, color: '#a78bfa', icon: ArrowUpDown },
{ label: 'Deleted', val: diff.componentsDeleted, color: '#f87171', icon: Trash2 },
].map(({ label, val, color, icon: Icon }) => (
<div
key={label}
className="p-2.5 rounded-xl text-center"
style={{ background: `${color}10`, border: `1px solid ${color}25` }}
>
<Icon className="w-3.5 h-3.5 mx-auto mb-1" style={{ color }} />
<p className="text-base font-bold" style={{ color }}>{val}</p>
<p className="text-[10px] text-zinc-600 uppercase tracking-wider">{label}</p>
</div>
))}
</div>
</div>
)}
{/* Conflicts */}
{conflicts.length > 0 && (
<div>
<div className="flex items-center justify-between mb-3">
<h3 className="text-xs font-semibold text-zinc-400 uppercase tracking-wide">
Conflicts ({conflicts.length})
</h3>
{unresolvedHigh.length > 0 && (
<span className="text-[10px] text-amber-400">
{unresolvedHigh.length} require resolution
</span>
)}
</div>
<div className="space-y-2">
{conflicts.map((c) => (
<ConflictCard
key={c.conflictId}
conflict={c}
resolution={resolutions[c.conflictId]}
onResolve={(id, decision) =>
setResolutions((p) => ({ ...p, [id]: decision }))
}
/>
))}
</div>
</div>
)}
{conflicts.length === 0 && (
<div
className="flex items-center gap-3 p-4 rounded-xl"
style={{ background: 'rgba(52,211,153,0.07)', border: '1px solid rgba(52,211,153,0.2)' }}
>
<CheckCircle2 className="w-5 h-5 text-green-400 flex-shrink-0" />
<p className="text-sm text-green-300">No conflicts clean merge</p>
</div>
)}
{/* Reviewer comment */}
<div>
<label className="text-xs font-medium text-zinc-400 mb-1.5 block">Reviewer comment</label>
<textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="Optional note for the author…"
rows={3}
className="w-full px-3 py-2.5 text-sm rounded-xl resize-none focus:outline-none"
style={{
background: 'rgba(255,255,255,0.04)',
border: '1px solid rgba(255,255,255,0.1)',
color: '#e4e4e7',
}}
/>
</div>
</div>
{/* Decision buttons */}
<div
className="flex gap-2 px-5 py-4 flex-shrink-0"
style={{ borderTop: '1px solid rgba(255,255,255,0.07)' }}
>
<Button
variant="outline"
onClick={() => void handleReview('reject')}
disabled={submitting}
className="flex-1 border-red-500/30 bg-red-500/10 text-red-400 hover:bg-red-500/20"
>
Reject
</Button>
<Button
variant="outline"
onClick={() => void handleReview('changes_requested')}
disabled={submitting}
className="flex-1 border-amber-500/30 bg-amber-500/10 text-amber-400 hover:bg-amber-500/20"
>
Request Changes
</Button>
<Button
onClick={() => void handleReview('approve')}
disabled={!canApprove || submitting}
className="flex-1 bg-green-700 hover:bg-green-600 text-white disabled:opacity-40"
>
{canApprove ? 'Approve & Merge' : `Resolve ${unresolvedHigh.length} conflicts`}
</Button>
</div>
</>
)}
</div>
</motion.div>
</>
)}
</AnimatePresence>
);
if (!mounted) return null;
return createPortal(content, document.body);
}

View File

@@ -0,0 +1,138 @@
/**
* useOracleExecution — manages prompt submission and durable execution history.
*/
import { useState, useCallback, useRef } from 'react';
import type { PromptExecution, CanvasComponent, PlacementMode } from '../types/canvas';
import { submitPrompt } from '../lib/oracleApiClient';
export interface ExecutionEntry {
execution: PromptExecution;
componentsCreated: string[];
}
export interface OracleExecutionState {
history: ExecutionEntry[];
inFlight: PromptExecution | null;
lastError: string | null;
submit: (params: {
pageId: string;
branchId: string;
prompt: string;
tenantId: string;
actorId: string;
placementMode?: PlacementMode;
conversationContext?: Array<{ role: 'user' | 'assistant'; content: string }>;
onExecutionCommitted?: (commit: {
headRevision: number;
components: CanvasComponent[];
execution: PromptExecution;
}) => void;
}) => Promise<void>;
clearError: () => void;
resetHistory: () => void;
}
export function useOracleExecution(): OracleExecutionState {
const [history, setHistory] = useState<ExecutionEntry[]>([]);
const [inFlight, setInFlight] = useState<PromptExecution | null>(null);
const [lastError, setLastError] = useState<string | null>(null);
const requestIdRef = useRef(0);
const submit = useCallback(
async ({
pageId,
branchId,
prompt,
tenantId,
actorId,
placementMode = 'append_after_last_visible_component',
conversationContext = [],
onExecutionCommitted,
}: {
pageId: string;
branchId: string;
prompt: string;
tenantId: string;
actorId: string;
placementMode?: PlacementMode;
conversationContext?: Array<{ role: 'user' | 'assistant'; content: string }>;
onExecutionCommitted?: (commit: {
headRevision: number;
components: CanvasComponent[];
execution: PromptExecution;
}) => void;
}) => {
const clientRequestId = `cli_${Date.now()}_${++requestIdRef.current}`;
const now = new Date().toISOString();
const optimistic: PromptExecution = {
executionId: `pex_${clientRequestId}`,
tenantId,
pageId,
branchId,
actorId,
prompt,
intentClass: 'analytical',
status: 'planning',
modelRuntime: 'oracle_runtime',
semanticModelVersion: 'oracle_semantic_v2026_04_08_01',
warnings: [],
createdAt: now,
};
setInFlight(optimistic);
setLastError(null);
try {
setInFlight((prev) => (prev ? { ...prev, status: 'executing' } : prev));
const response = await submitPrompt(pageId, {
clientRequestId,
branchId,
prompt,
conversationContext,
placementMode,
});
const completed: PromptExecution = {
...optimistic,
executionId: response.executionId,
status: response.status,
summary: response.summary,
warnings: response.warnings,
componentsCreated: response.componentsCreated,
completedAt: new Date().toISOString(),
};
onExecutionCommitted?.({
headRevision: response.headRevision,
components: response.components,
execution: completed,
});
setHistory((prev) => [...prev, { execution: completed, componentsCreated: response.componentsCreated }]);
setInFlight(null);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Prompt execution failed';
const failed: PromptExecution = {
...optimistic,
status: 'failed',
warnings: [msg],
completedAt: new Date().toISOString(),
};
setHistory((prev) => [...prev, { execution: failed, componentsCreated: [] }]);
setInFlight(null);
setLastError(msg);
}
},
[],
);
return {
history,
inFlight,
lastError,
submit,
clearError: () => setLastError(null),
resetHistory: () => setHistory([]),
};
}

View File

@@ -0,0 +1,120 @@
/**
* useOraclePage — Page hydration, branch state, component projection
* Owns the canonical page state including revision tracking and optimistic updates.
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import type { CanvasPage, CanvasComponent, OracleWSMessage } from '../types/canvas';
import { fetchCanvasPage, connectPageSocket } from '../lib/oracleApiClient';
export interface OraclePageState {
page: CanvasPage | null;
isLoading: boolean;
error: string | null;
isConnected: boolean;
// Actions
refresh: () => Promise<void>;
optimisticallyAppendComponent: (comp: CanvasComponent) => void;
applyRevision: (headRevision: number, components: CanvasComponent[]) => void;
}
export function useOraclePage(pageId: string | null): OraclePageState {
const [page, setPage] = useState<CanvasPage | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isConnected, setIsConnected] = useState(false);
const disconnectRef = useRef<(() => void) | null>(null);
const load = useCallback(async () => {
if (!pageId) {
setPage(null);
setIsLoading(false);
return;
}
setIsLoading(true);
setError(null);
try {
const data = await fetchCanvasPage(pageId);
setPage(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load page');
} finally {
setIsLoading(false);
}
}, [pageId]);
// Connect WebSocket
useEffect(() => {
if (!pageId) {
setIsConnected(false);
return () => undefined;
}
const disconnect = connectPageSocket(pageId, {
onMessage: (msg: OracleWSMessage) => handleWSMessage(msg),
onReconnect: () => undefined,
onOpen: () => setIsConnected(true),
onClose: () => setIsConnected(false),
});
disconnectRef.current = disconnect;
return () => {
disconnect();
disconnectRef.current = null;
};
}, [pageId, load]); // eslint-disable-line react-hooks/exhaustive-deps
// Handle WS messages
function handleWSMessage(msg: OracleWSMessage) {
if (msg.type === 'oracle.page.revision.committed') {
const { headRevision, components } = msg.payload as {
headRevision: number;
components: CanvasComponent[];
};
applyRevision(headRevision, components);
} else if (msg.type === 'oracle.presence.updated') {
setPage((prev) =>
prev
? {
...prev,
presence: {
activeViewers: (msg.payload.activeViewers as number) ?? prev.presence.activeViewers,
activeEditors: (msg.payload.activeEditors as number) ?? prev.presence.activeEditors,
lastPresenceAt: msg.timestamp,
},
}
: prev,
);
}
}
const optimisticallyAppendComponent = useCallback((comp: CanvasComponent) => {
setPage((prev) => {
if (!prev) return prev;
// Prevent duplicate (idempotent insert)
if (prev.components.some((c) => c.componentId === comp.componentId)) return prev;
return { ...prev, components: [...prev.components, comp] };
});
}, []);
const applyRevision = useCallback(
(headRevision: number, components: CanvasComponent[]) => {
setPage((prev) =>
prev ? { ...prev, headRevision, components } : prev,
);
},
[],
);
// Initial load
useEffect(() => {
void load();
}, [load]);
return {
page,
isLoading,
error,
isConnected,
refresh: load,
optimisticallyAppendComponent,
applyRevision,
};
}

View File

@@ -0,0 +1,248 @@
/**
* Oracle API Client — production-only client for the Oracle v1 backend.
*/
import type {
CanvasPage,
PromptSubmitRequest,
PromptSubmitResponse,
ForkCreateRequest,
ForkCreateResponse,
MergeRequestCreateRequest,
MergeRequest,
MergeReviewRequest,
ComponentTemplate,
UserProfile,
OracleWSMessage,
OracleEnvelope,
CanvasPageRevision,
} from '../types/canvas';
import { VELOCITY_TOKEN_KEY } from '@/lib/velocitySession';
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}`;
}
async function apiFetch<T>(
path: string,
options?: RequestInit & { idempotencyKey?: string },
): Promise<T> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'X-Oracle-Contract-Version': 'v1',
...(options?.idempotencyKey ? { 'Idempotency-Key': options.idempotencyKey } : {}),
};
const token = localStorage.getItem('oracle_jwt') ?? localStorage.getItem(VELOCITY_TOKEN_KEY);
if (token) headers.Authorization = `Bearer ${token}`;
const res = await fetch(apiUrl(path), { ...options, headers });
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw Object.assign(new Error(body?.error?.message ?? body?.detail?.errors?.join?.(', ') ?? `HTTP ${res.status}`), { apiError: body });
}
const body = await res.json() as OracleEnvelope<T> | T;
if (typeof body === 'object' && body !== null && 'data' in body) {
return (body as OracleEnvelope<T>).data;
}
return body as T;
}
export async function fetchMe(): Promise<UserProfile> {
return apiFetch<UserProfile>('/me');
}
export async function fetchCanvasPage(pageId: string): Promise<CanvasPage> {
return apiFetch<CanvasPage>(`/canvas-pages/${pageId}`);
}
export async function listCanvasPages(search?: string): Promise<CanvasPage[]> {
const qs = new URLSearchParams();
if (search?.trim()) qs.set('search', search.trim());
return apiFetch<CanvasPage[]>(`/canvas-pages${qs.toString() ? `?${qs.toString()}` : ''}`);
}
export async function createCanvasPage(title = 'Untitled Canvas'): Promise<CanvasPage> {
return apiFetch<CanvasPage>('/canvas-pages', {
method: 'POST',
body: JSON.stringify({ title }),
});
}
export async function renameCanvasPage(pageId: string, title: string): Promise<CanvasPage> {
return apiFetch<CanvasPage>(`/canvas-pages/${pageId}`, {
method: 'PATCH',
body: JSON.stringify({ title }),
});
}
export async function deleteCanvasPage(pageId: string): Promise<{ pageId: string; deleted: boolean }> {
return apiFetch<{ pageId: string; deleted: boolean }>(`/canvas-pages/${pageId}`, {
method: 'DELETE',
});
}
export async function submitPrompt(
pageId: string,
payload: PromptSubmitRequest,
): Promise<PromptSubmitResponse> {
return apiFetch<PromptSubmitResponse>(`/canvas-pages/${pageId}/prompts`, {
method: 'POST',
body: JSON.stringify(payload),
idempotencyKey: payload.clientRequestId,
});
}
export async function rollbackPage(
pageId: string,
targetRevision: number,
clientRequestId: string,
): Promise<{ headRevision: number; pageId: string; components: CanvasPage['components'] }> {
return apiFetch<{ headRevision: number; pageId: string; components: CanvasPage['components'] }>(
`/canvas-pages/${pageId}/rollback`,
{
method: 'POST',
body: JSON.stringify({ targetRevision, clientRequestId }),
idempotencyKey: clientRequestId,
},
);
}
export async function listRevisions(pageId: string): Promise<CanvasPageRevision[]> {
return apiFetch<CanvasPageRevision[]>(`/canvas-pages/${pageId}/revisions`);
}
export async function createFork(
pageId: string,
payload: ForkCreateRequest,
): Promise<ForkCreateResponse> {
return apiFetch<ForkCreateResponse>(`/canvas-pages/${pageId}/forks`, {
method: 'POST',
body: JSON.stringify(payload),
});
}
export async function openMergeRequest(
payload: MergeRequestCreateRequest,
): Promise<MergeRequest> {
return apiFetch<MergeRequest>('/merge-requests', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export async function reviewMergeRequest(
mrId: string,
payload: MergeReviewRequest,
): Promise<MergeRequest> {
return apiFetch<MergeRequest>(`/merge-requests/${mrId}/review`, {
method: 'POST',
body: JSON.stringify(payload),
});
}
export async function listMergeRequests(pageId: string): Promise<MergeRequest[]> {
return apiFetch<MergeRequest[]>(`/merge-requests?targetPageId=${pageId}`);
}
export async function listComponentTemplates(filters?: {
category?: string;
status?: string;
}): Promise<ComponentTemplate[]> {
const qs = new URLSearchParams(filters as Record<string, string>).toString();
return apiFetch<ComponentTemplate[]>(`/component-templates${qs ? `?${qs}` : ''}`);
}
export async function synthesizeTemplate(params: {
prompt: string;
dataShape: string[];
styleSignatureRef?: string;
}): Promise<ComponentTemplate> {
return apiFetch<ComponentTemplate>('/component-templates/synthesize', {
method: 'POST',
body: JSON.stringify(params),
});
}
export function connectPageSocket(
pageId: string,
handlers: {
onMessage: (msg: OracleWSMessage) => void;
onReconnect?: () => void;
onOpen?: () => void;
onClose: () => void;
},
): () => void {
if (!WS_URL && !BASE_URL) {
handlers.onClose();
return () => undefined;
}
const wsBase = WS_URL || BASE_URL.replace(/^http/, 'ws');
let ws: WebSocket;
let stopped = false;
let retryTimeout: ReturnType<typeof setTimeout> | undefined;
function connect() {
ws = new WebSocket(`${wsBase}/ws/oracle/canvas/${pageId}`);
ws.onopen = () => {
handlers.onOpen?.();
};
ws.onmessage = (event) => {
try {
handlers.onMessage(JSON.parse(event.data as string) as OracleWSMessage);
} catch {
// Ignore malformed messages from the transport.
}
};
ws.onclose = () => {
handlers.onClose();
if (!stopped) {
retryTimeout = setTimeout(() => {
handlers.onReconnect?.();
connect();
}, 3000);
}
};
ws.onerror = () => {
ws.close();
};
}
connect();
return () => {
stopped = true;
if (retryTimeout) clearTimeout(retryTimeout);
ws?.close();
};
}

View File

@@ -0,0 +1,489 @@
/**
* Oracle Canvas — Canonical TypeScript Contracts
* Mirrors the JSON Schema from Section 6.2 of the Oracle Architecture Document v1.0
* These types replace the temporary OracleQueryResult contract.
*/
// ── Enums ─────────────────────────────────────────────────────────────────────
export type OracleRole =
| 'junior_broker'
| 'senior_broker'
| 'sales_director'
| 'marketing_operator'
| 'data_steward'
| 'compliance_reviewer'
| 'platform_admin';
export type ComponentType =
| 'textCanvas'
| 'kpiTile'
| 'barChart'
| 'lineChart'
| 'scatterPlot'
| 'geoMap'
| 'table'
| 'pipelineBoard'
| 'timeline'
| 'heatmap'
| 'forecastChart'
| 'activityStream'
| 'customMLVisualization'
| 'errorNotice';
export type ComponentLifecycleState = 'draft' | 'active' | 'superseded' | 'archived' | 'revoked';
export type PrivacyTier = 'standard' | 'restricted' | 'sensitive';
export type SourceType = 'postgres' | 'warehouse' | 'api' | 'materialized_view' | 'derived_dataset' | 'inline';
export type CachePolicyMode = 'none' | 'ttl' | 'revision_scoped';
export type IntentClass = 'analytical' | 'operational' | 'mixed';
export type ExecutionStatus =
| 'received'
| 'planning'
| 'validated'
| 'executing'
| 'completed'
| 'failed'
| 'clarification_required';
export type PageType = 'main' | 'fork';
export type ForkStatus = 'active' | 'merged' | 'closed';
export type MergeRequestStatus = 'open' | 'changes_requested' | 'approved' | 'merged' | 'closed';
export type TemplateStatus = 'catalog_active' | 'tenant_draft' | 'tenant_active' | 'archived' | 'revoked';
export type TemplateOrigin = 'premade' | 'synthesized' | 'cloned';
export type ShareMode = 'private' | 'direct_fork_only';
export type WidthMode = 'full' | 'half' | 'third';
export type VisibilityScope = 'private' | 'shared_fork' | 'tenant_team';
export type ComponentOriginType = 'catalog' | 'prompt_generated' | 'cloned' | 'merged' | 'edited';
export type PlacementMode =
| 'append_after_last_visible_component'
| 'insert_after_component'
| 'replace_component'
| 'group_under_section';
export type ActorType = 'user' | 'service' | 'ai';
export type LineageSourceKind =
| 'table'
| 'view'
| 'materialization'
| 'prompt'
| 'component'
| 'template'
| 'merge_request';
export type ValidationStatus = 'validated' | 'rejected' | 'needs_review';
export type CommitKind = 'prompt' | 'merge' | 'rollback' | 'manual_edit';
// ── Sub-objects ───────────────────────────────────────────────────────────────
export interface CachePolicy {
mode: CachePolicyMode;
ttlSeconds?: number;
}
export interface DataSourceDescriptor {
descriptorId: string;
sourceType: SourceType;
connectorId: string;
dataset: string;
authContextRef: string;
queryTemplate: string;
queryParameters: Record<string, unknown>;
rowLimit: number;
freshnessSlaSeconds?: number;
cachePolicy?: CachePolicy;
privacyTier: PrivacyTier;
lineageRefs?: string[];
}
export interface DataBindings {
dimensions: string[];
measures: string[];
series: string[];
filters: Array<{
field: string;
operator: string;
value: unknown;
}>;
}
export interface ComponentProvenance {
originType: ComponentOriginType;
templateId?: string;
promptExecutionId?: string;
sourceComponentId?: string;
sourceBranchId?: string;
mergeRequestId?: string;
createdBy: string;
createdAt: string;
}
export interface RenderingHints {
estimatedHeightPx: number;
skeletonVariant: 'chart' | 'map' | 'table' | 'kpi' | 'pipeline' | 'timeline' | 'generic';
virtualizationPriority: number;
}
export interface ComponentLayout {
orderIndex: number;
sectionId: string;
widthMode: WidthMode;
minHeightPx: number;
stickyHeader: boolean;
}
export interface AccessControls {
visibilityScope: VisibilityScope;
allowedRoles: OracleRole[];
redactionPolicy: string;
}
export interface StyleSignature {
theme: string;
paletteToken: string;
motionProfile: string;
density: 'compact' | 'comfortable';
radiusScale: string;
typographyScale: string;
}
export interface ValidationState {
schema: 'pass' | 'fail';
policy: 'pass' | 'fail';
a11y: 'pass' | 'fail';
performance: 'pass' | 'fail';
status: ValidationStatus;
}
// ── Core Entities ─────────────────────────────────────────────────────────────
export interface CanvasComponent {
componentId: string;
type: ComponentType;
title: string;
description?: string;
dataSourceDescriptor: DataSourceDescriptor;
visualizationParameters: Record<string, unknown>;
dataBindings: DataBindings;
version: number;
lifecycleState?: ComponentLifecycleState;
provenance: ComponentProvenance;
renderingHints: RenderingHints;
layout: ComponentLayout;
accessControls: AccessControls;
styleSignature: StyleSignature;
validationState: ValidationState;
auditLog: string[];
// Runtime-only: actual data rows fetched for this component
dataRows?: Record<string, unknown>[];
}
export interface ForkRecord {
forkId: string;
sourcePageId: string;
sourceBranchId: string;
sourceRevision: number;
forkPageId: string;
forkBranchId: string;
recipientUserId: string;
createdBy: string;
createdAt: string;
status: ForkStatus;
}
export interface LineageRecord {
lineageRecordId: string;
tenantId: string;
sourceKind: LineageSourceKind;
sourceId: string;
transformationType: string;
transformationSpecHash?: string;
producedKind: string;
producedId: string;
policySnapshotId?: string;
createdAt: string;
}
export interface SharingPolicy {
shareMode: ShareMode;
allowReshare: boolean;
defaultForkVisibility: 'private' | 'team';
}
export interface PagePresence {
activeViewers: number;
activeEditors: number;
lastPresenceAt: string;
}
export interface PageAuditSummary {
lastAuditEventId: string;
eventCount: number;
}
export interface CanvasPage {
pageId: string;
tenantId: string;
ownerId: string;
branchId: string;
branchName: string;
pageType: PageType;
title: string;
createdAt: string;
updatedAt: string;
isShared: boolean;
forks: ForkRecord[];
mainBranchPointer: {
pageId: string;
branchId: string;
revision: number;
};
baseRevision: number;
headRevision: number;
sharingPolicy: SharingPolicy;
presence: PagePresence;
lineage: LineageRecord[];
audit: PageAuditSummary;
components: CanvasComponent[];
}
export interface PromptExecution {
executionId: string;
tenantId: string;
pageId: string;
branchId: string;
actorId: string;
prompt: string;
intentClass: IntentClass;
status: ExecutionStatus;
modelRuntime: string;
semanticModelVersion: string;
retrievalPlan?: Record<string, unknown>;
visualizationPlan?: Record<string, unknown>;
warnings: string[];
summary?: string;
componentsCreated?: string[];
createdAt: string;
completedAt?: string;
}
export interface ComponentTemplate {
templateId: string;
tenantId: string;
name: string;
category: string;
status: TemplateStatus;
origin: TemplateOrigin;
version: string;
acceptedShapes: string[];
styleSignature?: StyleSignature;
validationState?: ValidationState;
provenance?: ComponentProvenance;
createdAt: string;
updatedAt: string;
}
export interface ConflictRecord {
conflictId: string;
conflictClass:
| 'component_content_conflict'
| 'query_descriptor_conflict'
| 'layout_slot_conflict'
| 'access_policy_conflict'
| 'delete_edit_conflict'
| 'safe_append'
| 'safe_reorder';
componentId: string;
field?: string;
sourceValue?: unknown;
targetValue?: unknown;
description: string;
}
export interface DiffSummary {
componentsAdded: number;
componentsEdited: number;
componentsReordered: number;
componentsDeleted: number;
}
export interface MergeRequest {
mergeRequestId: string;
tenantId: string;
sourcePageId: string;
sourceBranchId: string;
sourceHeadRevision: number;
targetPageId: string;
targetBranchId: string;
targetBaseRevision: number;
title: string;
description?: string;
status: MergeRequestStatus;
conflicts: ConflictRecord[];
diffSummary?: DiffSummary;
createdBy: string;
reviewedBy?: string;
createdAt: string;
updatedAt: string;
}
export interface AuditEvent {
auditEventId: string;
tenantId: string;
entityType: string;
entityId: string;
action: string;
actorId: string;
actorType: ActorType;
correlationId: string;
executionId?: string;
createdAt: string;
details: Record<string, unknown>;
}
export interface UserProfile {
userId: string;
tenantId: string;
email: string;
displayName: string;
role: OracleRole;
timezone: string;
locale: string;
defaultPageId: string;
canvasPreferences: {
defaultDensity: 'compact' | 'comfortable';
defaultPlacementMode: PlacementMode;
showLineageBadges: boolean;
};
policyProfileId: string;
createdAt: string;
updatedAt: string;
}
// ── API Request/Response contracts ────────────────────────────────────────────
export interface PromptSubmitRequest {
clientRequestId: string;
branchId: string;
prompt: string;
conversationContext?: Array<{ role: 'user' | 'assistant'; content: string }>;
placementMode?: PlacementMode;
}
export interface PromptSubmitResponse {
executionId: string;
status: ExecutionStatus;
pageId: string;
branchId: string;
headRevision: number;
componentsCreated: string[];
components: CanvasComponent[];
summary: string;
warnings: string[];
}
export interface CanvasPageRevision {
revisionId: string;
pageId: string;
tenantId: string;
revisionNumber: number;
commitKind: CommitKind;
commitSummary?: string;
actorId: string;
executionId?: string;
mergeRequestId?: string;
createdAt: string;
}
export interface ForkCreateRequest {
recipientUserId: string;
sourceRevision: number;
visibility: 'private' | 'team';
message?: string;
}
export interface ForkCreateResponse {
forkId: string;
forkPageId: string;
forkBranchId: string;
status: ForkStatus;
sourceRevision: number;
}
export interface MergeRequestCreateRequest {
sourcePageId: string;
sourceBranchId: string;
targetPageId: string;
targetBranchId: string;
title: string;
description?: string;
}
export interface MergeReviewRequest {
decision: 'approve' | 'reject' | 'changes_requested';
comment?: string;
resolutions?: Array<{
conflictId: string;
resolutionType: 'source_wins' | 'target_wins' | 'manual_composite';
resolvedPayloadHash?: string;
comment?: string;
}>;
}
// ── WebSocket event types ─────────────────────────────────────────────────────
export type OracleWSEventType =
| 'oracle.prompt.received'
| 'oracle.prompt.validated'
| 'oracle.prompt.failed'
| 'oracle.page.revision.committed'
| 'oracle.page.rollback.committed'
| 'oracle.fork.created'
| 'oracle.merge_request.opened'
| 'oracle.merge_request.updated'
| 'oracle.merge_request.merged'
| 'oracle.component.template.promoted'
| 'oracle.presence.updated';
export interface OracleWSMessage {
type: OracleWSEventType;
tenantId: string;
pageId?: string;
branchId?: string;
correlationId: string;
timestamp: string;
payload: Record<string, unknown>;
}
// ── API Error envelope ────────────────────────────────────────────────────────
export interface OracleAPIError {
error: {
code: string;
message: string;
retryable: boolean;
correlationId: string;
details?: Record<string, unknown>;
};
}
export interface OracleEnvelope<T> {
status: 'ok';
data: T;
meta?: Record<string, unknown>;
}

View File

@@ -0,0 +1,29 @@
/* PipelinePillar */
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
.header { display: flex; align-items: center; justify-content: space-between; padding: var(--space-6) var(--space-8) var(--space-4); flex-shrink: 0; }
.title { font-size: var(--text-2xl); font-weight: var(--font-bold); color: var(--color-text-primary); margin: 0; letter-spacing: var(--tracking-tight); }
.subtitle { font-size: var(--text-sm); color: var(--color-text-tertiary); margin: 0; }
.viewToggle { display: flex; gap: var(--space-1); background: var(--glass-bg); border: var(--glass-border); border-radius: var(--radius-lg); padding: var(--space-1); }
.toggleBtn, .toggleActive { padding: var(--space-2) var(--space-3); border-radius: var(--radius-md); font-size: var(--text-xs); font-weight: var(--font-medium); border: none; cursor: pointer; transition: all var(--duration-fast) var(--ease-standard); }
.toggleBtn { background: none; color: var(--color-text-tertiary); }
.toggleActive { background: var(--color-violet); color: white; }
/* Board */
.board { display: flex; gap: var(--space-4); padding: 0 var(--space-8) var(--space-8); overflow-x: auto; flex: 1; scrollbar-width: thin; }
.column { min-width: 220px; flex-shrink: 0; display: flex; flex-direction: column; gap: var(--space-3); }
.colHeader { display: flex; align-items: center; gap: var(--space-2); padding: var(--space-2) var(--space-3); background: var(--glass-bg); border-radius: var(--radius-md); border: var(--glass-border); }
.colLabel { font-size: var(--text-xs); font-weight: var(--font-semibold); color: var(--color-text-secondary); flex: 1; }
.colCount { font-size: var(--text-xs); color: var(--color-text-tertiary); }
.cards { display: flex; flex-direction: column; gap: var(--space-2); }
/* Card */
.card { display: flex; flex-direction: column; gap: var(--space-3); width: 100%; text-align: left; cursor: pointer; }
.cardTop { display: flex; align-items: center; gap: var(--space-3); }
.avatar { width: 32px; height: 32px; border-radius: var(--radius-full); background: var(--color-violet-glow); border: 1px solid var(--color-violet); display: flex; align-items: center; justify-content: center; font-size: var(--text-sm); font-weight: var(--font-bold); color: var(--color-violet-light); flex-shrink: 0; }
.identity { flex: 1; min-width: 0; }
.cardName { font-size: var(--text-sm); font-weight: var(--font-semibold); color: var(--color-text-primary); display: block; }
.cardMeta { font-size: 10px; color: var(--color-text-tertiary); display: block; }
.qdWrap { flex-shrink: 0; }
.cardBottom { display: flex; align-items: center; justify-content: space-between; }
.lastContact { font-size: 10px; color: var(--color-text-tertiary); }
/* Skeletons */
.skeletonHeader { height: 32px; border-radius: var(--radius-md); margin-bottom: var(--space-2); }
.skeletonCard { height: 80px; border-radius: var(--radius-lg); margin-bottom: var(--space-2); }

View File

@@ -0,0 +1,168 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { QDRing } from './client360/QDRing';
import { useKanban } from '../../shared/hooks/useKanban';
import styles from './PipelinePillar.module.css';
/**
* PipelinePillar — Pillar 2: Deal Intelligence
* Merges: CRM + Comms + Sentinel
* Default view: KanbanBoard of lead cards by pipeline stage.
* Tapping a card → /pipeline/:personId → Client360 (no full-page nav,
* depth choreography handled by AuthenticatedShell).
*/
export default function PipelinePillar() {
const [viewMode, setViewMode] = useState<'board' | 'list'>('board');
const { stages, isLoading } = useKanban();
return (
<div className={styles.root}>
{/* Header */}
<div className={styles.header}>
<div>
<h1 className={styles.title}>Pipeline</h1>
<p className={styles.subtitle}>Deal Intelligence</p>
</div>
<div className={styles.viewToggle}>
<button
className={viewMode === 'board' ? styles.toggleActive : styles.toggleBtn}
onClick={() => setViewMode('board')}
aria-pressed={viewMode === 'board'}
> Board</button>
<button
className={viewMode === 'list' ? styles.toggleActive : styles.toggleBtn}
onClick={() => setViewMode('list')}
aria-pressed={viewMode === 'list'}
> List</button>
</div>
</div>
{/* Board */}
{isLoading ? <KanbanSkeleton /> : (
<KanbanBoard stages={stages} />
)}
</div>
);
}
// ── KanbanBoard ───────────────────────────────────────────────
interface Stage {
id: string;
label: string;
emoji: string;
leads: Lead[];
}
interface Lead {
id: string;
name: string;
location?: string;
qdScore: number;
qdDelta?: number;
lastContactRelative: string;
lastContactChannel: string;
isVaultActive?: boolean; // brochure currently being reviewed
}
function KanbanBoard({ stages }: { stages: Stage[] }) {
return (
<div className={styles.board}>
{stages.map((stage, si) => (
<motion.div
key={stage.id}
className={styles.column}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: si * 0.06, duration: 0.35, ease: [0.4, 0, 0.2, 1] }}
>
{/* Column header */}
<div className={styles.colHeader}>
<span>{stage.emoji}</span>
<span className={styles.colLabel}>{stage.label}</span>
<span className={styles.colCount}>{stage.leads.length}</span>
</div>
{/* Lead cards */}
<div className={styles.cards}>
{stage.leads.map((lead, li) => (
<LeadCard key={lead.id} lead={lead} delay={si * 0.06 + li * 0.04} />
))}
</div>
</motion.div>
))}
</div>
);
}
function LeadCard({ lead, delay }: { lead: Lead; delay: number }) {
const navigate = useNavigate();
const isHighIntent = lead.qdScore >= 70;
const isVaultLive = lead.isVaultActive;
return (
<motion.button
className={`${styles.card} glass-card`}
onClick={() => navigate(`/pipeline/${lead.id}`)}
initial={{ opacity: 0, scale: 0.96 }}
animate={{
opacity: 1,
scale: 1,
// Amber glow on vault engagement
boxShadow: isVaultLive
? '0 0 16px rgba(245,158,11,0.40)'
: 'var(--glass-shadow)',
}}
transition={{ delay, duration: 0.3 }}
whileHover={{ scale: 1.02, y: -2 }}
whileTap={{ scale: 0.99 }}
aria-label={`View ${lead.name}'s profile`}
>
<div className={styles.cardTop}>
{/* Avatar initial */}
<div className={styles.avatar}>
<span>{lead.name.slice(0, 1)}</span>
</div>
{/* Identity */}
<div className={styles.identity}>
<span className={styles.cardName}>{lead.name}</span>
{lead.location && (
<span className={styles.cardMeta}>{lead.location}</span>
)}
</div>
{/* QD Ring */}
<div className={styles.qdWrap}>
<QDRing score={lead.qdScore} size={36} strokeWidth={3} />
</div>
</div>
<div className={styles.cardBottom}>
<span className={styles.lastContact}>
{lead.lastContactRelative} · {lead.lastContactChannel}
</span>
{/* Vault live indicator */}
{isVaultLive && (
<span className="badge badge-high-intent" style={{ fontSize: '0.65rem' }}>
<span className="live-dot" style={{ width: 5, height: 5 }} />
Reviewing
</span>
)}
</div>
</motion.button>
);
}
function KanbanSkeleton() {
return (
<div className={styles.board}>
{[0, 1, 2, 3].map(i => (
<div key={i} className={styles.column}>
<div className={`${styles.skeletonHeader} shimmer`} />
{[0, 1].map(j => (
<div key={j} className={`${styles.skeletonCard} shimmer`} />
))}
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,32 @@
/* ShowroomMode */
.root { display: flex; flex-direction: column; height: 100%; padding: var(--space-6); gap: var(--space-6); }
.topBar { display: flex; align-items: center; justify-content: space-between; }
.liveIndicator { display: flex; align-items: center; gap: var(--space-3); }
.liveLabel { font-size: var(--text-xs); font-weight: var(--font-semibold); letter-spacing: var(--tracking-wider); text-transform: uppercase; color: var(--color-amber); }
.timer { font-family: var(--font-mono); font-size: var(--text-sm); color: var(--color-text-secondary); }
.liveContent { display: flex; flex-direction: column; gap: var(--space-5); flex: 1; }
.twoCol { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-5); }
/* Silhouette */
.vizPanel { display: flex; align-items: center; justify-content: center; min-height: 240px; border-radius: var(--radius-xl); }
.silhouetteWrap { display: flex; flex-direction: column; align-items: center; gap: var(--space-3); }
.silhouette { width: 120px; height: 180px; opacity: 0.7; }
.zoneLabel { font-size: var(--text-xs); color: var(--color-text-tertiary); }
/* Engagement */
.engagementPanel { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--space-4); border-radius: var(--radius-xl); }
.engLabel { font-size: var(--text-xs); font-weight: var(--font-semibold); letter-spacing: var(--tracking-wider); text-transform: uppercase; color: var(--color-text-tertiary); }
.qdCentered { display: flex; align-items: center; justify-content: center; }
.engTrend { font-size: var(--text-sm); font-weight: var(--font-semibold); letter-spacing: var(--tracking-wide); }
/* Observation */
.observationCard { display: flex; flex-direction: column; gap: var(--space-3); }
.aiStar { font-size: var(--text-xs); font-weight: var(--font-semibold); letter-spacing: var(--tracking-wider); color: var(--color-violet-light); }
.observation { font-size: var(--text-base); color: var(--color-text-primary); line-height: var(--leading-relaxed); margin: 0; }
.actions { display: flex; gap: var(--space-3); flex-wrap: wrap; }
.endBtn { background: none; border: var(--glass-border); color: var(--color-red); padding: var(--space-3) var(--space-5); border-radius: var(--radius-lg); cursor: pointer; font-size: var(--text-sm); font-weight: var(--font-medium); font-family: var(--font-sans); transition: all var(--duration-fast) var(--ease-standard); }
.endBtn:hover { background: rgba(239,68,68,0.1); }
/* Summary */
.summaryContent { display: flex; align-items: center; justify-content: center; flex: 1; }
.summaryCard { padding: var(--space-10); display: flex; flex-direction: column; align-items: center; gap: var(--space-4); text-align: center; }
.summaryTitle { font-size: var(--text-2xl); font-weight: var(--font-bold); color: var(--color-text-primary); margin: 0; }
.summaryDuration { font-size: var(--text-base); color: var(--color-text-secondary); margin: 0; }
.summaryStat { font-size: var(--text-base); color: var(--color-green); margin: 0; }
.summaryActions { display: flex; gap: var(--space-3); flex-wrap: wrap; justify-content: center; margin-top: var(--space-4); }

View File

@@ -0,0 +1,212 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { QDRing } from './client360/QDRing';
import { useSentinelStore } from '../../store/sentinelStore';
import { useSentinelWebSocket } from '../../shared/hooks/useSentinelWebSocket';
import styles from './ShowroomMode.module.css';
/**
* ShowroomMode
* Full-screen contextual Sentinel view. Route: /showroom
* Activated by SentinelAlertBanner or direct broker intent.
*
* Broker sees:
* - Anonymized silhouette visualization (not raw CCTV)
* - Live QD Engagement ring + zone label + rising/falling
* - AI observation (plain English, e.g. "Visitor spent 3.2min in living room")
* - Two actions: Tag as New Lead | Match to Existing Client
* - Session timer
* - Session end → one-action summary card
*
* Broker never sees: MediaPipe processing, WebSocket status, CCTV feed URL,
* perception calibration controls, consent record IDs.
*/
type ShowroomPhase = 'live' | 'summary';
export default function ShowroomMode() {
const navigate = useNavigate();
const [phase, setPhase] = useState<ShowroomPhase>('live');
const [elapsed, setElapsed] = useState(0); // seconds
const timerRef = useRef<ReturnType<typeof setInterval>>();
const {
isShowroomActive,
setShowroomActive,
clearPendingAlert,
} = useSentinelStore();
// Live Sentinel data from WebSocket (abstracted — no raw WS state in UI)
const { session } = useSentinelWebSocket();
// Session timer
useEffect(() => {
timerRef.current = setInterval(() => setElapsed(s => s + 1), 1000);
return () => clearInterval(timerRef.current);
}, []);
// End session → summary
const handleEndSession = () => {
clearInterval(timerRef.current);
setPhase('summary');
setShowroomActive(false);
};
const handleExitShowroom = () => {
clearPendingAlert();
navigate('/pipeline');
};
const formatTime = (s: number) =>
`${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`;
return (
<motion.div
className={styles.root}
initial={{ opacity: 0, scale: 1.04 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.96 }}
transition={{ duration: 0.35, ease: [0.4, 0, 0.2, 1] }}
>
{/* ── Header bar ───────────────────────────────────── */}
<div className={styles.topBar}>
<div className={styles.liveIndicator}>
<span className="live-dot" />
<span className={styles.liveLabel}>LIVE SHOWROOM INTELLIGENCE</span>
<span className={styles.timer}>{formatTime(elapsed)}</span>
</div>
<button className="btn-ghost" onClick={handleExitShowroom}>
Exit Showroom
</button>
</div>
<AnimatePresence mode="wait">
{phase === 'live' && (
<motion.div
key="live"
className={styles.liveContent}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
{/* ── Two-column layout ─────────────────────── */}
<div className={styles.twoCol}>
{/* Left: Anonymized silhouette visualization */}
<div className={`${styles.vizPanel} glass`}>
<SilhouetteViz zone={session?.currentZone} />
</div>
{/* Right: QD Engagement */}
<div className={`${styles.engagementPanel} glass`}>
<span className={styles.engLabel}>QD ENGAGEMENT</span>
<div className={styles.qdCentered}>
<QDRing
score={session?.qdScore ?? 0}
size={96}
strokeWidth={6}
showLabel
/>
</div>
<span
className={styles.engTrend}
style={{
color: (session?.qdTrend ?? 0) >= 0
? 'var(--color-green)' : 'var(--color-red)',
}}
>
{(session?.qdTrend ?? 0) >= 0 ? '▲ RISING' : '▼ FALLING'}
{session?.currentZone && `${session.currentZone}`}
</span>
</div>
</div>
{/* ── AI Observation ────────────────────────── */}
{session?.aiObservation && (
<motion.div
className={`${styles.observationCard} glass-card`}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
>
<span className={styles.aiStar}> AI OBSERVATION</span>
<p className={styles.observation}>{session.aiObservation}</p>
</motion.div>
)}
{/* ── Actions ──────────────────────────────── */}
<div className={styles.actions}>
<button className="btn-primary" onClick={() => {/* open new lead modal */}}>
Tag as New Lead
</button>
<button className="btn-ghost" onClick={() => {/* open match modal */}}>
Match to Existing Client
</button>
<button
className={styles.endBtn}
onClick={handleEndSession}
>
End Session
</button>
</div>
</motion.div>
)}
{/* ── Session summary ───────────────────────────── */}
{phase === 'summary' && (
<motion.div
key="summary"
className={styles.summaryContent}
initial={{ opacity: 0, scale: 0.96 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.3, ease: [0.4, 0, 0.2, 1] }}
>
<div className={`${styles.summaryCard} glass-heavy`}>
<h2 className={styles.summaryTitle}>Session Complete</h2>
<p className={styles.summaryDuration}>
Duration: {formatTime(elapsed)}
</p>
{session?.peakQd && (
<p className={styles.summaryStat}>
Peak engagement: QD {session.peakQd}
</p>
)}
<div className={styles.summaryActions}>
<button
className="btn-primary"
onClick={() => navigate('/pipeline/new')}
>
Create Lead Profile
</button>
<button className="btn-ghost" onClick={handleExitShowroom}>
Return to Pipeline
</button>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
}
// ── Silhouette visualization (never raw CCTV) ─────────────────
function SilhouetteViz({ zone }: { zone?: string }) {
return (
<div className={styles.silhouetteWrap}>
{/* Abstract silhouette SVG — represents presence, not identity */}
<svg viewBox="0 0 120 180" className={styles.silhouette}>
<ellipse cx="60" cy="35" rx="22" ry="28" fill="rgba(124,58,237,0.3)" />
<path
d="M20 170 Q30 100 60 90 Q90 100 100 170"
fill="rgba(124,58,237,0.2)"
stroke="rgba(124,58,237,0.5)"
strokeWidth="1"
/>
</svg>
{zone && (
<span className={styles.zoneLabel}>Zone: {zone}</span>
)}
</div>
);
}

View File

@@ -0,0 +1,32 @@
/* Client360 */
.root { display: flex; flex-direction: column; height: 100%; overflow-y: auto; }
.backBtn { align-self: flex-start; background: none; border: none; color: var(--color-text-tertiary); font-size: var(--text-sm); cursor: pointer; padding: var(--space-4) var(--space-6) 0; font-family: var(--font-sans); transition: color var(--duration-fast) var(--ease-standard); }
.backBtn:hover { color: var(--color-text-primary); }
/* Header */
.header { margin: var(--space-3) var(--space-6) 0; padding: var(--space-6); display: flex; flex-direction: column; gap: var(--space-5); }
.headerTop { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-4); }
.identity { display: flex; align-items: center; gap: var(--space-4); }
.avatar { width: 52px; height: 52px; border-radius: var(--radius-full); background: var(--color-violet-glow); border: 2px solid var(--color-violet); display: flex; align-items: center; justify-content: center; font-size: var(--text-xl); font-weight: var(--font-bold); color: var(--color-violet-light); flex-shrink: 0; overflow: hidden; }
.avatar img { width: 100%; height: 100%; object-fit: cover; }
.nameBlock { display: flex; flex-direction: column; gap: var(--space-1); }
.name { font-size: var(--text-2xl); font-weight: var(--font-bold); color: var(--color-text-primary); letter-spacing: var(--tracking-tight); margin: 0; }
.meta { font-size: var(--text-sm); color: var(--color-text-secondary); margin: 0; }
.intentBadge { flex-shrink: 0; align-self: flex-start; }
/* KPI row */
.kpiRow { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--space-3); }
.kpiChip { display: flex; flex-direction: column; gap: var(--space-2); padding: var(--space-4); }
.kpiLabel { font-size: 9px; font-weight: var(--font-semibold); letter-spacing: var(--tracking-wider); text-transform: uppercase; color: var(--color-text-tertiary); }
.qdWrap { display: flex; align-items: center; gap: var(--space-3); }
.qdValue { font-size: var(--text-2xl); font-weight: var(--font-bold); }
.qdDelta { font-size: 10px; display: block; }
.kpiValue { font-size: var(--text-lg); font-weight: var(--font-semibold); color: var(--color-text-primary); }
.kpiSub { font-size: var(--text-xs); color: var(--color-text-tertiary); }
/* Action bar */
.actionBar { display: flex; gap: var(--space-3); flex-wrap: wrap; }
/* Tabs */
.tabNav { display: flex; gap: 0; border-bottom: var(--glass-border); margin: var(--space-4) var(--space-6) 0; }
.tabBtn { position: relative; background: none; border: none; padding: var(--space-3) var(--space-5); font-family: var(--font-sans); font-size: var(--text-sm); font-weight: var(--font-medium); color: var(--color-text-tertiary); cursor: pointer; transition: color var(--duration-fast) var(--ease-standard); }
.tabActive { color: var(--color-text-primary); }
.tabUnderline { position: absolute; bottom: -1px; left: 0; right: 0; height: 2px; background: var(--color-violet); border-radius: 1px; }
/* Content */
.tabContent { padding: var(--space-5) var(--space-6) var(--space-10); flex: 1; }

View File

@@ -0,0 +1,217 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { QDRing } from './QDRing';
import { ConversationsTab } from './tabs/Conversations';
import { IntelligenceTab } from './tabs/Intelligence';
import { PropertiesTab } from './tabs/Properties';
import { TasksTab } from './tabs/Tasks';
import { useClient360 } from '../../../shared/hooks/useClient360';
import styles from './Client360.module.css';
/**
* Client360
* The unified client entity. The most important screen in Velocity-OS.
* Accessible at /pipeline/:personId — slides in with depth choreography.
*
* Four tabs only: Conversations | Intelligence | Properties | Tasks
* Header: glassmorphic, always visible with QD ring, pipeline stage, last contact.
*/
type Tab = 'conversations' | 'intelligence' | 'properties' | 'tasks';
const TABS: { id: Tab; label: string }[] = [
{ id: 'conversations', label: 'Conversations' },
{ id: 'intelligence', label: 'Intelligence' },
{ id: 'properties', label: 'Properties' },
{ id: 'tasks', label: 'Tasks' },
];
export default function Client360() {
const { personId } = useParams<{ personId: string }>();
const navigate = useNavigate();
const [activeTab, setActiveTab] = useState<Tab>('conversations');
const { client, isLoading, error } = useClient360(personId!);
if (isLoading) return <Client360Skeleton />;
if (error || !client) return <Client360Error onBack={() => navigate('/pipeline')} />;
const qdColor =
client.qdScore >= 70 ? 'var(--color-green)' :
client.qdScore >= 40 ? 'var(--color-amber)' :
'var(--color-red)';
const intentLabel =
client.qdScore >= 70 ? 'HIGH INTENT' :
client.qdScore >= 40 ? 'MODERATE' :
'LOW INTENT';
return (
<div className={styles.root}>
{/* Back button */}
<button
className={styles.backBtn}
onClick={() => navigate('/pipeline')}
aria-label="Back to Pipeline"
>
Pipeline
</button>
{/* ── Glassmorphic Header ─────────────────────────────── */}
<div className={`${styles.header} glass-heavy`}>
<div className={styles.headerTop}>
{/* Avatar + Identity */}
<div className={styles.identity}>
<div className={styles.avatar}>
{client.avatarUrl
? <img src={client.avatarUrl} alt={client.name} />
: <span>{client.name.slice(0, 1)}</span>
}
</div>
<div className={styles.nameBlock}>
<h1 className={styles.name}>{client.name}</h1>
<p className={styles.meta}>
{client.location}
{client.primaryPhone && ` · ${client.primaryPhone}`}
</p>
</div>
</div>
{/* Intent badge */}
<span className={`badge badge-high-intent ${styles.intentBadge}`}
style={{ borderColor: qdColor, color: qdColor }}>
<span className="live-dot" style={{ background: qdColor }} />
{intentLabel}
</span>
</div>
{/* ── Three KPI chips ──────────────────────────────── */}
<div className={styles.kpiRow}>
{/* QD Score chip */}
<div className={`${styles.kpiChip} glass`}>
<span className={styles.kpiLabel}>QD SCORE</span>
<div className={styles.qdWrap}>
<QDRing score={client.qdScore} size={48} color={qdColor} />
<div>
<span className={styles.qdValue}
style={{ color: qdColor }}>{client.qdScore}</span>
{client.qdDelta !== 0 && (
<span className={styles.qdDelta}
style={{ color: client.qdDelta > 0 ? 'var(--color-green)' : 'var(--color-red)' }}>
{client.qdDelta > 0 ? '+' : ''}{client.qdDelta} this week
</span>
)}
</div>
</div>
</div>
{/* Pipeline Stage chip */}
<div className={`${styles.kpiChip} glass`}>
<span className={styles.kpiLabel}>PIPELINE STAGE</span>
<span className={styles.kpiValue}>
{client.stageEmoji} {client.stageName}
</span>
</div>
{/* Last Contact chip */}
<div className={`${styles.kpiChip} glass`}>
<span className={styles.kpiLabel}>LAST CONTACT</span>
<span className={styles.kpiValue}>{client.lastContactRelative}</span>
<span className={styles.kpiSub}>via {client.lastContactChannel}</span>
</div>
</div>
{/* ── Primary action bar ───────────────────────────── */}
<div className={styles.actionBar}>
<ActionButton
icon="💬"
label={client.lastContactChannel === 'WhatsApp' ? 'WhatsApp' : 'Message'}
onClick={() => {/* open inline compose */}}
primary
/>
<ActionButton icon="📞" label="Call" onClick={() => window.open(`tel:${client.primaryPhone}`)} />
<ActionButton icon="📅" label="Schedule" onClick={() => {/* open task modal */}} />
<ActionButton icon="⋯" label="More" onClick={() => {/* slide-up sheet */}} />
</div>
</div>
{/* ── Tab Navigation ──────────────────────────────────── */}
<div className={styles.tabNav} role="tablist">
{TABS.map(({ id, label }) => (
<button
key={id}
role="tab"
aria-selected={activeTab === id}
className={`${styles.tabBtn} ${activeTab === id ? styles.tabActive : ''}`}
onClick={() => setActiveTab(id)}
>
{label}
{activeTab === id && (
<motion.div
layoutId="tab-underline"
className={styles.tabUnderline}
transition={{ type: 'spring', stiffness: 500, damping: 40 }}
/>
)}
</button>
))}
</div>
{/* ── Tab Content ─────────────────────────────────────── */}
<div className={styles.tabContent}>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={activeTab}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
>
{activeTab === 'conversations' && <ConversationsTab personId={personId!} />}
{activeTab === 'intelligence' && <IntelligenceTab client={client} />}
{activeTab === 'properties' && <PropertiesTab personId={personId!} />}
{activeTab === 'tasks' && <TasksTab personId={personId!} />}
</motion.div>
</AnimatePresence>
</div>
</div>
);
}
// ── Sub-components ───────────────────────────────────────────
function ActionButton({
icon, label, onClick, primary = false,
}: { icon: string; label: string; onClick: () => void; primary?: boolean }) {
return (
<button
className={primary ? 'btn-primary' : 'btn-ghost'}
onClick={onClick}
aria-label={label}
>
<span>{icon}</span> {label}
</button>
);
}
function Client360Skeleton() {
return (
<div className={styles.root}>
<div className={`${styles.header} glass-heavy shimmer`} style={{ minHeight: 220 }} />
<div style={{ padding: 'var(--space-6)', display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
{[1,2,3].map(i => (
<div key={i} className="glass shimmer" style={{ height: 80, borderRadius: 'var(--radius-lg)' }} />
))}
</div>
</div>
);
}
function Client360Error({ onBack }: { onBack: () => void }) {
return (
<div className={styles.root} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 'var(--space-4)', padding: 'var(--space-16)' }}>
<p style={{ color: 'var(--color-text-secondary)' }}>Could not load client profile.</p>
<button className="btn-ghost" onClick={onBack}> Back to Pipeline</button>
</div>
);
}

View File

@@ -0,0 +1,92 @@
import { useEffect, useRef } from 'react';
import { motion } from 'framer-motion';
/**
* QDRing
* Animated SVG arc representing the Qualification Desire score (0100).
* Color: green (≥70), amber (4069), red (<40).
* Animates from 0 to score on mount; smooth spring on value change.
*/
interface QDRingProps {
score: number;
size?: number; // diameter in px (default 64)
strokeWidth?: number;
color?: string; // overrides semantic color if provided
showLabel?: boolean;
}
export function QDRing({
score,
size = 64,
strokeWidth = 4,
color,
showLabel = false,
}: QDRingProps) {
const clampedScore = Math.max(0, Math.min(100, score));
const r = (size - strokeWidth) / 2;
const cx = size / 2;
const cy = size / 2;
const circumference = 2 * Math.PI * r;
// Arc: 0% = full stroke-dashoffset (hidden), 100% = 0 offset (full)
const offset = circumference * (1 - clampedScore / 100);
// Semantic color
const arcColor = color ?? (
clampedScore >= 70 ? 'var(--color-green)' :
clampedScore >= 40 ? 'var(--color-amber)' :
'var(--color-red)'
);
return (
<svg
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
role="img"
aria-label={`QD Score: ${clampedScore}`}
style={{ transform: 'rotate(-90deg)' }} // Start arc at top
>
{/* Track */}
<circle
cx={cx} cy={cy} r={r}
fill="none"
stroke="rgba(255,255,255,0.08)"
strokeWidth={strokeWidth}
/>
{/* Animated arc */}
<motion.circle
cx={cx} cy={cy} r={r}
fill="none"
stroke={arcColor}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeDasharray={circumference}
initial={{ strokeDashoffset: circumference }}
animate={{ strokeDashoffset: offset }}
transition={{ duration: 0.8, ease: [0.4, 0, 0.2, 1] }}
style={{
filter: `drop-shadow(0 0 4px ${arcColor})`,
}}
/>
{/* Optional center label (shown rotated back) */}
{showLabel && (
<text
x={cx} y={cy}
textAnchor="middle"
dominantBaseline="middle"
fill={arcColor}
fontSize={size * 0.22}
fontWeight="700"
fontFamily="var(--font-sans)"
style={{ transform: `rotate(90deg)`, transformOrigin: `${cx}px ${cy}px` }}
>
{clampedScore}
</text>
)}
</svg>
);
}

View File

@@ -0,0 +1,26 @@
/* Conversations tab */
.root { display: flex; flex-direction: column; gap: var(--space-3); }
.skeleton { height: 100px; border-radius: var(--radius-lg); }
.eventCard { display: flex; flex-direction: column; gap: var(--space-3); }
.eventHeader { display: flex; align-items: center; justify-content: space-between; }
.channelBadge { font-size: var(--text-xs); font-weight: var(--font-semibold); color: var(--color-text-secondary); }
.timestamp { font-size: var(--text-xs); color: var(--color-text-tertiary); }
/* Thread */
.thread { display: flex; flex-direction: column; gap: var(--space-2); }
.bubble { display: flex; align-items: flex-end; gap: var(--space-2); max-width: 80%; }
.inbound { align-self: flex-start; }
.outbound { align-self: flex-end; flex-direction: row-reverse; }
.bubbleText { background: var(--glass-bg); border-radius: var(--radius-lg); padding: var(--space-2) var(--space-3); font-size: var(--text-sm); color: var(--color-text-primary); }
.outbound .bubbleText { background: var(--color-violet); color: white; }
.status { font-size: 10px; color: var(--color-text-tertiary); }
.threadActions { display: flex; gap: var(--space-2); margin-top: var(--space-1); }
/* Reply */
.replyBox { display: flex; gap: var(--space-2); align-items: center; margin-top: var(--space-2); }
.replyInput { flex: 1; background: var(--glass-bg); border: var(--glass-border); border-radius: var(--radius-lg); padding: var(--space-2) var(--space-3); font-family: var(--font-sans); font-size: var(--text-sm); color: var(--color-text-primary); outline: none; }
/* Call */
.callRecord { display: flex; flex-direction: column; gap: var(--space-2); }
.callMeta { display: flex; gap: var(--space-3); font-size: var(--text-xs); color: var(--color-text-secondary); }
.keyMoments { display: flex; align-items: center; gap: var(--space-2); flex-wrap: wrap; }
.kmLabel { font-size: var(--text-xs); color: var(--color-text-tertiary); }
.kmChip { font-size: var(--text-xs); background: var(--glass-bg); border: var(--glass-border); border-radius: var(--radius-full); padding: 2px var(--space-2); color: var(--color-text-secondary); }
.callActions { display: flex; gap: var(--space-2); }

View File

@@ -0,0 +1,147 @@
import { useState, useRef } from 'react';
import { motion } from 'framer-motion';
import { useConversations } from '../../../shared/hooks/useClient360';
import styles from './Conversations.module.css';
/**
* Conversations Tab
* Unified chronological feed: WhatsApp threads + call records + emails.
* All in one scroll — no navigation to separate Comms module ever needed.
* WhatsApp threads render inline with reply. Calls show key moments.
*/
interface ConversationsTabProps {
personId: string;
}
type EventType = 'whatsapp' | 'call' | 'email';
interface ConvEvent {
id: string;
type: EventType;
timestamp: string;
timestampRelative: string;
// WhatsApp
messages?: { sender: 'client' | 'you'; text: string; status?: '✓' | '✓✓' }[];
// Call
duration?: string;
direction?: 'inbound' | 'outbound';
keyMoments?: string[];
hasTranscript?: boolean;
// Email
subject?: string;
preview?: string;
}
export function ConversationsTab({ personId }: ConversationsTabProps) {
const { events, isLoading, sendWhatsApp } = useConversations(personId);
const [replyText, setReplyText] = useState('');
const [replyingToId, setReplyingToId] = useState<string | null>(null);
if (isLoading) {
return (
<div className={styles.root}>
{[0, 1, 2].map(i => (
<div key={i} className={`${styles.skeleton} shimmer`} />
))}
</div>
);
}
return (
<div className={styles.root}>
{events.map((event, i) => (
<motion.div
key={event.id}
className={`${styles.eventCard} glass-card`}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.06, duration: 0.25 }}
>
{/* Event header */}
<div className={styles.eventHeader}>
<span className={styles.channelBadge}
data-type={event.type}>
{event.type === 'whatsapp' ? '💬 WhatsApp'
: event.type === 'call' ? '📞 Call'
: '✉️ Email'}
</span>
<span className={styles.timestamp}>{event.timestampRelative}</span>
</div>
{/* WhatsApp thread */}
{event.type === 'whatsapp' && event.messages && (
<div className={styles.thread}>
{event.messages.map((msg, mi) => (
<div
key={mi}
className={`${styles.bubble} ${msg.sender === 'you' ? styles.outbound : styles.inbound}`}
>
<span className={styles.bubbleText}>{msg.text}</span>
{msg.sender === 'you' && msg.status && (
<span className={styles.status}>{msg.status}</span>
)}
</div>
))}
{/* Inline reply */}
{replyingToId === event.id ? (
<div className={styles.replyBox}>
<input
value={replyText}
onChange={e => setReplyText(e.target.value)}
placeholder="Type a reply…"
className={styles.replyInput}
autoFocus
onKeyDown={e => {
if (e.key === 'Enter' && replyText.trim()) {
sendWhatsApp(replyText);
setReplyText('');
setReplyingToId(null);
}
}}
/>
<button className="btn-primary" onClick={() => {
if (replyText.trim()) { sendWhatsApp(replyText); setReplyText(''); setReplyingToId(null); }
}}>Send</button>
<button className="btn-ghost" onClick={() => setReplyingToId(null)}>Cancel</button>
</div>
) : (
<div className={styles.threadActions}>
<button className="btn-ghost" onClick={() => setReplyingToId(event.id)}>
Reply in WhatsApp
</button>
<button className="btn-ghost">AI Summarize</button>
</div>
)}
</div>
)}
{/* Call record */}
{event.type === 'call' && (
<div className={styles.callRecord}>
<div className={styles.callMeta}>
<span>{event.direction === 'inbound' ? '↙ Inbound' : '↗ Outbound'}</span>
{event.duration && <span>· {event.duration}</span>}
{event.hasTranscript && <span>· Transcript available</span>}
</div>
{event.keyMoments && event.keyMoments.length > 0 && (
<div className={styles.keyMoments}>
<span className={styles.kmLabel}>Key moments:</span>
{event.keyMoments.map((m, mi) => (
<span key={mi} className={styles.kmChip}>{m}</span>
))}
</div>
)}
{event.hasTranscript && (
<div className={styles.callActions}>
<button className="btn-ghost">Read Transcript</button>
<button className="btn-ghost">View Extracted Facts</button>
</div>
)}
</div>
)}
</motion.div>
))}
</div>
);
}

View File

@@ -0,0 +1,17 @@
/* Intelligence tab */
.root { display: flex; flex-direction: column; gap: var(--space-5); }
.insightCard { display: flex; flex-direction: column; gap: var(--space-3); }
.insightHeader { display: flex; align-items: center; justify-content: space-between; }
.aiStar { font-size: var(--text-xs); font-weight: var(--font-semibold); letter-spacing: var(--tracking-wider); color: var(--color-violet-light); }
.insightText { font-size: var(--text-base); color: var(--color-text-primary); line-height: var(--leading-relaxed); margin: 0; }
.section { display: flex; flex-direction: column; gap: var(--space-3); }
.sectionTitle { font-size: 10px; font-weight: var(--font-semibold); letter-spacing: var(--tracking-wider); text-transform: uppercase; color: var(--color-text-tertiary); }
.chipsGrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: var(--space-2); }
.factChip { display: flex; flex-direction: column; gap: var(--space-1); padding: var(--space-3); border-radius: var(--radius-lg); }
.chipLabel { font-size: 10px; color: var(--color-text-tertiary); font-weight: var(--font-medium); }
.chipValue { font-size: var(--text-sm); color: var(--color-text-primary); font-weight: var(--font-semibold); }
.objectionList { display: flex; flex-direction: column; gap: var(--space-2); list-style: none; padding: 0; margin: 0; }
.objectionItem { font-size: var(--text-sm); color: var(--color-text-secondary); }
.biometricSection { }
.sparkline { width: 100%; height: 64px; }
.peakLabel { font-size: var(--text-xs); color: var(--color-text-tertiary); margin: var(--space-1) 0 0; }

View File

@@ -0,0 +1,150 @@
import { motion } from 'framer-motion';
import { QDRing } from '../QDRing';
import styles from './Intelligence.module.css';
/**
* Intelligence Tab
* Biometric data expressed as business meaning — never raw scores.
* Sections: AI Insight (act-able) | Extracted Facts chips | Objections | Biometric sparkline
*/
interface IntelligenceTabProps {
client: {
id: string;
aiInsight?: string;
extractedFacts?: { budget?: string; timeline?: string; decisionMakers?: string; [key: string]: string | undefined };
objections?: string[];
qdHistory?: { date: string; score: number; label?: string }[];
qdScore: number;
};
}
export function IntelligenceTab({ client }: IntelligenceTabProps) {
const facts = client.extractedFacts ?? {};
const factChips = [
{ label: 'Budget', value: facts.budget },
{ label: 'Timeline', value: facts.timeline },
{ label: 'Decision Makers', value: facts.decisionMakers },
{ label: 'Property Type', value: facts.propertyType },
{ label: 'Location Pref', value: facts.locationPreference },
].filter(f => f.value);
return (
<div className={styles.root}>
{/* AI Insight — always first */}
{client.aiInsight && (
<motion.div
className={`${styles.insightCard} glass-card glass-violet`}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<div className={styles.insightHeader}>
<span className={styles.aiStar}> AI INSIGHT</span>
<button className="btn-primary">Act</button>
</div>
<p className={styles.insightText}>{client.aiInsight}</p>
</motion.div>
)}
{/* Extracted Facts chips */}
{factChips.length > 0 && (
<motion.div
className={styles.section}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1, duration: 0.3 }}
>
<h3 className={styles.sectionTitle}>EXTRACTED FACTS</h3>
<div className={styles.chipsGrid}>
{factChips.map(({ label, value }, i) => (
<motion.div
key={label}
className={`${styles.factChip} glass`}
initial={{ opacity: 0, scale: 0.94 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.15 + i * 0.05 }}
>
<span className={styles.chipLabel}>{label}</span>
<span className={styles.chipValue}>{value}</span>
</motion.div>
))}
</div>
</motion.div>
)}
{/* Objections */}
{client.objections && client.objections.length > 0 && (
<motion.div
className={styles.section}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2, duration: 0.3 }}
>
<h3 className={styles.sectionTitle}>OBJECTIONS RAISED</h3>
<ul className={styles.objectionList}>
{client.objections.map((obj, i) => (
<li key={i} className={styles.objectionItem}>· {obj}</li>
))}
</ul>
</motion.div>
)}
{/* Biometric Engagement sparkline */}
{client.qdHistory && client.qdHistory.length > 0 && (
<motion.div
className={`${styles.section} ${styles.biometricSection} glass-card`}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3, duration: 0.3 }}
>
<h3 className={styles.sectionTitle}>BIOMETRIC ENGAGEMENT</h3>
<QDSparkline history={client.qdHistory} currentScore={client.qdScore} />
{client.qdHistory.slice(-1)[0]?.label && (
<p className={styles.peakLabel}>
Peak: {client.qdHistory.slice(-1)[0].label}
</p>
)}
</motion.div>
)}
</div>
);
}
// ── QD Sparkline (inline SVG, no charting library dependency) ─
function QDSparkline({
history,
currentScore,
}: { history: { date: string; score: number; label?: string }[]; currentScore: number }) {
if (history.length < 2) return null;
const scores = history.map(h => h.score);
const min = Math.min(...scores);
const max = Math.max(...scores);
const range = max - min || 1;
const W = 320, H = 64, PAD = 8;
const step = (W - PAD * 2) / (scores.length - 1);
const points = scores.map((s, i) => ({
x: PAD + i * step,
y: H - PAD - ((s - min) / range) * (H - PAD * 2),
}));
const pathD = points.map((p, i) =>
(i === 0 ? `M ${p.x} ${p.y}` : `L ${p.x} ${p.y}`)
).join(' ');
const qdColor = currentScore >= 70 ? 'var(--color-green)'
: currentScore >= 40 ? 'var(--color-amber)'
: 'var(--color-red)';
return (
<svg viewBox={`0 0 ${W} ${H}`} className={styles.sparkline}>
<path d={pathD} fill="none" stroke={qdColor} strokeWidth="2" strokeLinecap="round" />
{/* Peak dot */}
{points.map((p, i) => scores[i] === max && (
<circle key={i} cx={p.x} cy={p.y} r="4"
fill={qdColor} opacity="0.9" />
))}
</svg>
);
}

View File

@@ -0,0 +1,15 @@
/* Properties tab */
.root { display: flex; flex-direction: column; gap: var(--space-4); }
.skeleton { height: 120px; border-radius: var(--radius-lg); }
.propertyCard { display: flex; flex-direction: column; gap: var(--space-4); overflow: hidden; }
.thumbnail { position: relative; height: 140px; border-radius: var(--radius-lg); overflow: hidden; background: var(--glass-bg); }
.thumbnail img { width: 100%; height: 100%; object-fit: cover; }
.thumbPlaceholder { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; font-size: 40px; }
.primaryBadge { position: absolute; top: var(--space-2); right: var(--space-2); }
.details { display: flex; flex-direction: column; gap: var(--space-2); }
.unitName { font-size: var(--text-base); font-weight: var(--font-semibold); color: var(--color-text-primary); margin: 0; }
.unitMeta { font-size: var(--text-sm); color: var(--color-text-secondary); margin: 0; }
.engagementLabel { font-size: var(--text-xs); font-weight: var(--font-semibold); margin: 0; }
.propActions { display: flex; gap: var(--space-2); flex-wrap: wrap; }
.reimagineWrap { padding-top: var(--space-4); }
.empty { font-size: var(--text-sm); color: var(--color-text-tertiary); }

View File

@@ -0,0 +1,121 @@
import { motion } from 'framer-motion';
import { useNavigate } from 'react-router-dom';
import { useState } from 'react';
import { ReimaginePanel } from '../../../studio/ReimaginePanel';
import { useClientProperties } from '../../../../shared/hooks/useClient360';
import styles from './Properties.module.css';
/**
* Properties Tab
* Shows property interests linked to this client.
* "Stage It" triggers ReimaginePanel inline — no navigation to Studio/Catalyst.
* "Share Brochure" generates Vault link — one tap, no modal.
*/
interface PropertiesTabProps {
personId: string;
}
export function PropertiesTab({ personId }: PropertiesTabProps) {
const navigate = useNavigate();
const { properties, isLoading } = useClientProperties(personId);
const [reimagining, setReimagining] = useState<string | null>(null); // propertyId
if (isLoading) {
return (
<div className={styles.root}>
{[0, 1].map(i => <div key={i} className={`${styles.skeleton} shimmer`} />)}
</div>
);
}
return (
<div className={styles.root}>
{properties.map((prop, i) => (
<motion.div
key={prop.id}
className={`${styles.propertyCard} glass-card`}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.08, duration: 0.3 }}
>
{/* Thumbnail */}
<div className={styles.thumbnail}>
{prop.thumbnailUrl
? <img src={prop.thumbnailUrl} alt={prop.unitName} />
: <div className={styles.thumbPlaceholder}>🏙</div>
}
{prop.isPrimary && (
<span className={`badge badge-high-intent ${styles.primaryBadge}`}>
Primary
</span>
)}
</div>
{/* Details */}
<div className={styles.details}>
<h3 className={styles.unitName}>{prop.projectName} · {prop.unitName}</h3>
<p className={styles.unitMeta}>
{prop.config} · {prop.area} · {prop.price}
</p>
<p className={styles.engagementLabel}
style={{ color: prop.engagementLevel === 'High' ? 'var(--color-green)' : 'var(--color-text-secondary)' }}>
{prop.engagementLevel} Engagement
</p>
{/* Actions */}
<div className={styles.propActions}>
<button
className="btn-ghost"
onClick={() => navigate(`/studio/${prop.id}`)}
>
View Unit
</button>
<button
className="btn-ghost"
onClick={() => {/* generate vault link + copy */}}
>
Share Brochure
</button>
<button
className="btn-primary"
onClick={() => setReimagining(
reimagining === prop.id ? null : prop.id
)}
>
Stage It
</button>
</div>
</div>
{/* Inline ReimaginePanel — no navigation to Studio */}
<AnimatedCollapse open={reimagining === prop.id}>
<div className={styles.reimagineWrap}>
<ReimaginePanel
propertyId={prop.id}
roomImageUrl={prop.thumbnailUrl}
onResultSaved={() => setReimagining(null)}
/>
</div>
</AnimatedCollapse>
</motion.div>
))}
{properties.length === 0 && (
<p className={styles.empty}>No property interests linked yet.</p>
)}
</div>
);
}
function AnimatedCollapse({ open, children }: { open: boolean; children: React.ReactNode }) {
return (
<motion.div
style={{ overflow: 'hidden' }}
animate={{ height: open ? 'auto' : 0, opacity: open ? 1 : 0 }}
transition={{ duration: 0.3, ease: [0.4, 0, 0.2, 1] }}
>
{children}
</motion.div>
);
}

View File

@@ -0,0 +1,14 @@
/* Tasks tab */
.root { display: flex; flex-direction: column; gap: var(--space-5); }
.skeleton { height: 56px; border-radius: var(--radius-md); }
.group { display: flex; flex-direction: column; gap: var(--space-2); }
.groupLabel { font-size: 10px; font-weight: var(--font-semibold); letter-spacing: var(--tracking-wider); text-transform: uppercase; color: var(--color-text-tertiary); padding-bottom: var(--space-1); border-bottom: var(--glass-border); }
.taskRow { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-2) 0; }
.checkOpen { color: var(--color-text-tertiary); font-size: 14px; flex-shrink: 0; }
.checkDone { color: var(--color-green); font-size: 14px; flex-shrink: 0; }
.taskContent { flex: 1; display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.taskLabel { font-size: var(--text-sm); color: var(--color-text-primary); }
.taskDue { font-size: 10px; color: var(--color-text-tertiary); }
.aiChip { font-size: 10px; background: rgba(124,58,237,0.15); color: var(--color-violet-light); border-radius: var(--radius-full); padding: 1px var(--space-2); display: inline-block; }
.taskActions { display: flex; gap: var(--space-1); flex-shrink: 0; }
.empty { font-size: var(--text-sm); color: var(--color-text-tertiary); }

View File

@@ -0,0 +1,104 @@
import { motion } from 'framer-motion';
import { useClientTasks } from '../../../../shared/hooks/useClient360';
import styles from './Tasks.module.css';
/**
* Tasks Tab
* Timeline: TODAY → UPCOMING → COMPLETED
* Tasks are AI-generated or manually created reminders.
* "Done" and "Snooze" are the only actions — no task configuration UI.
*/
interface TasksTabProps {
personId: string;
}
export function TasksTab({ personId }: TasksTabProps) {
const { tasks, isLoading, markDone, snooze } = useClientTasks(personId);
const today = tasks.filter(t => t.group === 'today');
const upcoming = tasks.filter(t => t.group === 'upcoming');
const completed = tasks.filter(t => t.group === 'completed');
if (isLoading) {
return (
<div className={styles.root}>
{[0, 1, 2].map(i => <div key={i} className={`${styles.skeleton} shimmer`} />)}
</div>
);
}
return (
<div className={styles.root}>
{today.length > 0 && (
<TaskGroup label="TODAY" tasks={today} onDone={markDone} onSnooze={snooze} />
)}
{upcoming.length > 0 && (
<TaskGroup label="UPCOMING" tasks={upcoming} onDone={markDone} onSnooze={snooze} />
)}
{completed.length > 0 && (
<TaskGroup label="COMPLETED" tasks={completed} dim />
)}
{tasks.length === 0 && (
<p className={styles.empty}>No tasks yet create one to stay on track.</p>
)}
</div>
);
}
interface Task {
id: string;
label: string;
dueAt?: string;
group: 'today' | 'upcoming' | 'completed';
isAIGenerated?: boolean;
}
function TaskGroup({
label, tasks, dim = false, onDone, onSnooze,
}: {
label: string;
tasks: Task[];
dim?: boolean;
onDone?: (id: string) => void;
onSnooze?: (id: string) => void;
}) {
return (
<motion.div
className={styles.group}
initial={{ opacity: 0 }}
animate={{ opacity: dim ? 0.55 : 1 }}
transition={{ duration: 0.3 }}
>
<h3 className={styles.groupLabel}>{label}</h3>
{tasks.map((task, i) => (
<motion.div
key={task.id}
className={styles.taskRow}
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: i * 0.05 }}
>
<span className={dim ? styles.checkDone : styles.checkOpen}>
{dim ? '✓' : '○'}
</span>
<div className={styles.taskContent}>
<span className={styles.taskLabel}>{task.label}</span>
{task.dueAt && (
<span className={styles.taskDue}>{task.dueAt}</span>
)}
{task.isAIGenerated && (
<span className={styles.aiChip}> AI</span>
)}
</div>
{!dim && onDone && onSnooze && (
<div className={styles.taskActions}>
<button className="btn-ghost" onClick={() => onDone(task.id)}>Done</button>
<button className="btn-ghost" onClick={() => onSnooze(task.id)}>Snooze</button>
</div>
)}
</motion.div>
))}
</motion.div>
);
}

View File

@@ -0,0 +1,166 @@
import { motion, AnimatePresence } from 'framer-motion';
import { X, Brain, Calendar, TrendingUp, TrendingDown, Minus } from 'lucide-react';
import type { JourneyEvent } from '@/types';
// ─── Radial Sentiment Gauge ────────────────────────────────────────────────────
function SentimentGauge({ score }: { score: number }) {
const radius = 44;
const stroke = 6;
const normalizedRadius = radius - stroke / 2;
const circumference = normalizedRadius * 2 * Math.PI;
// Only draw 270° arc (from 135° to 405°)
const arcLength = circumference * 0.75;
const offset = arcLength - (score / 100) * arcLength;
const color =
score >= 70 ? '#22c55e' :
score >= 40 ? '#f59e0b' : '#ef4444';
return (
<div className="relative flex items-center justify-center" style={{ width: 100, height: 100 }}>
<svg width={100} height={100} style={{ transform: 'rotate(135deg)' }}>
{/* Track */}
<circle
cx={50} cy={50} r={normalizedRadius}
fill="none"
stroke="rgba(255,255,255,0.06)"
strokeWidth={stroke}
strokeDasharray={`${arcLength} ${circumference}`}
strokeLinecap="round"
/>
{/* Value arc */}
<motion.circle
cx={50} cy={50} r={normalizedRadius}
fill="none"
stroke={color}
strokeWidth={stroke}
strokeDasharray={`${arcLength} ${circumference}`}
strokeLinecap="round"
initial={{ strokeDashoffset: arcLength }}
animate={{ strokeDashoffset: offset }}
transition={{ duration: 0.8, ease: 'easeOut' }}
style={{ filter: `drop-shadow(0 0 6px ${color})` }}
/>
</svg>
{/* Centre label */}
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-xl font-bold text-white leading-none">{score}</span>
<span className="text-[9px] text-white/40 uppercase tracking-wider mt-0.5">score</span>
</div>
</div>
);
}
// ─── Inspector Panel ───────────────────────────────────────────────────────────
interface InspectorPanelProps {
event: JourneyEvent | null;
onClose: () => void;
}
const TYPE_LABELS: Record<JourneyEvent['type'], string> = {
call: 'Phone Call',
visit: 'Site Visit',
chat: 'Chat Session',
negotiation: 'Negotiation',
};
export function InspectorPanel({ event, onClose }: InspectorPanelProps) {
const SentimentIcon =
event && event.sentimentScore >= 70 ? TrendingUp :
event && event.sentimentScore >= 40 ? Minus : TrendingDown;
const sentimentColor =
event && event.sentimentScore >= 70 ? 'text-green-400' :
event && event.sentimentScore >= 40 ? 'text-amber-400' : 'text-red-400';
return (
<AnimatePresence>
{event && (
<motion.div
key={event.id}
layout
initial={{ width: 0, opacity: 0 }}
animate={{ width: '30%', opacity: 1 }}
exit={{ width: 0, opacity: 0 }}
transition={{ type: 'spring', stiffness: 280, damping: 30 }}
className="flex-shrink-0 h-full overflow-hidden border-l border-white/5"
style={{
background: 'rgba(255,255,255,0.03)',
backdropFilter: 'blur(12px)',
}}
>
<div className="h-full overflow-y-auto custom-scrollbar p-4 flex flex-col gap-4 min-w-[220px]">
{/* Header */}
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<p className="text-[10px] uppercase tracking-widest text-white/30 mb-1">
{TYPE_LABELS[event.type]}
</p>
<h4 className="text-white font-semibold text-sm leading-tight truncate">
{event.title}
</h4>
<div className="flex items-center gap-1 mt-1 text-white/40 text-[10px]">
<Calendar className="w-3 h-3" />
{event.timestamp}
</div>
</div>
<button
onClick={onClose}
className="w-7 h-7 rounded-lg bg-white/5 hover:bg-white/10 flex items-center justify-center flex-shrink-0 transition-colors"
>
<X className="w-3.5 h-3.5 text-white/50" />
</button>
</div>
{/* Sentiment Gauge */}
<div className="flex flex-col items-center gap-2 py-2">
<SentimentGauge score={event.sentimentScore} />
<div className={`flex items-center gap-1 text-xs font-medium ${sentimentColor}`}>
<SentimentIcon className="w-3.5 h-3.5" />
{event.sentimentScore >= 70 ? 'High Interest' :
event.sentimentScore >= 40 ? 'Neutral' : 'Low Engagement'}
</div>
</div>
{/* Divider */}
<div className="h-px bg-white/5" />
{/* Summary */}
<div>
<p className="text-[10px] uppercase tracking-widest text-white/30 mb-2">Summary</p>
<p className="text-white/70 text-xs leading-relaxed">{event.summary}</p>
</div>
{/* AI Insight */}
{event.aiInsight && (
<div className="rounded-xl bg-blue-500/5 border border-blue-500/15 p-3">
<div className="flex items-center gap-1.5 mb-2">
<Brain className="w-3.5 h-3.5 text-blue-400" />
<span className="text-[10px] uppercase tracking-widest text-blue-400 font-semibold">
AI Insight
</span>
</div>
<p className="text-white/70 text-xs leading-relaxed">{event.aiInsight}</p>
</div>
)}
{/* Evidence placeholder */}
<div>
<p className="text-[10px] uppercase tracking-widest text-white/30 mb-2">Evidence</p>
<div className="rounded-xl overflow-hidden border border-white/5 bg-white/3 aspect-video flex items-center justify-center">
<div className="text-center">
<div className="w-8 h-8 rounded-full bg-white/5 flex items-center justify-center mx-auto mb-2">
<span className="text-lg">📷</span>
</div>
<p className="text-white/30 text-[10px]">
{event.evidenceLabel ?? 'Camera snapshot'}
</p>
</div>
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
);
}

View File

@@ -0,0 +1,239 @@
import React, { useRef, useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import { Phone, Eye, MessageSquare, TrendingUp } from 'lucide-react';
import type { JourneyEvent } from '@/types';
import { generateSmoothPath, sentimentToX, indexToY } from '@/utils/curveGenerator';
// ─── Icon map ─────────────────────────────────────────────────────────────────
const EVENT_ICONS: Record<JourneyEvent['type'], React.FC<React.SVGProps<SVGSVGElement>>> = {
call: Phone,
visit: Eye,
chat: MessageSquare,
negotiation: TrendingUp,
};
// ─── Colour stops for gradient ────────────────────────────────────────────────
// Gradient goes left (red, low sentiment) → amber → green (right, high sentiment)
const GRADIENT_ID = 'riverGradient';
interface RiverPathProps {
events: JourneyEvent[];
onSelectEvent: (id: string) => void;
selectedEventId: string | null;
}
export function RiverPath({ events, onSelectEvent, selectedEventId }: RiverPathProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [dims, setDims] = useState({ width: 600, height: 500 });
const [hoveredId, setHoveredId] = useState<string | null>(null);
// Measure container on mount and resize
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const ro = new ResizeObserver(entries => {
const { width, height } = entries[0].contentRect;
setDims({ width: Math.max(width, 200), height: Math.max(height, 300) });
});
ro.observe(el);
return () => ro.disconnect();
}, []);
const { width, height } = dims;
const PAD_X = 48;
const PAD_Y = 48;
// Map events → pixel points
const points = events.map((ev, i) => ({
x: sentimentToX(ev.sentimentScore, width, PAD_X),
y: indexToY(i, events.length, height, PAD_Y),
ev,
}));
const pathD = generateSmoothPath(points.map(p => ({ x: p.x, y: p.y })), 0.45);
return (
<div ref={containerRef} className="relative w-full h-full overflow-hidden">
{/* Subtle grid background */}
<div
className="absolute inset-0 pointer-events-none"
style={{
backgroundImage:
'linear-gradient(rgba(255,255,255,0.03) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.03) 1px, transparent 1px)',
backgroundSize: '32px 32px',
}}
/>
{/* Edge fade */}
<div className="absolute inset-0 pointer-events-none"
style={{
background: 'radial-gradient(ellipse at center, transparent 40%, rgba(0,0,0,0.6) 100%)',
}}
/>
{/* X-axis labels */}
<div className="absolute bottom-2 left-0 right-0 flex justify-between px-12 pointer-events-none">
<span className="text-[10px] text-red-400/60 font-mono">LOW</span>
<span className="text-[10px] text-white/30 font-mono">SENTIMENT</span>
<span className="text-[10px] text-green-400/60 font-mono">HIGH</span>
</div>
<svg
width={width}
height={height}
className="absolute inset-0"
style={{ overflow: 'visible' }}
>
<defs>
{/* Horizontal gradient: red → amber → green */}
<linearGradient id={GRADIENT_ID} x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#ef4444" stopOpacity="0.9" />
<stop offset="40%" stopColor="#f59e0b" stopOpacity="0.9" />
<stop offset="100%" stopColor="#22c55e" stopOpacity="0.9" />
</linearGradient>
{/* Glow filter for active nodes */}
<filter id="nodeGlow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="4" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
{/* Soft glow for the path itself */}
<filter id="pathGlow" x="-10%" y="-10%" width="120%" height="120%">
<feGaussianBlur stdDeviation="3" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
{/* Glow shadow path (blurred duplicate) */}
{pathD && (
<path
d={pathD}
fill="none"
stroke={`url(#${GRADIENT_ID})`}
strokeWidth={10}
strokeLinecap="round"
opacity={0.25}
filter="url(#pathGlow)"
/>
)}
{/* Main river path */}
{pathD && (
<motion.path
d={pathD}
fill="none"
stroke={`url(#${GRADIENT_ID})`}
strokeWidth={5}
strokeLinecap="round"
initial={{ pathLength: 0, opacity: 0 }}
animate={{ pathLength: 1, opacity: 1 }}
transition={{ duration: 1.2, ease: 'easeInOut' }}
/>
)}
{/* Event nodes */}
{points.map(({ x, y, ev }) => {
const Icon = EVENT_ICONS[ev.type];
const isHovered = hoveredId === ev.id;
const isSelected = selectedEventId === ev.id;
const isActive = isHovered || isSelected;
// Colour based on sentiment
const nodeColor =
ev.sentimentScore >= 70 ? '#22c55e' :
ev.sentimentScore >= 40 ? '#f59e0b' : '#ef4444';
return (
<g key={ev.id}>
{/* Pulse ring (idle) */}
{!isActive && (
<motion.circle
cx={x} cy={y} r={14}
fill="none"
stroke={nodeColor}
strokeWidth={1}
opacity={0.4}
animate={{ r: [12, 18, 12], opacity: [0.4, 0.1, 0.4] }}
transition={{ duration: 2.5, repeat: Infinity, ease: 'easeInOut' }}
/>
)}
{/* Node background */}
<motion.circle
cx={x} cy={y}
r={isActive ? 18 : 12}
fill={`${nodeColor}22`}
stroke={nodeColor}
strokeWidth={isActive ? 2 : 1.5}
style={isActive ? { filter: `drop-shadow(0 0 8px ${nodeColor})` } : {}}
animate={{ r: isActive ? 18 : 12 }}
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
className="cursor-pointer"
onMouseEnter={() => setHoveredId(ev.id)}
onMouseLeave={() => setHoveredId(null)}
onClick={() => onSelectEvent(ev.id)}
/>
{/* Icon (foreignObject for React icons) */}
<foreignObject
x={x - 8} y={y - 8}
width={16} height={16}
className="pointer-events-none"
>
<div className="w-4 h-4 flex items-center justify-center">
{React.createElement(Icon, {
style: { width: 10, height: 10, color: nodeColor, strokeWidth: 2.5 }
})}
</div>
</foreignObject>
{/* Hover label */}
{isHovered && !isSelected && (
<motion.g
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.15 }}
>
<rect
x={x - 60} y={y - 42}
width={120} height={26}
rx={6}
fill="rgba(0,0,0,0.85)"
stroke={nodeColor}
strokeWidth={0.8}
strokeOpacity={0.6}
/>
<text
x={x} y={y - 24}
textAnchor="middle"
fill="white"
fontSize={10}
fontFamily="Inter, sans-serif"
fontWeight={500}
>
{ev.title}
</text>
<text
x={x} y={y - 13}
textAnchor="middle"
fill={nodeColor}
fontSize={8.5}
fontFamily="Inter, sans-serif"
>
{ev.sentimentScore}% sentiment
</text>
</motion.g>
)}
</g>
);
})}
</svg>
</div>
);
}

View File

@@ -0,0 +1,284 @@
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { GitBranch, ChevronRight } from 'lucide-react';
import type { JourneyClient } from '@/types';
import { RiverPath } from './RiverPath';
import { InspectorPanel } from './InspectorPanel';
// ─── Mock Data ─────────────────────────────────────────────────────────────────
const MOCK_CLIENTS: JourneyClient[] = [
{
id: 'c1',
name: 'Quantum Dynamics',
initials: 'QD',
stage: 'Negotiation',
overallScore: 88,
events: [
{
id: 'e1', type: 'visit', timestamp: 'Feb 10, 10:30 AM',
sentimentScore: 42, title: 'Initial Showroom Visit',
summary: 'Client toured the main lobby and reception area. Showed moderate interest in the view from upper floors.',
aiInsight: 'Client lingered 4 mins near the floor plan display — a strong signal of spatial curiosity.',
evidenceLabel: 'Lobby cam · Frame 0:04:12',
},
{
id: 'e2', type: 'call', timestamp: 'Feb 12, 2:00 PM',
sentimentScore: 58, title: 'Follow-up Call',
summary: 'Discussed pricing tiers and payment schedule. Client requested a custom ROI breakdown.',
aiInsight: 'Vocal tone analysis indicates growing confidence. Recommend sending ROI deck within 24h.',
evidenceLabel: 'Call recording · 18 min',
},
{
id: 'e3', type: 'visit', timestamp: 'Feb 14, 11:00 AM',
sentimentScore: 75, title: 'Unit 402 Tour',
summary: 'Detailed walkthrough of the penthouse unit. Client spent 5 mins examining the Italian marble flooring.',
aiInsight: 'Dwell time on premium finishes was 3× the average. High purchase intent signal.',
evidenceLabel: 'Unit 402 cam · Frame 0:05:44',
},
{
id: 'e4', type: 'negotiation', timestamp: 'Feb 17, 4:00 PM',
sentimentScore: 91, title: 'Contract Review',
summary: 'Legal team reviewed terms. Minor pushback on payment timeline. Positive overall tone.',
aiInsight: 'Sentiment peaked during the amenities discussion. Close probability: 87%.',
evidenceLabel: 'Meeting room cam · Frame 0:22:10',
},
],
},
{
id: 'c2',
name: 'Nebula Ventures',
initials: 'NV',
stage: 'Discovery',
overallScore: 45,
events: [
{
id: 'e5', type: 'chat', timestamp: 'Feb 15, 9:00 AM',
sentimentScore: 35, title: 'WhatsApp Inquiry',
summary: 'Client asked about availability and pricing for 2BR units. Seemed price-sensitive.',
aiInsight: 'Short message length and delayed replies suggest low urgency. Nurture with value content.',
evidenceLabel: 'Chat log · 6 messages',
},
{
id: 'e6', type: 'visit', timestamp: 'Feb 16, 3:00 PM',
sentimentScore: 52, title: 'Showroom Walk-in',
summary: 'Unscheduled visit. Toured the 2BR model unit. Hesitated at the pricing board.',
aiInsight: 'Micro-expressions detected: confusion near pricing. Suggest a tailored payment plan.',
evidenceLabel: 'Showroom cam · Frame 0:08:30',
},
],
},
{
id: 'c3',
name: 'Apex Industries',
initials: 'AI',
stage: 'Proposal',
overallScore: 68,
events: [
{
id: 'e7', type: 'call', timestamp: 'Feb 13, 10:00 AM',
sentimentScore: 60, title: 'Discovery Call',
summary: 'Initial qualification call. Client interested in bulk purchase for corporate housing.',
aiInsight: 'High-value opportunity. Bulk discount framing will accelerate decision.',
evidenceLabel: 'Call recording · 25 min',
},
{
id: 'e8', type: 'visit', timestamp: 'Feb 15, 2:00 PM',
sentimentScore: 78, title: 'Corporate Tour',
summary: 'Toured 3 units simultaneously with their facilities manager. Strong engagement.',
aiInsight: 'Facilities manager asked about smart home integration — a key buying signal.',
evidenceLabel: 'Floor 7 cam · Frame 0:12:05',
},
{
id: 'e9', type: 'negotiation', timestamp: 'Feb 18, 11:00 AM',
sentimentScore: 65, title: 'Proposal Presentation',
summary: 'Presented bulk pricing proposal. Legal team raised concerns about contract clauses.',
aiInsight: 'Blocker identified: legal review. Recommend offering a compliance workshop.',
evidenceLabel: 'Boardroom cam · Frame 0:31:20',
},
],
},
];
// ─── Client Card (Left Roster) ─────────────────────────────────────────────────
function ClientCard({
client,
isActive,
onClick,
}: {
client: JourneyClient;
isActive: boolean;
onClick: () => void;
}) {
const scoreColor =
client.overallScore >= 70 ? 'text-green-400' :
client.overallScore >= 40 ? 'text-amber-400' : 'text-red-400';
return (
<motion.button
onClick={onClick}
whileHover={{ x: 2 }}
className={`w-full text-left p-3 rounded-xl border transition-all relative overflow-hidden ${isActive
? 'bg-blue-500/10 border-blue-500/30'
: 'bg-white/3 border-white/5 hover:bg-white/6 hover:border-white/10'
}`}
>
{isActive && (
<motion.div
layoutId="activeClientGlow"
className="absolute inset-0 bg-blue-500/5 rounded-xl pointer-events-none"
style={{ boxShadow: 'inset 0 0 20px rgba(59,130,246,0.1)' }}
/>
)}
<div className="relative flex items-center gap-2.5">
{/* Avatar */}
<div className={`w-9 h-9 rounded-lg flex items-center justify-center text-xs font-bold flex-shrink-0 ${isActive ? 'bg-blue-500/20 text-blue-300' : 'bg-white/8 text-white/60'
}`}>
{client.initials}
</div>
<div className="flex-1 min-w-0">
<p className="text-white text-xs font-medium truncate">{client.name}</p>
<p className="text-white/40 text-[10px] truncate">{client.stage}</p>
</div>
<div className="flex flex-col items-end gap-1">
<span className={`text-xs font-bold ${scoreColor}`}>{client.overallScore}</span>
{isActive && <ChevronRight className="w-3 h-3 text-blue-400" />}
</div>
</div>
{/* Score bar */}
<div className="mt-2 h-0.5 rounded-full bg-white/5 overflow-hidden">
<motion.div
className={`h-full rounded-full ${client.overallScore >= 70 ? 'bg-green-400' :
client.overallScore >= 40 ? 'bg-amber-400' : 'bg-red-400'
}`}
initial={{ width: 0 }}
animate={{ width: `${client.overallScore}%` }}
transition={{ duration: 0.6, ease: 'easeOut' }}
/>
</div>
</motion.button>
);
}
// ─── Main JourneyRiver Component ───────────────────────────────────────────────
export function JourneyRiver() {
const [activeClientId, setActiveClientId] = useState<string>(MOCK_CLIENTS[0].id);
const [selectedEventId, setSelectedEventId] = useState<string | null>(null);
const activeClient = MOCK_CLIENTS.find(c => c.id === activeClientId) ?? MOCK_CLIENTS[0];
const selectedEvent = activeClient.events.find(e => e.id === selectedEventId) ?? null;
const handleSelectClient = (id: string) => {
setActiveClientId(id);
setSelectedEventId(null);
};
return (
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.3 }}
className="relative w-full h-full rounded-2xl overflow-hidden flex"
style={{
background: 'rgba(8, 10, 18, 0.82)',
border: '1px solid rgba(59,130,246,0.14)',
backdropFilter: 'blur(24px)',
WebkitBackdropFilter: 'blur(24px)',
boxShadow: '0 0 0 1px rgba(255,255,255,0.04), 0 4px 32px rgba(0,0,0,0.55), 0 0 60px rgba(59,130,246,0.06)',
}}
>
{/* ── Left Panel: Roster (20%) ─────────────────────────────────────── */}
<div
className="flex-shrink-0 flex flex-col border-r border-white/5"
style={{ width: '22%' }}
>
{/* Header */}
<div className="p-3 border-b border-white/5 flex-shrink-0">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-xl bg-blue-500/20 flex items-center justify-center">
<GitBranch className="w-3.5 h-3.5 text-blue-400" />
</div>
<div>
<h3 className="text-white text-sm font-medium">Journey River</h3>
<p className="text-white/40 text-[10px]">{MOCK_CLIENTS.length} clients tracked</p>
</div>
</div>
</div>
{/* Client list */}
<div className="flex-1 overflow-y-auto custom-scrollbar p-2 space-y-1.5">
{MOCK_CLIENTS.map(client => (
<ClientCard
key={client.id}
client={client}
isActive={client.id === activeClientId}
onClick={() => handleSelectClient(client.id)}
/>
))}
</div>
{/* Footer legend */}
<div className="p-3 border-t border-white/5 flex-shrink-0">
<p className="text-[9px] text-white/20 uppercase tracking-widest mb-1.5">Sentiment Axis</p>
<div className="h-3 rounded-full" style={{
background: 'linear-gradient(to right, #ef4444, #f59e0b, #22c55e)'
}} />
<div className="flex justify-between mt-1">
<span className="text-[9px] text-red-400/60">Low</span>
<span className="text-[9px] text-green-400/60">High</span>
</div>
</div>
</div>
{/* ── Center Panel: River (flex-1, dynamic) ───────────────────────── */}
<motion.div layout className="flex-1 flex flex-col min-w-0 overflow-hidden">
{/* Client header */}
<div className="px-4 py-3 border-b border-white/5 flex-shrink-0 flex items-center justify-between">
<AnimatePresence mode="wait">
<motion.div
key={activeClientId}
initial={{ opacity: 0, y: -6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 6 }}
transition={{ duration: 0.2 }}
>
<h4 className="text-white font-semibold text-sm">{activeClient.name}</h4>
<p className="text-white/40 text-[10px]">
{activeClient.events.length} events · {activeClient.stage}
</p>
</motion.div>
</AnimatePresence>
<div className="text-[10px] text-white/30 font-mono">
X = Sentiment Score
</div>
</div>
{/* River canvas */}
<div className="flex-1 overflow-hidden relative">
<AnimatePresence mode="wait">
<motion.div
key={activeClientId}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
className="absolute inset-0"
>
<RiverPath
events={activeClient.events}
onSelectEvent={setSelectedEventId}
selectedEventId={selectedEventId}
/>
</motion.div>
</AnimatePresence>
</div>
</motion.div>
{/* ── Right Panel: Inspector (0 → 30%, slide-in) ──────────────────── */}
<InspectorPanel
event={selectedEvent}
onClose={() => setSelectedEventId(null)}
/>
</motion.div>
);
}

View File

@@ -0,0 +1,30 @@
/* PropertyEntity */
.root { display: flex; flex-direction: column; height: 100%; overflow-y: auto; }
.backBtn { align-self: flex-start; background: none; border: none; color: var(--color-text-tertiary); font-size: var(--text-sm); cursor: pointer; padding: var(--space-4) var(--space-6) 0; font-family: var(--font-sans); transition: color var(--duration-fast) var(--ease-standard); }
.backBtn:hover { color: var(--color-text-primary); }
/* 3D hero */
.hero3d { position: relative; height: 360px; margin: var(--space-3) var(--space-6) 0; border-radius: var(--radius-2xl); overflow: hidden; background: var(--color-base-raised); }
.canvas { width: 100% !important; height: 100% !important; }
/* Overlay */
.heroOverlay { position: absolute; bottom: 0; left: 0; right: 0; padding: var(--space-5) var(--space-6); border-radius: 0 0 var(--radius-2xl) var(--radius-2xl); }
.propName { font-size: var(--text-2xl); font-weight: var(--font-bold); color: var(--color-text-primary); letter-spacing: var(--tracking-tight); margin: 0 0 var(--space-1); }
.propMeta { font-size: var(--text-sm); color: var(--color-text-secondary); margin: 0; }
/* Action bar */
.actionBar { display: flex; gap: var(--space-3); padding: var(--space-4) var(--space-6); }
.reimagineActive { background: color-mix(in srgb, var(--color-violet) 80%, white) !important; }
/* Reimagine slot */
.reimagineSlot { padding: 0 var(--space-6); }
.reimaginePad { padding: var(--space-5); }
/* Tabs */
.tabs { display: flex; gap: 0; border-bottom: var(--glass-border); margin: 0 var(--space-6); }
.tab { position: relative; background: none; border: none; padding: var(--space-3) var(--space-5); font-family: var(--font-sans); font-size: var(--text-sm); font-weight: var(--font-medium); color: var(--color-text-tertiary); cursor: pointer; transition: color var(--duration-fast) var(--ease-standard); }
.tabActive { color: var(--color-text-primary); }
.tabUnderline { position: absolute; bottom: -1px; left: 0; right: 0; height: 2px; background: var(--color-violet); border-radius: 1px; }
/* Content */
.overview { padding: var(--space-5) var(--space-6); display: flex; flex-direction: column; gap: var(--space-4); }
.description { font-size: var(--text-base); color: var(--color-text-secondary); line-height: var(--leading-relaxed); margin: 0; }
.amenities { display: flex; flex-wrap: wrap; gap: var(--space-2); }
.amenityChip { font-size: var(--text-xs); padding: var(--space-2) var(--space-3); border-radius: var(--radius-full); color: var(--color-text-secondary); }
/* Media */
.mediaGrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: var(--space-3); padding: var(--space-5) var(--space-6); }
.mediaImg { width: 100%; height: 140px; object-fit: cover; border-radius: var(--radius-lg); }

View File

@@ -0,0 +1,227 @@
import { Suspense, useState, useRef } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { Canvas, useFrame } from '@react-three/fiber';
import { OrbitControls, Environment, PerspectiveCamera, Html, useGLTF } from '@react-three/drei';
import { ReimaginePanel } from './ReimaginePanel';
import { useProperty } from '../../shared/hooks/useStudio';
import styles from './PropertyEntity.module.css';
/**
* PropertyEntity — The unified property page.
* Route: /studio/:propertyId
*
* Layout:
* - 3D hero viewport (React Three Fiber) — always prominent
* - Property header overlay (name, config, price) — glassmorphic
* - Action bar: [View Floorplan] [Reimagine ✨] [Share Brochure]
* - ReimaginePanel slides in below hero on "Reimagine" tap
* - Media grid below
*
* Transition into this page: scale(0.94)→scale(1) from Studio
* (handled by AuthenticatedShell depth choreography)
*/
type Tab = 'overview' | 'media';
export default function PropertyEntity() {
const { propertyId } = useParams<{ propertyId: string }>();
const navigate = useNavigate();
const [showReimagine, setShowReimagine] = useState(false);
const [activeTab, setActiveTab] = useState<Tab>('overview');
const { property, isLoading } = useProperty(propertyId!);
if (isLoading || !property) return <PropertySkeleton />;
return (
<div className={styles.root}>
{/* Back */}
<button className={styles.backBtn} onClick={() => navigate('/studio')}>
Studio
</button>
{/* ── 3D Hero Viewport ────────────────────────────── */}
<div className={styles.hero3d}>
<Canvas
className={styles.canvas}
gl={{ antialias: true, alpha: true }}
dpr={[1, 2]}
>
<PerspectiveCamera makeDefault position={[0, 1.5, 5]} fov={55} />
<ambientLight intensity={0.6} />
<directionalLight position={[5, 8, 5]} intensity={1.2} castShadow />
<Suspense fallback={<Html center><span style={{ color: 'white' }}>Loading model</span></Html>}>
{property.modelUrl
? <PropertyModel url={property.modelUrl} />
: <PlaceholderBuilding />
}
<Environment preset="city" />
</Suspense>
<OrbitControls
enablePan={false}
enableZoom={true}
minDistance={3}
maxDistance={12}
maxPolarAngle={Math.PI / 2}
/>
</Canvas>
{/* Glassmorphic overlay on hero */}
<div className={`${styles.heroOverlay} glass`}>
<h1 className={styles.propName}>{property.name}</h1>
<p className={styles.propMeta}>
{property.config} · {property.area} · {property.price}
</p>
</div>
</div>
{/* ── Action bar ──────────────────────────────────── */}
<div className={styles.actionBar}>
<button className="btn-ghost">📐 Floorplan</button>
<button
className={`btn-primary ${showReimagine ? styles.reimagineActive : ''}`}
onClick={() => setShowReimagine(v => !v)}
>
Reimagine
</button>
<button className="btn-ghost"
onClick={() => {/* vault link generation */}}>
🔗 Share
</button>
</div>
{/* ── ReimaginePanel — slides in below hero (no nav) ─ */}
<AnimatePresence>
{showReimagine && (
<motion.div
className={styles.reimagineSlot}
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.3, ease: [0.4, 0, 0.2, 1] }}
style={{ overflow: 'hidden' }}
>
<div className={`${styles.reimaginePad} glass-card`}>
<ReimaginePanel
propertyId={propertyId!}
roomImageUrl={property.interiorImageUrl}
onResultSaved={() => setShowReimagine(false)}
/>
</div>
</motion.div>
)}
</AnimatePresence>
{/* ── Tab nav ─────────────────────────────────────── */}
<div className={styles.tabs}>
{(['overview', 'media'] as Tab[]).map(tab => (
<button
key={tab}
className={`${styles.tab} ${activeTab === tab ? styles.tabActive : ''}`}
onClick={() => setActiveTab(tab)}
>
{tab === 'overview' ? 'Overview' : 'Media Gallery'}
{activeTab === tab && (
<motion.div layoutId="prop-tab-underline" className={styles.tabUnderline}
transition={{ type: 'spring', stiffness: 500, damping: 40 }} />
)}
</button>
))}
</div>
{/* ── Tab content ─────────────────────────────────── */}
<AnimatePresence mode="wait">
<motion.div
key={activeTab}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
{activeTab === 'overview' && (
<div className={styles.overview}>
{property.description && (
<p className={styles.description}>{property.description}</p>
)}
{property.amenities && (
<div className={styles.amenities}>
{property.amenities.map((a, i) => (
<span key={i} className={`${styles.amenityChip} glass`}>{a}</span>
))}
</div>
)}
</div>
)}
{activeTab === 'media' && (
<div className={styles.mediaGrid}>
{(property.images ?? []).map((img, i) => (
<motion.img
key={i}
src={img}
className={styles.mediaImg}
initial={{ opacity: 0, scale: 0.96 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: i * 0.06 }}
/>
))}
</div>
)}
</motion.div>
</AnimatePresence>
</div>
);
}
// ── 3D Model component (R3F) ──────────────────────────────────
function PropertyModel({ url }: { url: string }) {
const { scene } = useGLTF(url);
const meshRef = useRef<any>();
useFrame((_, delta) => {
if (meshRef.current) {
meshRef.current.rotation.y += delta * 0.05;
}
});
return <primitive ref={meshRef} object={scene} scale={1} />;
}
// Placeholder building geometry when no GLB model available
function PlaceholderBuilding() {
const ref = useRef<any>();
useFrame((_, delta) => {
if (ref.current) ref.current.rotation.y += delta * 0.1;
});
return (
<group ref={ref}>
<mesh position={[0, 0.75, 0]}>
<boxGeometry args={[1.5, 1.5, 1.5]} />
<meshStandardMaterial color="#7C3AED" roughness={0.3} metalness={0.4} />
</mesh>
<mesh position={[0, 2.5, 0]}>
<boxGeometry args={[0.9, 1.5, 0.9]} />
<meshStandardMaterial color="#A78BFA" roughness={0.2} metalness={0.5} />
</mesh>
<mesh position={[0, 3.8, 0]}>
<boxGeometry args={[0.5, 0.6, 0.5]} />
<meshStandardMaterial color="#C4B5FD" roughness={0.1} metalness={0.6} />
</mesh>
</group>
);
}
function PropertySkeleton() {
return (
<div className={styles.root}>
<div className={`${styles.hero3d} shimmer`} style={{ height: 360 }} />
<div style={{ padding: 'var(--space-6)', display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
{[0,1].map(i => (
<div key={i} className="shimmer" style={{ height: 64, borderRadius: 'var(--radius-lg)', background: 'var(--glass-bg)' }} />
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,22 @@
/* ReimaginePanel */
.root { display: flex; flex-direction: column; gap: var(--space-5); }
.reimagineBtn { align-self: flex-start; }
.presetsSection { display: flex; flex-direction: column; gap: var(--space-4); }
.presetGrid { display: flex; gap: var(--space-3); }
.presetTile { position: relative; display: flex; flex-direction: column; align-items: center; gap: var(--space-2); background: var(--glass-bg); border: var(--glass-border); border-radius: var(--radius-lg); padding: var(--space-3); cursor: pointer; transition: all var(--duration-fast) var(--ease-standard); width: 120px; flex-shrink: 0; }
.presetTile:hover { background: var(--glass-bg-hover); }
.selected { border-color: var(--color-violet) !important; }
.previewImg { width: 100%; height: 72px; border-radius: var(--radius-md); overflow: hidden; background: var(--glass-bg); }
.previewImg img { width: 100%; height: 100%; object-fit: cover; }
.presetLabel { font-size: 10px; font-weight: var(--font-medium); color: var(--color-text-secondary); text-align: center; }
.selectionRing { position: absolute; inset: -2px; border-radius: var(--radius-lg); border: 2px solid var(--color-violet); pointer-events: none; box-shadow: var(--glass-shadow-violet); }
.generateBtn { align-self: flex-start; }
/* Generating */
.generatingState { display: flex; flex-direction: column; gap: var(--space-4); }
.imageFrame { height: 200px; border-radius: var(--radius-xl); }
.generatingText { font-size: var(--text-sm); color: var(--color-text-secondary); margin: 0; }
/* Result */
.resultState { display: flex; flex-direction: column; gap: var(--space-4); }
.resultImage { border-radius: var(--radius-xl); overflow: hidden; }
.resultImage img { width: 100%; height: 200px; object-fit: cover; }
.resultActions { display: flex; gap: var(--space-3); }

View File

@@ -0,0 +1,236 @@
import { useState, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import styles from './ReimaginePanel.module.css';
/**
* ReimaginePanel
* One-tap AI room staging. Replaces the raw Dream Weaver tab.
* UX Master Plan §4.4 + Part 2 §2.4 spec:
*
* - Tap "Reimagine" → 3 style tiles stagger-reveal (50ms intervals)
* - Tiles show AI-representative preview images, not labels
* - Select style → "Generate" materializes with spring
* - Generation → property view blurs + shimmer sweeps
* - Result → cross-fade, two actions: Use This / Try Another
*
* All ComfyUI machinery hidden. No job IDs, no queue position.
* User sees: "Reimagining your space…"
*/
type StylePreset = 'modern-luxury' | 'warm-contemporary' | 'minimalist-zen';
type Phase = 'idle' | 'selecting' | 'generating' | 'result';
interface ReimagineResult {
imageUrl: string;
jobId: string; // internal only, never shown
}
interface ReimaginePanelProps {
propertyId: string;
roomImageUrl?: string; // Source room photo
onResultSaved?: (url: string) => void;
}
const STYLE_PRESETS: { id: StylePreset; label: string; previewUrl: string }[] = [
{
id: 'modern-luxury',
label: 'Modern Luxury',
// Curated representative preview images
previewUrl: '/assets/style-previews/modern-luxury.jpg',
},
{
id: 'warm-contemporary',
label: 'Warm Contemporary',
previewUrl: '/assets/style-previews/warm-contemporary.jpg',
},
{
id: 'minimalist-zen',
label: 'Minimalist Zen',
previewUrl: '/assets/style-previews/minimalist-zen.jpg',
},
];
export function ReimaginePanel({
propertyId,
roomImageUrl,
onResultSaved,
}: ReimaginePanelProps) {
const [phase, setPhase] = useState<Phase>('idle');
const [selectedStyle, setSelectedStyle] = useState<StylePreset | null>(null);
const [result, setResult] = useState<ReimagineResult | null>(null);
const handleReimagineClick = () => setPhase('selecting');
const handleStyleSelect = (style: StylePreset) => {
setSelectedStyle(style);
};
const handleGenerate = useCallback(async () => {
if (!selectedStyle) return;
setPhase('generating');
try {
// POST to Dream Weaver gateway (hidden from user)
const resp = await fetch('/dream-weaver', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
property_id: propertyId,
style_preset: selectedStyle,
source_image_url: roomImageUrl,
}),
});
const { job_id } = await resp.json();
// Poll silently (user sees shimmer, not polling)
const imageUrl = await pollForResult(job_id);
setResult({ imageUrl, jobId: job_id });
setPhase('result');
} catch {
setPhase('selecting');
}
}, [selectedStyle, propertyId, roomImageUrl]);
const handleUseThis = async () => {
if (!result) return;
// Save to property media library + generate vault link (handled by Studio)
onResultSaved?.(result.imageUrl);
setPhase('idle');
setResult(null);
setSelectedStyle(null);
};
const handleTryAnother = () => {
setPhase('selecting');
setResult(null);
setSelectedStyle(null);
};
return (
<div className={styles.root}>
{/* ── Idle: single button ─────────────────────────── */}
{phase === 'idle' && (
<motion.button
className={`btn-primary ${styles.reimagineBtn}`}
onClick={handleReimagineClick}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
Reimagine
</motion.button>
)}
{/* ── Selecting: staggered style tiles ─────────────── */}
{(phase === 'selecting' || phase === 'result') && (
<div className={styles.presetsSection}>
<div className={styles.presetGrid}>
{STYLE_PRESETS.map(({ id, label, previewUrl }, idx) => (
<motion.button
key={id}
className={`${styles.presetTile} ${selectedStyle === id ? styles.selected : ''}`}
onClick={() => handleStyleSelect(id)}
initial={{ opacity: 0, scale: 0.92 }}
animate={{
opacity: selectedStyle && selectedStyle !== id ? 0.4 : 1,
scale: selectedStyle === id ? 1.04 : 1,
}}
transition={{
delay: idx * 0.05,
duration: 0.25,
ease: [0.4, 0, 0.2, 1],
}}
aria-pressed={selectedStyle === id}
aria-label={`Style: ${label}`}
>
<div className={styles.previewImg}>
<img src={previewUrl} alt={label} />
</div>
<span className={styles.presetLabel}>{label}</span>
{/* Active glow border */}
{selectedStyle === id && (
<motion.div
layoutId="preset-selection"
className={styles.selectionRing}
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
/>
)}
</motion.button>
))}
</div>
{/* Generate button materializes when style selected */}
<AnimatePresence>
{selectedStyle && phase !== 'result' && (
<motion.button
className={`btn-primary ${styles.generateBtn}`}
onClick={handleGenerate}
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0, opacity: 0 }}
transition={{ type: 'spring', stiffness: 380, damping: 25 }}
>
Generate
</motion.button>
)}
</AnimatePresence>
</div>
)}
{/* ── Generating: shimmer overlay ──────────────────── */}
{phase === 'generating' && (
<motion.div
className={styles.generatingState}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
>
<div className={`${styles.imageFrame} shimmer`} />
<motion.p
className={styles.generatingText}
animate={{ opacity: [0.4, 0.7, 0.4] }}
transition={{ duration: 2, repeat: Infinity }}
>
Reimagining your space
</motion.p>
</motion.div>
)}
{/* ── Result: cross-fade to generated image ─────────── */}
{phase === 'result' && result && (
<motion.div
className={styles.resultState}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.6, ease: [0.4, 0, 0.2, 1] }}
>
<div className={styles.resultImage}>
<img src={result.imageUrl} alt="AI-generated staging" />
</div>
<div className={styles.resultActions}>
<button className="btn-primary" onClick={handleUseThis}>
Use This
</button>
<button className="btn-ghost" onClick={handleTryAnother}>
Try Another Style
</button>
</div>
</motion.div>
)}
</div>
);
}
// ── Internal: silent polling (hidden from user) ───────────────
async function pollForResult(jobId: string): Promise<string> {
const MAX_ATTEMPTS = 60;
const INTERVAL_MS = 5000;
for (let i = 0; i < MAX_ATTEMPTS; i++) {
await new Promise(r => setTimeout(r, INTERVAL_MS));
const resp = await fetch(`/dream-weaver/status/${jobId}`);
const { status, result_url } = await resp.json();
if (status === 'complete' && result_url) return result_url;
if (status === 'failed') throw new Error('Generation failed');
}
throw new Error('Generation timed out');
}

View File

@@ -0,0 +1,22 @@
/* StudioPillar */
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
.header { display: flex; align-items: center; justify-content: space-between; padding: var(--space-6) var(--space-8) var(--space-4); flex-shrink: 0; }
.title { font-size: var(--text-2xl); font-weight: var(--font-bold); color: var(--color-text-primary); margin: 0; letter-spacing: var(--tracking-tight); }
.subtitle { font-size: var(--text-sm); color: var(--color-text-tertiary); margin: 0; }
.tabs { display: flex; gap: var(--space-1); background: var(--glass-bg); border: var(--glass-border); border-radius: var(--radius-lg); padding: var(--space-1); }
.tab, .tabActive { padding: var(--space-2) var(--space-4); border-radius: var(--radius-md); font-size: var(--text-sm); font-weight: var(--font-medium); border: none; cursor: pointer; transition: all var(--duration-fast) var(--ease-standard); font-family: var(--font-sans); }
.tab { background: none; color: var(--color-text-tertiary); }
.tabActive { background: var(--color-violet); color: white; }
/* Grid */
.propGrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: var(--space-4); padding: 0 var(--space-8) var(--space-8); overflow-y: auto; flex: 1; }
.propSkeleton { height: 200px; border-radius: var(--radius-xl); }
.propCard { display: flex; flex-direction: column; gap: var(--space-3); text-align: left; cursor: pointer; }
.propThumb { position: relative; height: 130px; border-radius: var(--radius-lg); overflow: hidden; background: var(--glass-bg); }
.propThumb img { width: 100%; height: 100%; object-fit: cover; }
.thumbPlaceholder { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; font-size: 36px; }
.availBadge { position: absolute; bottom: var(--space-2); right: var(--space-2); font-size: 10px; background: rgba(16,185,129,0.2); color: var(--color-green); padding: 2px var(--space-2); border-radius: var(--radius-full); border: 1px solid rgba(16,185,129,0.3); }
.propMeta { display: flex; flex-direction: column; gap: 2px; }
.propName { font-size: var(--text-sm); font-weight: var(--font-semibold); color: var(--color-text-primary); }
.propSub { font-size: 10px; color: var(--color-text-tertiary); }
.propPrice { font-size: var(--text-xs); color: var(--color-violet-light); font-weight: var(--font-medium); }
.campaignsPlaceholder { padding: var(--space-8); }

View File

@@ -0,0 +1,103 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { motion } from 'framer-motion';
import { useStudioProperties } from '../../shared/hooks/useStudio';
import styles from './StudioPillar.module.css';
/**
* StudioPillar — Pillar 3: Asset & Marketing Hub
* Merges: Inventory + Catalyst
* Two sections: Properties | Campaigns
*/
export default function StudioPillar() {
const [section, setSection] = useState<'properties' | 'campaigns'>('properties');
return (
<div className={styles.root}>
<div className={styles.header}>
<div>
<h1 className={styles.title}>Studio</h1>
<p className={styles.subtitle}>Asset & Marketing Hub</p>
</div>
<div className={styles.tabs}>
<button
className={section === 'properties' ? styles.tabActive : styles.tab}
onClick={() => setSection('properties')}
>Properties</button>
<button
className={section === 'campaigns' ? styles.tabActive : styles.tab}
onClick={() => setSection('campaigns')}
>Campaigns</button>
</div>
</div>
{section === 'properties' && <PropertiesSection />}
{section === 'campaigns' && <CampaignsSection />}
</div>
);
}
// ── Properties Section ────────────────────────────────────────
function PropertiesSection() {
const navigate = useNavigate();
const { properties, isLoading } = useStudioProperties();
if (isLoading) {
return (
<div className={styles.propGrid}>
{[0,1,2,3,4,5].map(i => (
<div key={i} className={`${styles.propSkeleton} shimmer`} />
))}
</div>
);
}
return (
<div className={styles.propGrid}>
{properties.map((prop, i) => (
<motion.button
key={prop.id}
className={`${styles.propCard} glass-card`}
onClick={() => navigate(`/studio/${prop.id}`)}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: i * 0.05, duration: 0.3 }}
whileHover={{ scale: 1.03, y: -4 }}
whileTap={{ scale: 0.99 }}
aria-label={`View ${prop.name}`}
>
{/* Property thumbnail */}
<div className={styles.propThumb}>
{prop.thumbnailUrl
? <img src={prop.thumbnailUrl} alt={prop.name} />
: <div className={styles.thumbPlaceholder}>🏗</div>
}
{prop.availableUnits !== undefined && (
<span className={styles.availBadge}>
{prop.availableUnits} available
</span>
)}
</div>
<div className={styles.propMeta}>
<span className={styles.propName}>{prop.name}</span>
<span className={styles.propSub}>{prop.location}</span>
{prop.priceRange && (
<span className={styles.propPrice}>{prop.priceRange}</span>
)}
</div>
</motion.button>
))}
</div>
);
}
// ── Campaigns Section ─────────────────────────────────────────
function CampaignsSection() {
return (
<div className={styles.campaignsPlaceholder}>
<p style={{ color: 'var(--color-text-secondary)', fontSize: 'var(--text-sm)' }}>
Campaigns loading Meta Ads integration active.
</p>
</div>
);
}

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 };
}

View File

@@ -0,0 +1,26 @@
import { Navigate } from 'react-router-dom';
import { useAuthStore } from '../../store/authStore';
/**
* AdminGuard
* Route-level RBAC gate for the Control Room.
* Redirects non-admin users to /command silently.
* Defense-in-depth: API also enforces ADMIN role via middleware.
*/
export function AdminGuard({ children }: { children: React.ReactNode }) {
const { user, isAuthenticated } = useAuthStore();
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
const isAdmin =
user?.role === 'ADMIN' || user?.role === 'SALES_DIRECTOR';
if (!isAdmin) {
// Silent redirect — no error screen (security through obscurity)
return <Navigate to="/command" replace />;
}
return <>{children}</>;
}

View File

@@ -0,0 +1,17 @@
import { Navigate } from 'react-router-dom';
import { useAuthStore } from '../../store/authStore';
/**
* AuthGuard
* Protects all authenticated routes.
* Redirects unauthenticated users to /login.
*/
export function AuthGuard({ children }: { children: React.ReactNode }) {
const { isAuthenticated } = useAuthStore();
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
}

View File

@@ -0,0 +1,21 @@
/* AuthenticatedShell */
.shell {
display: flex;
height: 100vh;
overflow: hidden;
background: var(--color-base-bg);
}
.main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
}
.page {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
scrollbar-color: rgba(255,255,255,0.1) transparent;
}

View File

@@ -0,0 +1,83 @@
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useEffect, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { NavRail } from './NavRail';
import { IntelligenceRail } from '../../intelligence-rail/IntelligenceRail';
import { SentinelAlertBanner } from '../../intelligence-rail/SentinelAlertBanner';
import styles from './AuthenticatedShell.module.css';
/**
* AuthenticatedShell
* The persistent chrome for all authenticated broker views.
* Contains: NavRail (left) | Main content area | IntelligenceRail (right)
* SentinelAlertBanner slides in from top on CCTV events.
*/
export function AuthenticatedShell() {
const location = useLocation();
const prevPath = useRef(location.pathname);
// Determine spatial direction for pillar transitions
const getPillarIndex = (path: string): number => {
if (path.startsWith('/command')) return 0;
if (path.startsWith('/pipeline')) return 1;
if (path.startsWith('/studio')) return 2;
return -1;
};
const prevIdx = getPillarIndex(prevPath.current);
const currIdx = getPillarIndex(location.pathname);
const isDrillIn = currIdx === -1; // non-pillar route = drill-in
// Directional slide variants
const direction = currIdx > prevIdx ? 1 : -1;
const variants = isDrillIn
? {
// Drill into entity: scale + fade
initial: { opacity: 0, scale: 0.96 },
animate: { opacity: 1, scale: 1 },
exit: { opacity: 0.6, scale: 0.97 },
}
: {
// Pillar-to-pillar: directional slide
initial: { opacity: 0, x: direction * 60 },
animate: { opacity: 1, x: 0 },
exit: { opacity: 0, x: direction * -40 },
};
useEffect(() => {
prevPath.current = location.pathname;
}, [location.pathname]);
return (
<div className={styles.shell}>
{/* Left: Navigation rail (3 pillars + control-room gear) */}
<NavRail />
{/* Center: Main content with transition choreography */}
<main className={styles.main}>
{/* Showroom alert banner — slides from top, non-blocking */}
<SentinelAlertBanner />
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={location.pathname}
className={styles.page}
variants={variants}
initial="initial"
animate="animate"
exit="exit"
transition={{
duration: 0.3,
ease: [0.4, 0, 0.2, 1],
}}
>
<Outlet />
</motion.div>
</AnimatePresence>
</main>
{/* Right: Persistent Intelligence Rail */}
<IntelligenceRail />
</div>
);
}

View File

@@ -0,0 +1,132 @@
/* LoginPage styles */
.root {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-base-bg);
position: relative;
overflow: hidden;
}
/* Ambient gradient blob */
.bg {
position: absolute;
inset: 0;
background:
radial-gradient(ellipse 60% 50% at 30% 40%, var(--color-violet-glow) 0%, transparent 70%),
radial-gradient(ellipse 40% 40% at 70% 70%, var(--color-amber-glow) 0%, transparent 70%);
pointer-events: none;
}
.card {
width: 100%;
max-width: 420px;
padding: var(--space-10) var(--space-8);
position: relative;
z-index: 1;
}
.logo {
display: flex;
align-items: center;
gap: var(--space-3);
margin-bottom: var(--space-8);
}
.logoMark {
width: 36px;
height: 36px;
border-radius: var(--radius-md);
background: var(--color-violet);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: var(--font-bold);
font-size: var(--text-base);
box-shadow: var(--glass-shadow-violet);
}
.logoLabel {
font-size: var(--text-lg);
font-weight: var(--font-semibold);
color: var(--color-text-primary);
letter-spacing: var(--tracking-tight);
}
.title {
font-size: var(--text-3xl);
font-weight: var(--font-bold);
color: var(--color-text-primary);
letter-spacing: var(--tracking-tight);
margin: 0 0 var(--space-2);
}
.subtitle {
font-size: var(--text-sm);
color: var(--color-text-secondary);
margin: 0 0 var(--space-8);
}
.form {
display: flex;
flex-direction: column;
gap: var(--space-5);
}
.field {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.label {
font-size: var(--text-xs);
font-weight: var(--font-semibold);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--color-text-secondary);
}
.input {
background: var(--glass-bg);
border: var(--glass-border);
border-radius: var(--radius-lg);
padding: var(--space-3) var(--space-4);
font-family: var(--font-sans);
font-size: var(--text-base);
color: var(--color-text-primary);
outline: none;
transition: border-color var(--duration-fast) var(--ease-standard),
box-shadow var(--duration-fast) var(--ease-standard);
width: 100%;
box-sizing: border-box;
}
.input:focus {
border-color: rgba(124, 58, 237, 0.50);
box-shadow: 0 0 0 3px var(--color-violet-glow);
}
.input::placeholder { color: var(--color-text-tertiary); }
.error {
font-size: var(--text-sm);
color: var(--color-red);
margin: 0;
}
.submit {
width: 100%;
justify-content: center;
padding: var(--space-4);
font-size: var(--text-base);
margin-top: var(--space-2);
}
.submit:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none !important;
}

View File

@@ -0,0 +1,114 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { motion } from 'framer-motion';
import { useAuthStore } from '../../store/authStore';
import { api } from '../lib/apiClient';
import styles from './LoginPage.module.css';
/**
* LoginPage
* Clean, branded login. Email + password → JWT → AuthStore → /command.
* No "register" link. No "forgot password" noise. Single intent.
*/
export function LoginPage() {
const navigate = useNavigate();
const { setSession } = useAuthStore();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!email || !password) return;
setLoading(true);
setError('');
try {
const { access_token, user } = await api.post<{ access_token: string; user: any }>(
'/auth/login',
{ email, password }
);
setSession(user, access_token);
navigate('/command', { replace: true });
} catch (err: any) {
setError('Invalid credentials. Please try again.');
} finally {
setLoading(false);
}
};
return (
<div className={styles.root}>
{/* Ambient gradient background */}
<div className={styles.bg} aria-hidden />
<motion.div
className={`${styles.card} glass-heavy`}
initial={{ opacity: 0, y: 24, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ duration: 0.45, ease: [0.4, 0, 0.2, 1] }}
>
{/* Logomark */}
<div className={styles.logo}>
<div className={styles.logoMark}>V</div>
<span className={styles.logoLabel}>Velocity-OS</span>
</div>
<h1 className={styles.title}>Welcome back.</h1>
<p className={styles.subtitle}>Sign in to your workspace.</p>
<form className={styles.form} onSubmit={handleSubmit} noValidate>
<div className={styles.field}>
<label className={styles.label} htmlFor="email">Email</label>
<input
id="email"
type="email"
className={styles.input}
value={email}
onChange={e => setEmail(e.target.value)}
placeholder="you@desineuron.in"
autoComplete="email"
disabled={loading}
required
/>
</div>
<div className={styles.field}>
<label className={styles.label} htmlFor="password">Password</label>
<input
id="password"
type="password"
className={styles.input}
value={password}
onChange={e => setPassword(e.target.value)}
placeholder="••••••••"
autoComplete="current-password"
disabled={loading}
required
/>
</div>
{error && (
<motion.p
className={styles.error}
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
>
{error}
</motion.p>
)}
<motion.button
type="submit"
className={`btn-primary ${styles.submit}`}
disabled={loading || !email || !password}
whileTap={{ scale: 0.98 }}
>
{loading ? 'Signing in…' : 'Sign In →'}
</motion.button>
</form>
</motion.div>
</div>
);
}

View File

@@ -0,0 +1,66 @@
/* NavRail */
.rail {
width: var(--nav-rail-width);
height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
padding: var(--space-4) 0;
background: var(--color-base-surface);
border-right: var(--glass-border);
z-index: var(--z-nav);
flex-shrink: 0;
}
.brand { padding: var(--space-2) 0 var(--space-4); }
.pillars { display: flex; flex-direction: column; gap: var(--space-1); flex: 1; width: 100%; align-items: center; }
.pillarLink {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
width: 44px;
padding: var(--space-2) 0;
color: var(--color-text-tertiary);
text-decoration: none;
border-radius: var(--radius-lg);
transition: color var(--duration-fast) var(--ease-standard);
}
.pillarLink:hover { color: var(--color-text-secondary); background: var(--glass-bg); }
.active { color: var(--color-violet-light) !important; }
.activePill {
position: absolute;
inset: 0;
background: rgba(124, 58, 237, 0.15);
border-radius: var(--radius-lg);
border: 1px solid rgba(124, 58, 237, 0.25);
}
.iconWrap { position: relative; z-index: 1; }
.labelWrap { display: flex; flex-direction: column; align-items: center; z-index: 1; }
.label { font-size: 9px; font-weight: var(--font-semibold); letter-spacing: 0.04em; }
.sublabel { font-size: 7px; opacity: 0.6; display: none; }
.bottom { display: flex; flex-direction: column; align-items: center; gap: var(--space-3); padding-bottom: var(--space-2); }
.adminLink {
width: 36px; height: 36px;
display: flex; align-items: center; justify-content: center;
border-radius: var(--radius-md);
color: var(--color-text-tertiary);
background: transparent;
border: none; cursor: pointer;
transition: all var(--duration-fast) var(--ease-standard);
}
.adminLink:hover { color: var(--color-text-secondary); background: var(--glass-bg); }
.avatarBtn {
width: 32px; height: 32px;
border-radius: var(--radius-full);
background: var(--color-violet-glow);
border: 2px solid var(--color-violet);
cursor: pointer;
display: flex; align-items: center; justify-content: center;
color: var(--color-text-primary);
font-size: var(--text-sm);
font-weight: var(--font-bold);
overflow: hidden;
}
.avatarBtn img { width: 100%; height: 100%; object-fit: cover; }

View File

@@ -0,0 +1,188 @@
import { NavLink, useNavigate } from 'react-router-dom';
import { motion } from 'framer-motion';
import { useAuthStore } from '../../store/authStore';
import styles from './NavRail.module.css';
/**
* NavRail
* The three-pillar navigation rail.
* Spatial model: Command (top) → Pipeline (mid) → Studio (bottom).
* Control Room gear icon at very bottom for admins only.
* Active indicator: animated pill that slides between positions.
*/
const PILLARS = [
{
path: '/command',
label: 'Command',
sublabel: 'Morning Briefing',
icon: CommandIcon,
},
{
path: '/pipeline',
label: 'Pipeline',
sublabel: 'Deal Intelligence',
icon: PipelineIcon,
},
{
path: '/studio',
label: 'Studio',
sublabel: 'Asset Hub',
icon: StudioIcon,
},
] as const;
export function NavRail() {
const { user } = useAuthStore();
const isAdmin = user?.role === 'ADMIN' || user?.role === 'SALES_DIRECTOR';
return (
<nav className={styles.rail} aria-label="Main navigation">
{/* Brand mark */}
<div className={styles.brand}>
<VelocityLogomark />
</div>
{/* Pillar links */}
<div className={styles.pillars}>
{PILLARS.map(({ path, label, sublabel, icon: Icon }) => (
<NavLink
key={path}
to={path}
className={({ isActive }) =>
`${styles.pillarLink} ${isActive ? styles.active : ''}`
}
aria-label={`${label}: ${sublabel}`}
>
{({ isActive }) => (
<>
{isActive && (
<motion.div
layoutId="nav-active-pill"
className={styles.activePill}
transition={{ type: 'spring', stiffness: 400, damping: 35 }}
/>
)}
<span className={styles.iconWrap}>
<Icon active={isActive} />
</span>
<span className={styles.labelWrap}>
<span className={styles.label}>{label}</span>
<span className={styles.sublabel}>{sublabel}</span>
</span>
</>
)}
</NavLink>
))}
</div>
{/* Bottom: User avatar + Admin gear */}
<div className={styles.bottom}>
{isAdmin && (
<NavLink
to="/control-room"
className={styles.adminLink}
aria-label="Control Room (Admin)"
title="Control Room"
>
<GearIcon />
</NavLink>
)}
<UserAvatar user={user} />
</div>
</nav>
);
}
// ── Icon components ───────────────────────────────────────────
function CommandIcon({ active }: { active: boolean }) {
return (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path
d="M3 5h14M3 10h14M3 15h8"
stroke={active ? 'var(--color-violet-light)' : 'currentColor'}
strokeWidth="1.5"
strokeLinecap="round"
/>
<circle
cx="16"
cy="15"
r="2"
fill={active ? 'var(--color-violet)' : 'transparent'}
stroke={active ? 'var(--color-violet-light)' : 'currentColor'}
strokeWidth="1.5"
/>
</svg>
);
}
function PipelineIcon({ active }: { active: boolean }) {
const c = active ? 'var(--color-violet-light)' : 'currentColor';
return (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<rect x="2" y="4" width="4" height="12" rx="1"
fill={active ? 'var(--color-violet-glow)' : 'transparent'}
stroke={c} strokeWidth="1.5" />
<rect x="8" y="7" width="4" height="9" rx="1"
fill={active ? 'var(--color-violet-glow)' : 'transparent'}
stroke={c} strokeWidth="1.5" />
<rect x="14" y="2" width="4" height="14" rx="1"
fill={active ? 'var(--color-violet)' : 'transparent'}
stroke={c} strokeWidth="1.5" />
</svg>
);
}
function StudioIcon({ active }: { active: boolean }) {
const c = active ? 'var(--color-violet-light)' : 'currentColor';
return (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<rect x="2" y="2" width="7" height="7" rx="1.5" stroke={c} strokeWidth="1.5" />
<rect x="11" y="2" width="7" height="7" rx="1.5"
fill={active ? 'var(--color-violet)' : 'transparent'}
stroke={c} strokeWidth="1.5" />
<rect x="2" y="11" width="7" height="7" rx="1.5" stroke={c} strokeWidth="1.5" />
<rect x="11" y="11" width="7" height="7" rx="1.5" stroke={c} strokeWidth="1.5" />
</svg>
);
}
function GearIcon() {
return (
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
<circle cx="9" cy="9" r="2.5" stroke="currentColor" strokeWidth="1.5" />
<path
d="M9 1.5v2M9 14.5v2M1.5 9h2M14.5 9h2M3.6 3.6l1.4 1.4M13 13l1.4 1.4M3.6 14.4l1.4-1.4M13 5l1.4-1.4"
stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"
/>
</svg>
);
}
function VelocityLogomark() {
return (
<div style={{
width: 32, height: 32,
borderRadius: 'var(--radius-md)',
background: 'var(--color-violet)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: 'var(--glass-shadow-violet)',
}}>
<span style={{ color: 'white', fontWeight: 700, fontSize: 14 }}>V</span>
</div>
);
}
function UserAvatar({ user }: { user: any }) {
const initials = user
? (user.name ?? user.email ?? '?').slice(0, 1).toUpperCase()
: '?';
return (
<button className={styles.avatarBtn} aria-label="User profile" title={user?.email}>
{user?.avatar_url
? <img src={user.avatar_url} alt={initials} />
: <span>{initials}</span>
}
</button>
);
}

View File

@@ -0,0 +1,62 @@
import { motion } from 'framer-motion';
/**
* PillarSkeleton
* Loading state shown by React Suspense while lazy pillar components load.
* Matches the approximate layout of each pillar to avoid layout shift.
*/
export function PillarSkeleton() {
return (
<motion.div
style={{
padding: 'var(--space-8)',
display: 'flex',
flexDirection: 'column',
gap: 'var(--space-5)',
}}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2 }}
>
{/* Page title skeleton */}
<div
className="shimmer"
style={{
height: 36,
width: 200,
borderRadius: 'var(--radius-md)',
background: 'var(--glass-bg)',
}}
/>
{/* Card row skeleton */}
<div style={{ display: 'flex', gap: 'var(--space-4)' }}>
{[0, 1, 2].map(i => (
<div
key={i}
className="shimmer"
style={{
flex: 1,
height: 96,
borderRadius: 'var(--radius-xl)',
background: 'var(--glass-bg)',
}}
/>
))}
</div>
{/* Content area skeleton */}
{[0, 1, 2].map(i => (
<div
key={i}
className="shimmer"
style={{
height: 80,
borderRadius: 'var(--radius-lg)',
background: 'var(--glass-bg)',
}}
/>
))}
</motion.div>
);
}

View File

@@ -0,0 +1,71 @@
import { useParams } from 'react-router-dom';
import { motion } from 'framer-motion';
/**
* VaultPublicPage
* Public-facing brochure link — no auth required.
* Accessed via: /vault/:trackingHash
* Renders the property brochure in a clean, branded experience.
* Tracks open event server-side via the hash; broker is notified via Sentinel.
*/
export default function VaultPublicPage() {
const { trackingHash } = useParams<{ trackingHash: string }>();
return (
<div style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'var(--color-base-bg)',
flexDirection: 'column',
gap: 'var(--space-6)',
padding: 'var(--space-8)',
}}>
<motion.div
className="glass-heavy"
style={{
maxWidth: 480,
width: '100%',
padding: 'var(--space-10)',
borderRadius: 'var(--radius-2xl)',
textAlign: 'center',
}}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
<div style={{
width: 48, height: 48,
borderRadius: 'var(--radius-md)',
background: 'var(--color-violet)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
margin: '0 auto var(--space-6)',
boxShadow: 'var(--glass-shadow-violet)',
}}>
<span style={{ color: 'white', fontWeight: 700, fontSize: 20 }}>V</span>
</div>
<h1 style={{
fontSize: 'var(--text-2xl)',
fontWeight: 'var(--font-bold)',
color: 'var(--color-text-primary)',
margin: '0 0 var(--space-3)',
}}>
Property Brochure
</h1>
<p style={{
fontSize: 'var(--text-sm)',
color: 'var(--color-text-secondary)',
margin: '0 0 var(--space-6)',
}}>
Loading your exclusive property details
</p>
<div className="shimmer" style={{
height: 200,
borderRadius: 'var(--radius-lg)',
background: 'var(--glass-bg)',
}} />
</motion.div>
</div>
);
}

128
webos/src/shared/lib/api.ts Normal file
View File

@@ -0,0 +1,128 @@
import { buildVelocityHeaders } from '@/lib/velocitySession';
const rawApiBase = import.meta.env.VITE_API_URL?.trim();
const DEPLOYED_BACKEND_ORIGIN = 'https://velocity.desineuron.in';
function getBrowserOrigin() {
if (typeof window !== 'undefined' && window.location?.origin) {
return window.location.origin;
}
return '';
}
export const API_URL = (
rawApiBase && rawApiBase.length > 0
? rawApiBase
: import.meta.env.DEV
? getBrowserOrigin()
: DEPLOYED_BACKEND_ORIGIN || getBrowserOrigin()
).replace(/\/$/, '');
export const WS_URL = API_URL.replace(/^http/, 'ws');
export interface ScatterDataPoint {
id: string;
name: string;
sentiment_score: number;
response_time_ms: number;
score: number;
qualification: string;
kanban_status: string;
}
export interface LeadRecord {
id: string;
name: string;
email?: string | null;
phone?: string | null;
source: string;
notes: string;
qualification: string;
score: number;
kanban_status: string;
stage: string;
budget: string;
unit_interest: string;
metadata: Record<string, unknown>;
created_at?: string | null;
updated_at?: string | null;
}
export interface LeadDemographics {
by_source: Array<{ source: string; lead_count: number; avg_score: number }>;
by_qualification: Array<{ qualification: string; lead_count: number }>;
}
export interface ChatLogRecord {
id: string;
lead_id: string;
sender: string;
channel: string;
content: string;
metadata: Record<string, unknown>;
created_at: string | null;
}
export interface MarketingCampaignSummary {
id: string;
name: string;
platform: 'meta' | 'google';
status: 'active' | 'paused' | 'completed';
budget: number;
spent: number;
impressions: number;
clicks: number;
conversions: number;
}
async function requestJson<T>(path: string): Promise<T> {
const response = await fetch(`${API_URL}${path}`, {
headers: buildVelocityHeaders(undefined, false),
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(
typeof body?.detail === 'string'
? body.detail
: typeof body?.message === 'string'
? body.message
: `Request failed: ${response.status}`,
);
}
return response.json() as Promise<T>;
}
async function requestWrappedData<T>(path: string): Promise<T> {
const payload = await requestJson<{ data: T }>(path);
return payload.data;
}
export async function getSentimentScatter(): Promise<ScatterDataPoint[]> {
return requestJson<ScatterDataPoint[]>('/api/analytics/sentiment-scatter');
}
export async function getCatalystCampaigns(): Promise<MarketingCampaignSummary[]> {
return requestWrappedData<MarketingCampaignSummary[]>('/api/catalyst/campaigns');
}
export async function getLeads(): Promise<LeadRecord[]> {
const payload = await requestJson<{ data: LeadRecord[] }>('/api/leads');
return payload.data;
}
export async function getLead(leadId: string): Promise<LeadRecord> {
return requestWrappedData<LeadRecord>(`/api/leads/${leadId}`);
}
export async function getKanbanBoard() {
return requestWrappedData<Array<{ status: string; stage: string; count: number; items: LeadRecord[] }>>('/api/kanban/board');
}
export async function getChatLogs(leadId?: string): Promise<ChatLogRecord[]> {
const suffix = leadId ? `?lead_id=${encodeURIComponent(leadId)}` : '';
return requestWrappedData<ChatLogRecord[]>(`/api/chat-logs${suffix}`);
}
export async function getLeadDemographics(): Promise<LeadDemographics> {
return requestWrappedData<LeadDemographics>('/api/leads/demographics');
}

View File

@@ -0,0 +1,52 @@
/**
* Velocity-OS API Client
* Thin wrapper around fetch. Injects JWT auth header automatically.
* All requests go to /api (proxied by Traefik to core-api:8443).
*/
import { useAuthStore } from '@/store/authStore';
const BASE_URL = '/api';
export class ApiError extends Error {
constructor(public status: number, message: string) {
super(message);
this.name = 'ApiError';
}
}
async function apiFetch<T>(
path: string,
options: RequestInit = {}
): Promise<T> {
const token = useAuthStore.getState().token;
const response = await fetch(`${BASE_URL}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(options.headers ?? {}),
},
});
if (response.status === 401) {
// Token expired — clear session and let AuthGuard redirect
useAuthStore.getState().clearSession();
throw new ApiError(401, 'Session expired');
}
if (!response.ok) {
const body = await response.text().catch(() => '');
throw new ApiError(response.status, body || `HTTP ${response.status}`);
}
if (response.status === 204) return undefined as T;
return response.json();
}
export const api = {
get: <T>(path: string) => apiFetch<T>(path),
post: <T>(path: string, body: unknown) => apiFetch<T>(path, { method: 'POST', body: JSON.stringify(body) }),
patch: <T>(path: string, body: unknown) => apiFetch<T>(path, { method: 'PATCH', body: JSON.stringify(body) }),
delete: <T>(path: string) => apiFetch<T>(path, { method: 'DELETE' }),
};

View File

@@ -0,0 +1,89 @@
import { getVelocityToken } from './velocityPlatformClient';
import type {
CommsThread,
CommsSettings,
CommsProviderTestResult,
SendMessagePayload,
CommsThreadListResponse,
CommsMessageListResponse,
ThreadLinkPayload,
} from '@/types/commsTypes';
const API_BASE = import.meta.env.VITE_API_BASE_URL || '';
async function commsFetch(path: string, options?: RequestInit) {
const token = getVelocityToken();
const res = await fetch(`${API_BASE}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options?.headers,
},
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${res.status}`);
}
return res.json();
}
function toQuery(params?: Record<string, string | number | undefined>) {
const query = new URLSearchParams();
Object.entries(params ?? {}).forEach(([key, value]) => {
if (value !== undefined && value !== '') query.set(key, String(value));
});
const serialized = query.toString();
return serialized ? `?${serialized}` : '';
}
export const fetchCommsThreads = (params?: { status?: string; search?: string; limit?: number; offset?: number }) =>
commsFetch(`/api/comms/threads${toQuery(params)}`) as Promise<CommsThreadListResponse>;
export const fetchCommsThread = (threadId: string) =>
commsFetch(`/api/comms/threads/${threadId}`) as Promise<CommsThread>;
export const fetchCommsMessages = (threadId: string, params?: { limit?: number; offset?: number }) =>
commsFetch(`/api/comms/threads/${threadId}/messages${toQuery(params)}`) as Promise<CommsMessageListResponse>;
export const sendCommsMessage = (threadId: string, payload: SendMessagePayload) =>
commsFetch(`/api/comms/threads/${threadId}/messages`, {
method: 'POST',
body: JSON.stringify(payload),
});
export const linkCommsThreadToPerson = (threadId: string, payload: ThreadLinkPayload) =>
commsFetch(`/api/comms/threads/${threadId}/link-person`, {
method: 'POST',
body: JSON.stringify(payload),
});
export const addCommsThreadNote = (threadId: string, body: { content: string }) =>
commsFetch(`/api/comms/threads/${threadId}/notes`, {
method: 'POST',
body: JSON.stringify(body),
});
export const addCommsThreadTask = (threadId: string, body: { title: string; dueAt?: string }) =>
commsFetch(`/api/comms/threads/${threadId}/tasks`, {
method: 'POST',
body: JSON.stringify(body),
});
export const fetchCommsSettings = () =>
commsFetch(`/api/comms/settings`) as Promise<CommsSettings>;
export const updateCommsSettings = (payload: Partial<CommsSettings>) =>
commsFetch(`/api/comms/settings`, {
method: 'PATCH',
body: JSON.stringify(payload),
});
export const testCommsProviderConnection = () =>
commsFetch(`/api/comms/provider/test`, { method: 'POST' }) as Promise<CommsProviderTestResult>;
export const transcribeCommsRecording = (callId: string) =>
commsFetch(`/api/comms/recordings/transcribe`, {
method: 'POST',
body: JSON.stringify({ callId }),
});

View File

@@ -0,0 +1,310 @@
// app/src/lib/crmApi.ts
// CRM API client — canonical CRM routes
// Implements the frontend adapter layer from Doc 10 (TypeScript Module Spec)
import type {
CrmContactListItem,
CrmPerson,
Client360Snapshot,
CrmOpportunityCard,
CrmTask,
CrmLeadStageUpdate,
KanbanColumn,
ImportBatchSummary,
ImportProposal,
ImportReviewDecision,
QdScoreEntry,
OracleClientDataListItem,
OracleClientDataDetail,
OracleClientTimelineItem,
} from '@/types/crmTypes';
import { buildVelocityHeaders } from '@/lib/velocitySession';
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? '';
function getAuthHeaders(): Record<string, string> {
return Object.fromEntries(buildVelocityHeaders(undefined, false).entries());
}
async function apiFetch<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
...getAuthHeaders(),
...(options?.headers ?? {}),
},
});
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(err.detail ?? `API error ${res.status}`);
}
return res.json() as Promise<T>;
}
// ── Contact List ──────────────────────────────────────────────────────────────
export async function fetchContacts(params: {
search?: string;
buyer_type?: string;
status?: string;
limit?: number;
offset?: number;
}): Promise<{ contacts: CrmContactListItem[]; total: number; limit: number; offset: number }> {
const qs = new URLSearchParams();
if (params.search) qs.set('search', params.search);
if (params.buyer_type) qs.set('buyer_type', params.buyer_type);
if (params.status) qs.set('status', params.status);
if (params.limit != null) qs.set('limit', String(params.limit));
if (params.offset != null) qs.set('offset', String(params.offset));
const res = await apiFetch<{ status: string; data: { contacts: CrmContactListItem[]; total: number; limit: number; offset: number } }>(
`/api/crm/contacts?${qs}`
);
return res.data;
}
export async function fetchContact(personId: string): Promise<CrmPerson> {
const res = await apiFetch<{ status: string; data: CrmPerson }>(`/api/crm/contacts/${personId}`);
return res.data;
}
// ── Client 360 ────────────────────────────────────────────────────────────────
export async function fetchClient360(personId: string): Promise<Client360Snapshot> {
const res = await apiFetch<{ status: string; data: Client360Snapshot }>(`/api/crm/client-360/${personId}`);
return res.data;
}
// ── Opportunities ─────────────────────────────────────────────────────────────
export async function fetchOpportunities(params?: {
stage?: string;
limit?: number;
offset?: number;
}): Promise<CrmOpportunityCard[]> {
const qs = new URLSearchParams();
if (params?.stage) qs.set('stage', params.stage);
if (params?.limit != null) qs.set('limit', String(params.limit));
if (params?.offset != null) qs.set('offset', String(params.offset));
const res = await apiFetch<{ status: string; data: CrmOpportunityCard[] }>(`/api/crm/opportunities?${qs}`);
return res.data;
}
export async function updateOpportunity(body: {
opportunity_id: string;
stage?: string;
value?: number | null;
probability?: number | null;
expected_close_date?: string | null;
next_action?: string | null;
notes?: string | null;
}): Promise<CrmOpportunityCard> {
const { opportunity_id, ...payload } = body;
const res = await apiFetch<{ status: string; data: CrmOpportunityCard }>(`/api/crm/opportunities/${opportunity_id}`, {
method: 'PATCH',
body: JSON.stringify(payload),
});
return res.data;
}
// ── Tasks ─────────────────────────────────────────────────────────────────────
export async function fetchTasks(params?: {
status?: string;
assigned_to?: string;
limit?: number;
}): Promise<CrmTask[]> {
const qs = new URLSearchParams();
if (params?.status) qs.set('status', params.status);
if (params?.assigned_to) qs.set('assigned_to', params.assigned_to);
if (params?.limit != null) qs.set('limit', String(params.limit));
const res = await apiFetch<{ status: string; data: CrmTask[] }>(`/api/crm/tasks?${qs}`);
return res.data;
}
export async function createTask(body: {
person_id: string;
lead_id?: string;
reminder_type?: string;
title: string;
notes?: string;
due_at?: string;
priority?: string;
}): Promise<{ reminder_id: string; title: string }> {
const res = await apiFetch<{ status: string; data: { reminder_id: string; title: string } }>('/api/crm/tasks', {
method: 'POST',
body: JSON.stringify(body),
});
return res.data;
}
export async function updateTask(body: {
reminder_id: string;
status: 'pending' | 'done' | 'snoozed' | 'cancelled';
due_at?: string;
notes?: string;
}): Promise<CrmTask> {
const res = await apiFetch<{ status: string; data: CrmTask }>(`/api/crm/tasks/${body.reminder_id}`, {
method: 'PATCH',
body: JSON.stringify({
status: body.status,
due_at: body.due_at,
notes: body.notes,
}),
});
return res.data;
}
// ── Kanban ────────────────────────────────────────────────────────────────────
export async function fetchKanbanBoard(): Promise<KanbanColumn[]> {
const res = await apiFetch<{ status: string; data: KanbanColumn[] }>('/api/crm/kanban');
return res.data;
}
export async function updateLeadStage(body: {
lead_id: string;
status: string;
notes?: string;
}): Promise<CrmLeadStageUpdate> {
const res = await apiFetch<{ status: string; data: CrmLeadStageUpdate }>(`/api/crm/leads/${body.lead_id}/stage`, {
method: 'PATCH',
body: JSON.stringify({
status: body.status,
notes: body.notes,
}),
});
return res.data;
}
// ── QD Scores ─────────────────────────────────────────────────────────────────
export async function fetchQdScore(personId: string): Promise<{
person_id: string;
scores: Record<string, QdScoreEntry>;
timeseries: Array<{ score_type: string; value: number; timestamp: string | null; signal_source: string | null; delta: number | null }>;
}> {
const res = await apiFetch<{
status: string;
data: {
person_id: string;
scores: Record<string, QdScoreEntry>;
timeseries: Array<{ score_type: string; value: number; timestamp: string | null; signal_source: string | null; delta: number | null }>;
};
}>(`/api/crm/qd/${personId}`);
return res.data;
}
// ── Import Batches ─────────────────────────────────────────────────────────────
export async function uploadCrmImport(file: File, sourceSystem = 'csv_upload'): Promise<{
batch_id: string;
row_count: number;
mapped_columns: number;
unmapped_columns: number;
mapping_confidence: number;
proposals_created: number;
parse_errors: string[];
lifecycle: string;
message: string;
}> {
const form = new FormData();
form.append('file', file);
const res = await fetch(`${API_BASE}/api/crm/imports?source_system=${sourceSystem}`, {
method: 'POST',
headers: { ...getAuthHeaders() },
body: form,
});
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(err.detail ?? `Upload error ${res.status}`);
}
const json = await res.json();
return json.data;
}
export async function fetchImportBatches(lifecycle?: string): Promise<ImportBatchSummary[]> {
const qs = lifecycle ? `?lifecycle=${lifecycle}` : '';
const res = await apiFetch<{ status: string; data: ImportBatchSummary[] }>(`/api/crm/imports${qs}`);
return res.data;
}
export async function fetchImportBatch(batchId: string): Promise<{
batch_id: string;
filename: string;
row_count: number;
mapping_manifest: Record<string, unknown>;
lifecycle: string;
proposals: ImportProposal[];
proposal_count: number;
}> {
const res = await apiFetch<{ status: string; data: ReturnType<typeof fetchImportBatch> extends Promise<infer R> ? R : never }>(`/api/crm/imports/${batchId}`);
return res.data as Awaited<ReturnType<typeof fetchImportBatch>>;
}
export async function reviewProposal(
batchId: string,
proposalId: string,
decision: ImportReviewDecision,
notes = ''
): Promise<{ decision_id: string; decision: string }> {
const res = await apiFetch<{ status: string; data: { decision_id: string; decision: string } }>(
`/api/crm/imports/${batchId}/review-proposal`,
{
method: 'PUT',
body: JSON.stringify({ proposal_id: proposalId, decision, notes }),
}
);
return res.data;
}
export async function commitImportBatch(batchId: string): Promise<{
committed: number;
skipped: number;
errors: string[];
lifecycle: string;
}> {
const res = await apiFetch<{
status: string;
data: { committed: number; skipped: number; errors: string[]; lifecycle: string };
}>(`/api/crm/imports/${batchId}/commit`, { method: 'POST' });
return res.data;
}
export async function fetchOracleClientData(params?: {
search?: string;
limit?: number;
offset?: number;
}): Promise<{ items: OracleClientDataListItem[]; count: number }> {
const qs = new URLSearchParams();
if (params?.search) qs.set('search', params.search);
if (params?.limit != null) qs.set('limit', String(params.limit));
if (params?.offset != null) qs.set('offset', String(params.offset));
const res = await apiFetch<{ status: string; data: OracleClientDataListItem[]; meta?: { count?: number } }>(
`/api/crm/client-data?${qs}`,
);
return { items: res.data, count: res.meta?.count ?? res.data.length };
}
export async function fetchOracleClientDataDetail(personId: string): Promise<OracleClientDataDetail> {
const res = await apiFetch<{ status: string; data: OracleClientDataDetail }>(`/api/crm/client-data/${personId}`);
return res.data;
}
export async function patchOracleClientData(
personId: string,
patch: Record<string, string | null>,
): Promise<{ person_id: string; updated: string[] }> {
const res = await apiFetch<{ status: string; data: { person_id: string; updated: string[] } }>(
`/api/crm/client-data/${personId}`,
{ method: 'PATCH', body: JSON.stringify(patch) },
);
return res.data;
}
export async function fetchOracleClientTimeline(personId: string): Promise<OracleClientTimelineItem[]> {
const res = await apiFetch<{ status: string; data: OracleClientTimelineItem[] }>(
`/api/crm/client-data/${personId}/timeline`,
);
return res.data;
}

View File

@@ -0,0 +1,122 @@
import type { ChatLogRecord, LeadRecord } from '@/lib/api';
import type { Lead } from '@/types';
import type { LeadBadge, LeadTag, LeadSource, Message, MessageSender, PipelineStage, SentimentLog } from '@/types/crm';
const TAG_MAP: Record<string, LeadTag> = {
whale: '#CashBuyer',
potential: '#Investor',
hot: '#EndUser',
};
export function mapLeadRecordToStoreLead(record: LeadRecord): Lead {
const qualification = record.qualification.toLowerCase() as Lead['qualification'];
const status = record.stage === 'closed'
? 'closed'
: record.stage === 'qualified' || record.stage === 'negotiation'
? 'qualified'
: record.score >= 75
? 'hot'
: record.stage === 'new'
? 'new'
: 'engaged';
const tags = Array.isArray(record.metadata?.tags) ? (record.metadata.tags as string[]) : [];
return {
id: record.id,
name: record.name,
phone: record.phone ?? '',
source: mapSource(record.source),
status,
lastMessage: record.notes || 'No conversation summary yet.',
lastActive: new Date(record.updated_at ?? record.created_at ?? Date.now()),
unreadCount: 0,
qualification: qualification === 'tire_kicker' || qualification === 'potential' || qualification === 'whale'
? qualification
: 'potential',
budget: record.budget,
interest: record.unit_interest,
quantumDynamicsScore: record.score,
tags: tags.length > 0 ? tags : [record.qualification],
};
}
export function mapLeadRecordToOracleLead(record: LeadRecord, chatLogs: ChatLogRecord[]): import('@/types/crm').Lead {
const badge = mapBadge(record.qualification);
const tags = mapOracleTags(record.qualification, record.metadata);
return {
id: record.id,
name: record.name,
phone: record.phone ?? '',
stage: mapPipelineStage(record.stage),
oracleScore: record.score,
badge,
tags,
source: mapSource(record.source),
budget: record.budget,
unitInterest: record.unit_interest,
profileImageUrl: `https://api.dicebear.com/9.x/glass/svg?seed=${encodeURIComponent(record.name)}`,
visitedShowroom: record.stage === 'site_visit' || record.stage === 'negotiation' || record.stage === 'closed',
inShowroomNow: record.stage === 'site_visit',
messages: chatLogs.map(mapChatLogToOracleMessage),
sentimentLog: buildSentimentLog(record.score, record.stage),
};
}
function mapSource(source: string): LeadSource {
if (source === 'walkin' || source === 'website' || source === 'whatsapp') return source;
return 'website';
}
function mapPipelineStage(stage: string): PipelineStage {
const normalized = stage.toLowerCase();
if (normalized === 'new' || normalized === 'new_inquiries') return 'new_inquiries';
if (normalized === 'qualified' || normalized === 'qualifying') return 'qualified';
if (normalized === 'site_visit') return 'site_visit';
if (normalized === 'negotiation') return 'negotiation';
return 'closed';
}
function mapBadge(qualification: string): LeadBadge | undefined {
const normalized = qualification.toLowerCase();
if (normalized === 'whale') return 'whale';
if (normalized === 'hot' || normalized === 'potential') return 'hot';
if (normalized === 'tire_kicker') return 'tire_kicker';
return undefined;
}
function mapOracleTags(qualification: string, metadata: Record<string, unknown>): LeadTag[] {
const mapped = TAG_MAP[qualification.toLowerCase()];
const rawTags = Array.isArray(metadata?.tags) ? metadata.tags as string[] : [];
const canonical = rawTags.includes('#CashBuyer') || mapped === '#CashBuyer'
? '#CashBuyer'
: rawTags.includes('#EndUser') || mapped === '#EndUser'
? '#EndUser'
: '#Investor';
return [canonical];
}
function mapChatLogToOracleMessage(log: ChatLogRecord): Message {
return {
id: log.id,
sender: mapSender(log.sender),
content: log.content,
createdAt: log.created_at ?? new Date().toISOString(),
};
}
function mapSender(sender: string): MessageSender {
if (sender === 'lead' || sender === 'oracle' || sender === 'system') return sender;
return 'system';
}
function buildSentimentLog(score: number, stage: string): SentimentLog[] {
const base = Math.max(20, score - 18);
const labels = stage === 'site_visit'
? ['Entry', 'Showroom peak', 'Pricing review']
: ['Discovery', 'Qualification', 'Follow-up'];
return labels.map((label, index) => ({
id: `${stage}-${index}`,
at: `${10 + index}:0${index}`,
score: Math.min(100, base + index * 9),
note: label,
}));
}

View File

@@ -0,0 +1,197 @@
import { API_URL } from '@/lib/api';
import { buildVelocityHeaders } from '@/lib/velocitySession';
const rawDreamWeaverBase = import.meta.env.VITE_DREAM_WEAVER_URL?.trim();
const rawDreamWeaverApiKey = import.meta.env.VITE_DREAM_WEAVER_API_KEY?.trim();
const LOCAL_DREAM_WEAVER_GATEWAY = 'http://127.0.0.1:8082';
export const DREAM_WEAVER_URL = (rawDreamWeaverBase && rawDreamWeaverBase.length > 0
? rawDreamWeaverBase
: import.meta.env.DEV
? LOCAL_DREAM_WEAVER_GATEWAY
: API_URL
).replace(/\/$/, '');
export interface DreamWeaverHealth {
online: boolean;
routeMounted: boolean;
status: string;
comfyuiOnline?: boolean;
comfyuiUrl?: string;
checkpointReady?: boolean;
checkpointCount?: number;
availableCheckpoints?: string[];
preferredCheckpoints?: string[];
detail?: string;
}
export interface DreamWeaverJobResponse {
job_id: string;
status?: string;
poll_url?: string;
result_url?: string;
}
export interface DreamWeaverStatusResponse {
status?: string;
ready?: boolean;
result_url?: string;
error?: string;
}
export interface SubmitDreamWeaverJobInput {
image: File;
roomType: string;
keywords: string;
}
function buildDreamWeaverHeaders(init?: HeadersInit): Headers {
const headers = buildVelocityHeaders(init, false);
if (rawDreamWeaverApiKey && !headers.has('X-Dream-Weaver-API-Key')) {
headers.set('X-Dream-Weaver-API-Key', rawDreamWeaverApiKey);
}
return headers;
}
function resolveDreamWeaverUrl(candidate: string | undefined, fallbackPath: string): string {
const path = candidate && candidate.trim().length > 0 ? candidate.trim() : fallbackPath;
if (/^https?:\/\//i.test(path)) {
return path;
}
return `${DREAM_WEAVER_URL}${path.startsWith('/') ? path : `/${path}`}`;
}
async function readErrorMessage(response: Response, fallback: string): Promise<string> {
const body = await response.json().catch(() => null) as { detail?: unknown; message?: unknown; error?: unknown } | null;
if (typeof body?.detail === 'string') return body.detail;
if (typeof body?.message === 'string') return body.message;
if (typeof body?.error === 'string') return body.error;
const text = await response.text().catch(() => '');
return text.trim() || fallback;
}
async function requestDreamWeaverJson<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, {
...init,
headers: buildDreamWeaverHeaders(init?.headers),
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, `Dream Weaver request failed: ${response.status}`));
}
return response.json() as Promise<T>;
}
export async function checkDreamWeaverHealth(): Promise<DreamWeaverHealth> {
let status = 'offline';
let detail: string | undefined;
let comfyuiOnline: boolean | undefined;
let comfyuiUrl: string | undefined;
let checkpointReady: boolean | undefined;
let checkpointCount: number | undefined;
let availableCheckpoints: string[] | undefined;
let preferredCheckpoints: string[] | undefined;
let healthOk = false;
try {
const response = await fetch(resolveDreamWeaverUrl(undefined, '/dream-weaver/health'), {
headers: buildDreamWeaverHeaders(),
});
const body = await response.json().catch(() => null) as {
status?: unknown;
detail?: unknown;
comfyui?: unknown;
comfyui_url?: unknown;
comfyuiUrl?: unknown;
checkpoint_ready?: unknown;
checkpoint_count?: unknown;
available_checkpoints?: unknown;
preferred_checkpoints?: unknown;
} | null;
status = typeof body?.status === 'string' ? body.status : response.ok ? 'ok' : `HTTP ${response.status}`;
detail = typeof body?.detail === 'string' ? body.detail : undefined;
comfyuiOnline = typeof body?.comfyui === 'boolean' ? body.comfyui : undefined;
comfyuiUrl = typeof body?.comfyui_url === 'string'
? body.comfyui_url
: typeof body?.comfyuiUrl === 'string'
? body.comfyuiUrl
: undefined;
checkpointReady = typeof body?.checkpoint_ready === 'boolean' ? body.checkpoint_ready : undefined;
checkpointCount = typeof body?.checkpoint_count === 'number' ? body.checkpoint_count : undefined;
availableCheckpoints = Array.isArray(body?.available_checkpoints)
? body.available_checkpoints.filter((item): item is string => typeof item === 'string')
: undefined;
preferredCheckpoints = Array.isArray(body?.preferred_checkpoints)
? body.preferred_checkpoints.filter((item): item is string => typeof item === 'string')
: undefined;
healthOk = response.ok && ['ok', 'healthy', 'online'].includes(status.toLowerCase());
} catch (error) {
detail = error instanceof Error ? error.message : 'Unable to reach Dream Weaver gateway.';
}
try {
const probe = await fetch(resolveDreamWeaverUrl(undefined, '/dream-weaver/status/velocity-route-probe'), {
headers: buildDreamWeaverHeaders(),
});
if (probe.ok) {
return { online: healthOk, routeMounted: true, status, comfyuiOnline, comfyuiUrl, checkpointReady, checkpointCount, availableCheckpoints, preferredCheckpoints, detail };
}
const probeMessage = await readErrorMessage(probe, '');
const expectedMissingJob = probe.status === 404 && /job|not found|missing/i.test(probeMessage);
return {
online: healthOk && expectedMissingJob,
routeMounted: expectedMissingJob,
status,
comfyuiOnline,
comfyuiUrl,
checkpointReady,
checkpointCount,
availableCheckpoints,
preferredCheckpoints,
detail: detail ?? probeMessage,
};
} catch (error) {
return {
online: false,
routeMounted: false,
status,
comfyuiOnline,
comfyuiUrl,
checkpointReady,
checkpointCount,
availableCheckpoints,
preferredCheckpoints,
detail: error instanceof Error ? error.message : detail,
};
}
}
export async function submitDreamWeaverJob(input: SubmitDreamWeaverJobInput): Promise<DreamWeaverJobResponse> {
const formData = new FormData();
formData.append('image', input.image, input.image.name || 'room-source.jpg');
formData.append('room_type', input.roomType);
const trimmedKeywords = input.keywords.trim();
if (trimmedKeywords.length > 0) {
formData.append('keywords', trimmedKeywords);
}
return requestDreamWeaverJson<DreamWeaverJobResponse>(resolveDreamWeaverUrl(undefined, '/dream-weaver'), {
method: 'POST',
body: formData,
});
}
export async function getDreamWeaverStatus(job: Pick<DreamWeaverJobResponse, 'job_id' | 'poll_url'>): Promise<DreamWeaverStatusResponse> {
return requestDreamWeaverJson<DreamWeaverStatusResponse>(
resolveDreamWeaverUrl(job.poll_url, `/dream-weaver/status/${encodeURIComponent(job.job_id)}`),
);
}
export async function fetchDreamWeaverResult(jobId: string, resultUrl?: string): Promise<Blob> {
const response = await fetch(resolveDreamWeaverUrl(resultUrl, `/dream-weaver/result/${encodeURIComponent(jobId)}`), {
headers: buildDreamWeaverHeaders({ Accept: 'image/png,image/*,*/*' }),
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, `Dream Weaver result failed: ${response.status}`));
}
return response.blob();
}

Some files were not shown because too many files have changed in this diff Show More