Initial commit: Velocity-OS migration
This commit is contained in:
41
webos/Dockerfile
Normal file
41
webos/Dockerfile
Normal 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
27
webos/fix_dirs.ps1
Normal 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
25
webos/index.html
Normal 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
37
webos/nginx.conf
Normal 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
4233
webos/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
39
webos/package.json
Normal file
39
webos/package.json
Normal 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
115
webos/src/App.tsx
Normal 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} />;
|
||||
}
|
||||
60
webos/src/control-room/ControlRoom.module.css
Normal file
60
webos/src/control-room/ControlRoom.module.css
Normal 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); }
|
||||
297
webos/src/control-room/ControlRoom.tsx
Normal file
297
webos/src/control-room/ControlRoom.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
210
webos/src/design-system/glass.css
Normal file
210
webos/src/design-system/glass.css
Normal 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);
|
||||
}
|
||||
128
webos/src/design-system/tokens.css
Normal file
128
webos/src/design-system/tokens.css
Normal 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); /* 40–69 */
|
||||
--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;
|
||||
}
|
||||
81
webos/src/intelligence-rail/IntelligenceRail.module.css
Normal file
81
webos/src/intelligence-rail/IntelligenceRail.module.css
Normal 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); }
|
||||
182
webos/src/intelligence-rail/IntelligenceRail.tsx
Normal file
182
webos/src/intelligence-rail/IntelligenceRail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
webos/src/intelligence-rail/SentinelAlertBanner.module.css
Normal file
18
webos/src/intelligence-rail/SentinelAlertBanner.module.css
Normal 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; }
|
||||
99
webos/src/intelligence-rail/SentinelAlertBanner.tsx
Normal file
99
webos/src/intelligence-rail/SentinelAlertBanner.tsx
Normal 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
29
webos/src/main.tsx
Normal 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>
|
||||
);
|
||||
12
webos/src/pillars/command/AIPriorityCards.module.css
Normal file
12
webos/src/pillars/command/AIPriorityCards.module.css
Normal 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); }
|
||||
106
webos/src/pillars/command/AIPriorityCards.tsx
Normal file
106
webos/src/pillars/command/AIPriorityCards.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
webos/src/pillars/command/CommandPillar.module.css
Normal file
5
webos/src/pillars/command/CommandPillar.module.css
Normal 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); }
|
||||
71
webos/src/pillars/command/CommandPillar.tsx
Normal file
71
webos/src/pillars/command/CommandPillar.tsx
Normal 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.';
|
||||
}
|
||||
10
webos/src/pillars/command/KpiHero.module.css
Normal file
10
webos/src/pillars/command/KpiHero.module.css
Normal 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); }
|
||||
62
webos/src/pillars/command/KpiHero.tsx
Normal file
62
webos/src/pillars/command/KpiHero.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
12
webos/src/pillars/command/OracleBar.module.css
Normal file
12
webos/src/pillars/command/OracleBar.module.css
Normal 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); }
|
||||
170
webos/src/pillars/command/OracleBar.tsx
Normal file
170
webos/src/pillars/command/OracleBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
12
webos/src/pillars/command/OracleResultCard.module.css
Normal file
12
webos/src/pillars/command/OracleResultCard.module.css
Normal 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; }
|
||||
113
webos/src/pillars/command/OracleResultCard.tsx
Normal file
113
webos/src/pillars/command/OracleResultCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
webos/src/pillars/command/PipelinePulse.module.css
Normal file
13
webos/src/pillars/command/PipelinePulse.module.css
Normal 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); }
|
||||
98
webos/src/pillars/command/PipelinePulse.tsx
Normal file
98
webos/src/pillars/command/PipelinePulse.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
201
webos/src/pillars/command/oracle/oracle/components/BranchBar.tsx
Normal file
201
webos/src/pillars/command/oracle/oracle/components/BranchBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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([]),
|
||||
};
|
||||
}
|
||||
120
webos/src/pillars/command/oracle/oracle/hooks/useOraclePage.ts
Normal file
120
webos/src/pillars/command/oracle/oracle/hooks/useOraclePage.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
248
webos/src/pillars/command/oracle/oracle/lib/oracleApiClient.ts
Normal file
248
webos/src/pillars/command/oracle/oracle/lib/oracleApiClient.ts
Normal 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();
|
||||
};
|
||||
}
|
||||
489
webos/src/pillars/command/oracle/oracle/types/canvas.ts
Normal file
489
webos/src/pillars/command/oracle/oracle/types/canvas.ts
Normal 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>;
|
||||
}
|
||||
29
webos/src/pillars/pipeline/PipelinePillar.module.css
Normal file
29
webos/src/pillars/pipeline/PipelinePillar.module.css
Normal 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); }
|
||||
168
webos/src/pillars/pipeline/PipelinePillar.tsx
Normal file
168
webos/src/pillars/pipeline/PipelinePillar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
webos/src/pillars/pipeline/ShowroomMode.module.css
Normal file
32
webos/src/pillars/pipeline/ShowroomMode.module.css
Normal 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); }
|
||||
212
webos/src/pillars/pipeline/ShowroomMode.tsx
Normal file
212
webos/src/pillars/pipeline/ShowroomMode.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
webos/src/pillars/pipeline/client360/Client360.module.css
Normal file
32
webos/src/pillars/pipeline/client360/Client360.module.css
Normal 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; }
|
||||
217
webos/src/pillars/pipeline/client360/Client360.tsx
Normal file
217
webos/src/pillars/pipeline/client360/Client360.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
92
webos/src/pillars/pipeline/client360/QDRing.tsx
Normal file
92
webos/src/pillars/pipeline/client360/QDRing.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
/**
|
||||
* QDRing
|
||||
* Animated SVG arc representing the Qualification Desire score (0–100).
|
||||
* Color: green (≥70), amber (40–69), 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>
|
||||
);
|
||||
}
|
||||
@@ -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); }
|
||||
147
webos/src/pillars/pipeline/client360/tabs/Conversations.tsx
Normal file
147
webos/src/pillars/pipeline/client360/tabs/Conversations.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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; }
|
||||
150
webos/src/pillars/pipeline/client360/tabs/Intelligence.tsx
Normal file
150
webos/src/pillars/pipeline/client360/tabs/Intelligence.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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); }
|
||||
121
webos/src/pillars/pipeline/client360/tabs/Properties.tsx
Normal file
121
webos/src/pillars/pipeline/client360/tabs/Properties.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
webos/src/pillars/pipeline/client360/tabs/Tasks.module.css
Normal file
14
webos/src/pillars/pipeline/client360/tabs/Tasks.module.css
Normal 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); }
|
||||
104
webos/src/pillars/pipeline/client360/tabs/Tasks.tsx
Normal file
104
webos/src/pillars/pipeline/client360/tabs/Tasks.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
30
webos/src/pillars/studio/PropertyEntity.module.css
Normal file
30
webos/src/pillars/studio/PropertyEntity.module.css
Normal 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); }
|
||||
227
webos/src/pillars/studio/PropertyEntity.tsx
Normal file
227
webos/src/pillars/studio/PropertyEntity.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
webos/src/pillars/studio/ReimaginePanel.module.css
Normal file
22
webos/src/pillars/studio/ReimaginePanel.module.css
Normal 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); }
|
||||
236
webos/src/pillars/studio/ReimaginePanel.tsx
Normal file
236
webos/src/pillars/studio/ReimaginePanel.tsx
Normal 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');
|
||||
}
|
||||
22
webos/src/pillars/studio/StudioPillar.module.css
Normal file
22
webos/src/pillars/studio/StudioPillar.module.css
Normal 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); }
|
||||
103
webos/src/pillars/studio/StudioPillar.tsx
Normal file
103
webos/src/pillars/studio/StudioPillar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
webos/src/shared/hooks/use-mobile.ts
Normal file
19
webos/src/shared/hooks/use-mobile.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import * as React from "react"
|
||||
|
||||
const MOBILE_BREAKPOINT = 768
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
}
|
||||
mql.addEventListener("change", onChange)
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
return () => mql.removeEventListener("change", onChange)
|
||||
}, [])
|
||||
|
||||
return !!isMobile
|
||||
}
|
||||
133
webos/src/shared/hooks/useClient360.ts
Normal file
133
webos/src/shared/hooks/useClient360.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '@/shared/lib/apiClient';
|
||||
|
||||
/**
|
||||
* useClient360 — fetch unified client entity
|
||||
* Feeds: CRM lead data + QD score + pipeline stage + contact info
|
||||
*/
|
||||
export function useClient360(personId: string) {
|
||||
const query = useQuery({
|
||||
queryKey: ['client360', personId],
|
||||
queryFn: () => api.get<Client360Data>(`/crm/leads/${personId}/360`),
|
||||
staleTime: 30_000,
|
||||
enabled: !!personId,
|
||||
});
|
||||
return { client: query.data, isLoading: query.isLoading, error: query.error };
|
||||
}
|
||||
|
||||
/**
|
||||
* useConversations — unified comms feed for a lead
|
||||
*/
|
||||
export function useConversations(personId: string) {
|
||||
const qc = useQueryClient();
|
||||
const query = useQuery({
|
||||
queryKey: ['conversations', personId],
|
||||
queryFn: () => api.get<ConversationEvent[]>(`/comms/threads/${personId}`),
|
||||
staleTime: 10_000,
|
||||
enabled: !!personId,
|
||||
});
|
||||
|
||||
const sendWhatsApp = (text: string) =>
|
||||
api.post(`/comms/send`, { person_id: personId, channel: 'whatsapp', text })
|
||||
.then(() => qc.invalidateQueries({ queryKey: ['conversations', personId] }));
|
||||
|
||||
return { events: query.data ?? [], isLoading: query.isLoading, sendWhatsApp };
|
||||
}
|
||||
|
||||
/**
|
||||
* useClientProperties — linked property interests
|
||||
*/
|
||||
export function useClientProperties(personId: string) {
|
||||
const query = useQuery({
|
||||
queryKey: ['client-properties', personId],
|
||||
queryFn: () => api.get<PropertyInterest[]>(`/crm/leads/${personId}/properties`),
|
||||
staleTime: 60_000,
|
||||
enabled: !!personId,
|
||||
});
|
||||
return { properties: query.data ?? [], isLoading: query.isLoading };
|
||||
}
|
||||
|
||||
/**
|
||||
* useClientTasks — tasks for a specific lead
|
||||
*/
|
||||
export function useClientTasks(personId: string) {
|
||||
const qc = useQueryClient();
|
||||
const query = useQuery({
|
||||
queryKey: ['client-tasks', personId],
|
||||
queryFn: () => api.get<Task[]>(`/crm/leads/${personId}/tasks`),
|
||||
staleTime: 30_000,
|
||||
enabled: !!personId,
|
||||
});
|
||||
|
||||
const markDone = (taskId: string) =>
|
||||
api.patch(`/crm/tasks/${taskId}`, { status: 'done' })
|
||||
.then(() => qc.invalidateQueries({ queryKey: ['client-tasks', personId] }));
|
||||
|
||||
const snooze = (taskId: string) =>
|
||||
api.patch(`/crm/tasks/${taskId}`, { status: 'snoozed' })
|
||||
.then(() => qc.invalidateQueries({ queryKey: ['client-tasks', personId] }));
|
||||
|
||||
// Group tasks
|
||||
const all = query.data ?? [];
|
||||
const tasks = all.map(t => ({
|
||||
...t,
|
||||
group: t.status === 'done' ? 'completed'
|
||||
: t.isDueToday ? 'today'
|
||||
: 'upcoming',
|
||||
})) as any[];
|
||||
|
||||
return { tasks, isLoading: query.isLoading, markDone, snooze };
|
||||
}
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────
|
||||
export interface Client360Data {
|
||||
id: string;
|
||||
name: string;
|
||||
location?: string;
|
||||
primaryPhone?: string;
|
||||
avatarUrl?: string;
|
||||
qdScore: number;
|
||||
qdDelta: number;
|
||||
stageName: string;
|
||||
stageEmoji: string;
|
||||
lastContactRelative: string;
|
||||
lastContactChannel: string;
|
||||
aiInsight?: string;
|
||||
extractedFacts?: Record<string, string>;
|
||||
objections?: string[];
|
||||
qdHistory?: { date: string; score: number; label?: string }[];
|
||||
}
|
||||
|
||||
interface ConversationEvent {
|
||||
id: string;
|
||||
type: 'whatsapp' | 'call' | 'email';
|
||||
timestamp: string;
|
||||
timestampRelative: string;
|
||||
messages?: { sender: 'client' | 'you'; text: string; status?: '✓' | '✓✓' }[];
|
||||
duration?: string;
|
||||
direction?: 'inbound' | 'outbound';
|
||||
keyMoments?: string[];
|
||||
hasTranscript?: boolean;
|
||||
subject?: string;
|
||||
}
|
||||
|
||||
interface PropertyInterest {
|
||||
id: string;
|
||||
projectName: string;
|
||||
unitName: string;
|
||||
config: string;
|
||||
area: string;
|
||||
price: string;
|
||||
thumbnailUrl?: string;
|
||||
isPrimary: boolean;
|
||||
engagementLevel: 'High' | 'Medium' | 'Low';
|
||||
}
|
||||
|
||||
interface Task {
|
||||
id: string;
|
||||
label: string;
|
||||
dueAt?: string;
|
||||
status: 'pending' | 'done' | 'snoozed';
|
||||
isDueToday?: boolean;
|
||||
isAIGenerated?: boolean;
|
||||
}
|
||||
41
webos/src/shared/hooks/useCommandData.ts
Normal file
41
webos/src/shared/hooks/useCommandData.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '@/shared/lib/apiClient';
|
||||
|
||||
/**
|
||||
* useCommandData — Command Pillar data
|
||||
* Fetches KPIs, AI priority cards, and pipeline stage summary.
|
||||
*/
|
||||
export function useCommandData() {
|
||||
return useQuery({
|
||||
queryKey: ['command-data'],
|
||||
queryFn: () => api.get<CommandData>('/dashboard/morning-brief'),
|
||||
staleTime: 60_000,
|
||||
refetchInterval: 5 * 60_000, // background refresh every 5min
|
||||
});
|
||||
}
|
||||
|
||||
export interface CommandData {
|
||||
kpis: {
|
||||
label: string;
|
||||
value: string;
|
||||
delta?: string;
|
||||
deltaPositive?: boolean;
|
||||
sublabel?: string;
|
||||
}[];
|
||||
priorityCards: {
|
||||
id: string;
|
||||
type: 'qd_surge' | 'vault_engagement' | 'follow_up' | 'site_visit';
|
||||
headline: string;
|
||||
sublabel?: string;
|
||||
personId?: string;
|
||||
personName?: string;
|
||||
cta: string;
|
||||
urgency: 'high' | 'medium' | 'low';
|
||||
}[];
|
||||
pipelineStages: {
|
||||
id: string;
|
||||
label: string;
|
||||
count: number;
|
||||
value?: string;
|
||||
}[];
|
||||
}
|
||||
146
webos/src/shared/hooks/useCrmBootstrap.ts
Normal file
146
webos/src/shared/hooks/useCrmBootstrap.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { getChatLogs, getLeads } from '@/lib/api';
|
||||
import { mapLeadRecordToStoreLead } from '@/lib/crmMappers';
|
||||
import { mapInventoryPropertySummaryToUnit } from '@/lib/platformMappers';
|
||||
import { useStore } from '@/store/useStore';
|
||||
import type { ChatMessage } from '@/types';
|
||||
import type { LeadRecord } from '@/lib/api';
|
||||
import { listInventoryProperties } from '@/lib/velocityPlatformClient';
|
||||
|
||||
export function useCrmBootstrap() {
|
||||
const { setLeads, replaceMessages, setUnits, updateMetrics, setVelocityData, updateStatus } = useStore();
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const hydrate = async () => {
|
||||
updateStatus({
|
||||
isConnected: false,
|
||||
serverStatus: 'syncing',
|
||||
});
|
||||
|
||||
try {
|
||||
const leads = await getLeads();
|
||||
if (cancelled) return;
|
||||
setLeads(leads.map(mapLeadRecordToStoreLead));
|
||||
|
||||
const messageEntries = await Promise.all(
|
||||
leads.slice(0, 25).map(async (lead) => {
|
||||
const logs = await getChatLogs(lead.id);
|
||||
return [
|
||||
lead.id,
|
||||
logs.map((log): ChatMessage => ({
|
||||
id: log.id,
|
||||
sender: log.sender === 'lead' ? 'user' : 'oracle',
|
||||
content: log.content,
|
||||
timestamp: new Date(log.created_at ?? Date.now()),
|
||||
})),
|
||||
] as const;
|
||||
}),
|
||||
);
|
||||
if (!cancelled) {
|
||||
replaceMessages(Object.fromEntries(messageEntries));
|
||||
}
|
||||
|
||||
const inventoryResult = await listInventoryProperties(100).catch(() => null);
|
||||
if (!cancelled) {
|
||||
const units = inventoryResult?.properties.map(mapInventoryPropertySummaryToUnit) ?? [];
|
||||
setUnits(units);
|
||||
updateMetrics(buildDashboardMetrics(leads, messageEntries, units.length));
|
||||
setVelocityData(buildVelocitySeries(leads));
|
||||
updateStatus({
|
||||
isConnected: true,
|
||||
serverStatus: 'online',
|
||||
lastSync: new Date(),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setLeads([]);
|
||||
replaceMessages({});
|
||||
setUnits([]);
|
||||
updateMetrics({
|
||||
activeVisitors: 0,
|
||||
todayLeads: 0,
|
||||
closedDeals: 0,
|
||||
conversionRate: 0,
|
||||
sentiment: 0,
|
||||
systemHealth: {
|
||||
cpu: 0,
|
||||
gpu: 0,
|
||||
memory: 0,
|
||||
temperature: 0,
|
||||
},
|
||||
});
|
||||
setVelocityData([]);
|
||||
updateStatus({
|
||||
isConnected: false,
|
||||
serverStatus: 'offline',
|
||||
lastSync: new Date(),
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void hydrate();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [replaceMessages, setLeads, setUnits, setVelocityData, updateMetrics, updateStatus]);
|
||||
}
|
||||
|
||||
function buildDashboardMetrics(
|
||||
leads: LeadRecord[],
|
||||
messageEntries: ReadonlyArray<readonly [string, ChatMessage[]]>,
|
||||
inventoryCount: number,
|
||||
) {
|
||||
const closedDeals = leads.filter((lead) => lead.stage === 'closed').length;
|
||||
const engagedLeads = leads.filter((lead) => lead.score >= 75 || lead.stage === 'negotiation' || lead.stage === 'qualified').length;
|
||||
const averageScore = leads.length > 0
|
||||
? Math.round(leads.reduce((sum, lead) => sum + lead.score, 0) / leads.length)
|
||||
: 0;
|
||||
const totalMessages = messageEntries.reduce((sum, [, messages]) => sum + messages.length, 0);
|
||||
|
||||
return {
|
||||
activeVisitors: Math.min(999, totalMessages),
|
||||
todayLeads: leads.length,
|
||||
closedDeals,
|
||||
conversionRate: leads.length > 0 ? Number(((closedDeals / leads.length) * 100).toFixed(1)) : 0,
|
||||
sentiment: averageScore,
|
||||
systemHealth: {
|
||||
cpu: Math.min(100, 10 + leads.length * 2),
|
||||
gpu: Math.min(100, 5 + Math.round(inventoryCount * 1.5)),
|
||||
memory: Math.min(100, 15 + totalMessages),
|
||||
temperature: Math.min(100, 20 + engagedLeads * 4),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildVelocitySeries(leads: LeadRecord[]) {
|
||||
const buckets = new Map<string, { generated: number; closed: number }>();
|
||||
|
||||
for (let dayOffset = 6; dayOffset >= 0; dayOffset -= 1) {
|
||||
const day = new Date();
|
||||
day.setHours(0, 0, 0, 0);
|
||||
day.setDate(day.getDate() - dayOffset);
|
||||
const key = day.toISOString().slice(0, 10);
|
||||
buckets.set(key, { generated: 0, closed: 0 });
|
||||
}
|
||||
|
||||
for (const lead of leads) {
|
||||
const createdKey = (lead.created_at ?? '').slice(0, 10);
|
||||
const updatedKey = (lead.updated_at ?? lead.created_at ?? '').slice(0, 10);
|
||||
if (buckets.has(createdKey)) {
|
||||
buckets.get(createdKey)!.generated += 1;
|
||||
}
|
||||
if (lead.stage === 'closed' && buckets.has(updatedKey)) {
|
||||
buckets.get(updatedKey)!.closed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(buckets.entries()).map(([key, value]) => ({
|
||||
time: key.slice(5),
|
||||
generated: value.generated,
|
||||
closed: value.closed,
|
||||
}));
|
||||
}
|
||||
34
webos/src/shared/hooks/useKanban.ts
Normal file
34
webos/src/shared/hooks/useKanban.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '@/shared/lib/apiClient';
|
||||
|
||||
/**
|
||||
* useKanban — Pipeline Pillar kanban board data
|
||||
* Returns leads grouped by stage.
|
||||
*/
|
||||
export function useKanban() {
|
||||
const query = useQuery({
|
||||
queryKey: ['kanban'],
|
||||
queryFn: () => api.get<KanbanStage[]>('/crm/pipeline/kanban'),
|
||||
staleTime: 30_000,
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
return { stages: query.data ?? [], isLoading: query.isLoading };
|
||||
}
|
||||
|
||||
export interface KanbanStage {
|
||||
id: string;
|
||||
label: string;
|
||||
emoji: string;
|
||||
leads: KanbanLead[];
|
||||
}
|
||||
|
||||
export interface KanbanLead {
|
||||
id: string;
|
||||
name: string;
|
||||
location?: string;
|
||||
qdScore: number;
|
||||
qdDelta?: number;
|
||||
lastContactRelative: string;
|
||||
lastContactChannel: string;
|
||||
isVaultActive?: boolean;
|
||||
}
|
||||
100
webos/src/shared/hooks/useMediapipeFaceLandmarker.ts
Normal file
100
webos/src/shared/hooks/useMediapipeFaceLandmarker.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import {
|
||||
FaceLandmarker,
|
||||
FilesetResolver,
|
||||
} from '@mediapipe/tasks-vision';
|
||||
|
||||
export interface BlendShapeCategory {
|
||||
categoryName: string;
|
||||
score: number;
|
||||
displayName: string;
|
||||
index: number;
|
||||
}
|
||||
|
||||
export interface FaceLandmarkerResult {
|
||||
faceBlendshapes: Array<{ categories: BlendShapeCategory[] }>;
|
||||
faceLandmarks: Array<Array<{ x: number; y: number; z: number }>>;
|
||||
}
|
||||
|
||||
const MODEL_URL =
|
||||
import.meta.env.VITE_MEDIAPIPE_MODEL_URL ??
|
||||
'/mediapipe/assets/face_landmarker.task';
|
||||
|
||||
const WASM_ROOT =
|
||||
import.meta.env.VITE_MEDIAPIPE_WASM_ROOT ??
|
||||
'/mediapipe/wasm';
|
||||
|
||||
interface UseFaceLandmarkerReturn {
|
||||
isLoading: boolean;
|
||||
isReady: boolean;
|
||||
error: string | null;
|
||||
detectFrame: (
|
||||
videoElement: HTMLVideoElement,
|
||||
timestampMs: number,
|
||||
) => FaceLandmarkerResult | null;
|
||||
}
|
||||
|
||||
export function useMediapipeFaceLandmarker(): UseFaceLandmarkerReturn {
|
||||
const landmarkerRef = useRef<FaceLandmarker | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
const filesetResolver = await FilesetResolver.forVisionTasks(WASM_ROOT);
|
||||
const landmarker = await FaceLandmarker.createFromOptions(filesetResolver, {
|
||||
baseOptions: {
|
||||
modelAssetPath: MODEL_URL,
|
||||
delegate: 'GPU',
|
||||
},
|
||||
outputFaceBlendshapes: true,
|
||||
runningMode: 'VIDEO',
|
||||
numFaces: 3,
|
||||
minFaceDetectionConfidence: 0.65,
|
||||
minFacePresenceConfidence: 0.6,
|
||||
minTrackingConfidence: 0.6,
|
||||
});
|
||||
|
||||
if (cancelled) {
|
||||
landmarker.close();
|
||||
return;
|
||||
}
|
||||
|
||||
landmarkerRef.current = landmarker;
|
||||
setIsLoading(false);
|
||||
setIsReady(true);
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
console.error('[MediaPipe] Initialization failed:', err);
|
||||
setError(err instanceof Error ? err.message : 'MediaPipe failed to initialize.');
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
void init();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
landmarkerRef.current?.close();
|
||||
landmarkerRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const detectFrame = useCallback(
|
||||
(videoElement: HTMLVideoElement, timestampMs: number): FaceLandmarkerResult | null => {
|
||||
if (!landmarkerRef.current || !isReady) return null;
|
||||
try {
|
||||
return landmarkerRef.current.detectForVideo(videoElement, timestampMs) as FaceLandmarkerResult;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[isReady],
|
||||
);
|
||||
|
||||
return { isLoading, isReady, error, detectFrame };
|
||||
}
|
||||
128
webos/src/shared/hooks/useSentinelWebSocket.ts
Normal file
128
webos/src/shared/hooks/useSentinelWebSocket.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { useSentinelStore } from '@/store/sentinelStore';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
|
||||
/**
|
||||
* useSentinelWebSocket
|
||||
* Connects to the Sentinel WebSocket endpoint on core-api.
|
||||
* Translates raw CCTV events into store actions.
|
||||
* The broker-facing UI NEVER sees: WebSocket status, connection state,
|
||||
* raw event types, MediaPipe processing info, or frame IDs.
|
||||
*
|
||||
* Events handled:
|
||||
* visitor_detected → setPendingAlert (triggers SentinelAlertBanner)
|
||||
* session_start → setShowroomActive(true)
|
||||
* qd_update → update session QD score
|
||||
* session_end → setShowroomActive(false)
|
||||
* ai_observation → update session AI observation text
|
||||
*/
|
||||
|
||||
interface RawSentinelEvent {
|
||||
type: 'visitor_detected' | 'session_start' | 'qd_update' | 'session_end' | 'ai_observation';
|
||||
session_id?: string;
|
||||
qd_score?: number;
|
||||
qd_trend?: number;
|
||||
zone?: string;
|
||||
matched_person_id?: string;
|
||||
matched_name?: string;
|
||||
face_confidence?: number;
|
||||
ai_observation?: string;
|
||||
peak_qd?: number;
|
||||
}
|
||||
|
||||
interface LiveSession {
|
||||
sessionId: string;
|
||||
qdScore: number;
|
||||
qdTrend: number;
|
||||
currentZone?: string;
|
||||
aiObservation?: string;
|
||||
peakQd?: number;
|
||||
}
|
||||
|
||||
export function useSentinelWebSocket() {
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const sessionRef = useRef<LiveSession | null>(null);
|
||||
const { token } = useAuthStore.getState();
|
||||
const {
|
||||
setPendingAlert,
|
||||
setShowroomActive,
|
||||
setSessionDuration,
|
||||
setHasInsights,
|
||||
} = useSentinelStore();
|
||||
|
||||
const connect = useCallback(() => {
|
||||
// wss:// on velocity.local, proxied by Traefik → core-api
|
||||
const wsUrl = `wss://${window.location.host}/ws/sentinel?token=${token}`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onmessage = (evt) => {
|
||||
let event: RawSentinelEvent;
|
||||
try { event = JSON.parse(evt.data); }
|
||||
catch { return; }
|
||||
|
||||
switch (event.type) {
|
||||
case 'visitor_detected':
|
||||
setPendingAlert({
|
||||
id: String(Date.now()),
|
||||
matchedName: event.matched_name,
|
||||
matchedPersonId:event.matched_person_id,
|
||||
confidence: event.face_confidence,
|
||||
zone: event.zone ?? 'Entrance',
|
||||
timestamp: new Date(),
|
||||
});
|
||||
break;
|
||||
|
||||
case 'session_start':
|
||||
sessionRef.current = {
|
||||
sessionId: event.session_id!,
|
||||
qdScore: 0,
|
||||
qdTrend: 0,
|
||||
};
|
||||
setShowroomActive(true, event.session_id);
|
||||
break;
|
||||
|
||||
case 'qd_update':
|
||||
if (sessionRef.current) {
|
||||
sessionRef.current = {
|
||||
...sessionRef.current,
|
||||
qdScore: event.qd_score ?? 0,
|
||||
qdTrend: event.qd_trend ?? 0,
|
||||
currentZone: event.zone,
|
||||
peakQd: Math.max(sessionRef.current.peakQd ?? 0, event.qd_score ?? 0),
|
||||
};
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ai_observation':
|
||||
if (sessionRef.current) {
|
||||
sessionRef.current = {
|
||||
...sessionRef.current,
|
||||
aiObservation: event.ai_observation,
|
||||
};
|
||||
setHasInsights(true);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'session_end':
|
||||
setShowroomActive(false);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => { /* silent — broker never sees connection errors */ };
|
||||
ws.onclose = (e) => {
|
||||
if (e.code !== 1000) {
|
||||
// Reconnect after 3s on unexpected disconnect
|
||||
setTimeout(connect, 3000);
|
||||
}
|
||||
};
|
||||
}, [token, setPendingAlert, setShowroomActive, setHasInsights]);
|
||||
|
||||
useEffect(() => {
|
||||
connect();
|
||||
return () => { wsRef.current?.close(1000, 'component unmount'); };
|
||||
}, [connect]);
|
||||
|
||||
return { session: sessionRef.current };
|
||||
}
|
||||
51
webos/src/shared/hooks/useStudio.ts
Normal file
51
webos/src/shared/hooks/useStudio.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '@/shared/lib/apiClient';
|
||||
|
||||
/**
|
||||
* useStudioProperties — Studio Pillar property listing
|
||||
*/
|
||||
export function useStudioProperties() {
|
||||
const query = useQuery({
|
||||
queryKey: ['studio-properties'],
|
||||
queryFn: () => api.get<StudioProperty[]>('/inventory/properties'),
|
||||
staleTime: 120_000,
|
||||
});
|
||||
return { properties: query.data ?? [], isLoading: query.isLoading };
|
||||
}
|
||||
|
||||
/**
|
||||
* useProperty — single property entity with full details
|
||||
*/
|
||||
export function useProperty(propertyId: string) {
|
||||
const query = useQuery({
|
||||
queryKey: ['property', propertyId],
|
||||
queryFn: () => api.get<PropertyDetail>(`/inventory/properties/${propertyId}`),
|
||||
staleTime: 120_000,
|
||||
enabled: !!propertyId,
|
||||
});
|
||||
return { property: query.data, isLoading: query.isLoading };
|
||||
}
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────
|
||||
export interface StudioProperty {
|
||||
id: string;
|
||||
name: string;
|
||||
location: string;
|
||||
priceRange?: string;
|
||||
thumbnailUrl?: string;
|
||||
availableUnits?: number;
|
||||
}
|
||||
|
||||
export interface PropertyDetail {
|
||||
id: string;
|
||||
name: string;
|
||||
config: string;
|
||||
area: string;
|
||||
price: string;
|
||||
description?: string;
|
||||
thumbnailUrl?: string;
|
||||
interiorImageUrl?: string;
|
||||
modelUrl?: string; // GLB/GLTF for R3F
|
||||
images?: string[];
|
||||
amenities?: string[];
|
||||
}
|
||||
145
webos/src/shared/hooks/useVelocitySocket.ts
Normal file
145
webos/src/shared/hooks/useVelocitySocket.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { useStore } from '@/store/useStore';
|
||||
import { WS_URL } from '@/lib/api';
|
||||
import type { QDScoreUpdate, VaultOpenedEvent } from '@/types';
|
||||
|
||||
const SENTINEL_WS_ROOT = `${WS_URL}/api/sentinel/ws`;
|
||||
|
||||
type WsEventType =
|
||||
| 'WS_ASSET_OPENED'
|
||||
| 'QD_UPDATED'
|
||||
| 'LEAD_TAGGED'
|
||||
| 'system'
|
||||
| 'ack';
|
||||
|
||||
interface WsMessage {
|
||||
type: WsEventType;
|
||||
data?: Record<string, unknown>;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
interface UseVelocitySocketOptions {
|
||||
channel?: 'notifications' | 'perception';
|
||||
onConnect?: () => void;
|
||||
onDisconnect?: () => void;
|
||||
onMessage?: (msg: WsMessage) => void;
|
||||
}
|
||||
|
||||
export function useVelocitySocket(options: UseVelocitySocketOptions = {}) {
|
||||
const { channel = 'notifications', onConnect, onDisconnect, onMessage } = options;
|
||||
const { addNotification } = useStore();
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const retryCountRef = useRef(0);
|
||||
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pendingBufferRef = useRef<string[]>([]);
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
const handleMessage = useCallback(
|
||||
(event: MessageEvent) => {
|
||||
let msg: WsMessage;
|
||||
try {
|
||||
msg = JSON.parse(event.data as string) as WsMessage;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
onMessage?.(msg);
|
||||
|
||||
switch (msg.type) {
|
||||
case 'WS_ASSET_OPENED': {
|
||||
const d = msg.data as Partial<VaultOpenedEvent>;
|
||||
addNotification({
|
||||
type: 'velocity_link_opened',
|
||||
title: 'Velocity Link Opened',
|
||||
body: `${d.lead_name ?? 'A prospect'} just opened ${d.asset_name ?? 'your asset'}.`,
|
||||
leadId: d.lead_id,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'QD_UPDATED': {
|
||||
const d = msg.data as Partial<QDScoreUpdate>;
|
||||
if ((d.qd_score ?? 0) >= 75) {
|
||||
addNotification({
|
||||
type: 'qd_spike',
|
||||
title: 'QD Score Spike',
|
||||
body: `QD Score jumped to ${d.qd_score}. ${d.reasoning ?? ''}`.trim(),
|
||||
leadId: d.lead_id,
|
||||
qdScore: d.qd_score,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'LEAD_TAGGED': {
|
||||
const d = msg.data as { lead_id?: string; lead_name?: string; tags?: string[] };
|
||||
if (d.tags?.length) {
|
||||
addNotification({
|
||||
type: 'lead_tagged',
|
||||
title: 'Lead Tagged',
|
||||
body: `${d.lead_name ?? 'Lead'} tagged as ${d.tags.join(', ')}.`,
|
||||
leadId: d.lead_id,
|
||||
tags: d.tags,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
[addNotification, onMessage],
|
||||
);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
const url = `${SENTINEL_WS_ROOT}/${channel}`;
|
||||
const ws = new WebSocket(url);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
retryCountRef.current = 0;
|
||||
pendingBufferRef.current.forEach((msg) => ws.send(msg));
|
||||
pendingBufferRef.current = [];
|
||||
onConnect?.();
|
||||
};
|
||||
|
||||
ws.onmessage = handleMessage;
|
||||
|
||||
ws.onclose = () => {
|
||||
onDisconnect?.();
|
||||
if (!isMountedRef.current) return;
|
||||
if (retryCountRef.current >= 5) return;
|
||||
const delay = Math.min(1000 * 2 ** retryCountRef.current, 30_000);
|
||||
retryCountRef.current += 1;
|
||||
retryTimerRef.current = setTimeout(connect, delay);
|
||||
};
|
||||
|
||||
ws.onerror = () => ws.close();
|
||||
}, [channel, handleMessage, onConnect, onDisconnect]);
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
|
||||
wsRef.current?.close();
|
||||
};
|
||||
}, [connect]);
|
||||
|
||||
const sendPacket = useCallback((payload: unknown) => {
|
||||
const str = JSON.stringify(payload);
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(str);
|
||||
} else {
|
||||
pendingBufferRef.current.push(str);
|
||||
if (pendingBufferRef.current.length > 100) {
|
||||
pendingBufferRef.current.shift();
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { sendPacket };
|
||||
}
|
||||
26
webos/src/shared/layout/AdminGuard.tsx
Normal file
26
webos/src/shared/layout/AdminGuard.tsx
Normal 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}</>;
|
||||
}
|
||||
17
webos/src/shared/layout/AuthGuard.tsx
Normal file
17
webos/src/shared/layout/AuthGuard.tsx
Normal 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}</>;
|
||||
}
|
||||
21
webos/src/shared/layout/AuthenticatedShell.module.css
Normal file
21
webos/src/shared/layout/AuthenticatedShell.module.css
Normal 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;
|
||||
}
|
||||
83
webos/src/shared/layout/AuthenticatedShell.tsx
Normal file
83
webos/src/shared/layout/AuthenticatedShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
132
webos/src/shared/layout/LoginPage.module.css
Normal file
132
webos/src/shared/layout/LoginPage.module.css
Normal 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;
|
||||
}
|
||||
114
webos/src/shared/layout/LoginPage.tsx
Normal file
114
webos/src/shared/layout/LoginPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
66
webos/src/shared/layout/NavRail.module.css
Normal file
66
webos/src/shared/layout/NavRail.module.css
Normal 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; }
|
||||
188
webos/src/shared/layout/NavRail.tsx
Normal file
188
webos/src/shared/layout/NavRail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
62
webos/src/shared/layout/PillarSkeleton.tsx
Normal file
62
webos/src/shared/layout/PillarSkeleton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
71
webos/src/shared/layout/VaultPublicPage.tsx
Normal file
71
webos/src/shared/layout/VaultPublicPage.tsx
Normal 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
128
webos/src/shared/lib/api.ts
Normal 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');
|
||||
}
|
||||
52
webos/src/shared/lib/apiClient.ts
Normal file
52
webos/src/shared/lib/apiClient.ts
Normal 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' }),
|
||||
};
|
||||
89
webos/src/shared/lib/commsApi.ts
Normal file
89
webos/src/shared/lib/commsApi.ts
Normal 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 }),
|
||||
});
|
||||
310
webos/src/shared/lib/crmApi.ts
Normal file
310
webos/src/shared/lib/crmApi.ts
Normal 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;
|
||||
}
|
||||
122
webos/src/shared/lib/crmMappers.ts
Normal file
122
webos/src/shared/lib/crmMappers.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
197
webos/src/shared/lib/dreamWeaverApi.ts
Normal file
197
webos/src/shared/lib/dreamWeaverApi.ts
Normal 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
Reference in New Issue
Block a user