feat: Built the Oracle Tab1 (#14)

#13 Built the complete Oracle Tab with all the functionalities.

Co-authored-by: Sayan Datta <sayan@Sayans-MacBook-Air.local>
Reviewed-on: #14
This commit was merged in pull request #14.
This commit is contained in:
2026-04-11 19:35:45 +05:30
parent 8e1ffe0e43
commit f78655debc
54 changed files with 10651 additions and 818 deletions

4
app/dist/index.html vendored
View File

@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Velocity WebOS</title>
<script type="module" crossorigin src="./assets/index-DWXNQJq4.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-y9vEdTGy.css">
<script type="module" crossorigin src="./assets/index-PSY8fNXf.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-tj5O5w9J.css">
</head>
<body>
<div id="root"></div>

View File

@@ -1 +1 @@
{"root":["../../src/app.tsx","../../src/main.tsx","../../src/app/oracle/page.tsx","../../src/components/layout/loginscreen.tsx","../../src/components/layout/sidebar.tsx","../../src/components/modules/catalyst.tsx","../../src/components/modules/dashboard.tsx","../../src/components/modules/inventory.tsx","../../src/components/modules/oracle.tsx","../../src/components/modules/sentinel.tsx","../../src/components/modules/settings.tsx","../../src/components/oracle/leadinspector.tsx","../../src/components/oracle/pipelineview.tsx","../../src/components/oracle/mockleads.ts","../../src/components/sentinel/journeyriver/inspectorpanel.tsx","../../src/components/sentinel/journeyriver/riverpath.tsx","../../src/components/sentinel/journeyriver/index.tsx","../../src/components/ui/accordion.tsx","../../src/components/ui/alert-dialog.tsx","../../src/components/ui/alert.tsx","../../src/components/ui/aspect-ratio.tsx","../../src/components/ui/avatar.tsx","../../src/components/ui/badge.tsx","../../src/components/ui/breadcrumb.tsx","../../src/components/ui/button-group.tsx","../../src/components/ui/button.tsx","../../src/components/ui/calendar.tsx","../../src/components/ui/card.tsx","../../src/components/ui/carousel.tsx","../../src/components/ui/chart.tsx","../../src/components/ui/checkbox.tsx","../../src/components/ui/collapsible.tsx","../../src/components/ui/command.tsx","../../src/components/ui/context-menu.tsx","../../src/components/ui/dialog.tsx","../../src/components/ui/drawer.tsx","../../src/components/ui/dropdown-menu.tsx","../../src/components/ui/empty.tsx","../../src/components/ui/field.tsx","../../src/components/ui/form.tsx","../../src/components/ui/hover-card.tsx","../../src/components/ui/input-group.tsx","../../src/components/ui/input-otp.tsx","../../src/components/ui/input.tsx","../../src/components/ui/item.tsx","../../src/components/ui/kbd.tsx","../../src/components/ui/label.tsx","../../src/components/ui/menubar.tsx","../../src/components/ui/navigation-menu.tsx","../../src/components/ui/pagination.tsx","../../src/components/ui/popover.tsx","../../src/components/ui/progress.tsx","../../src/components/ui/radio-group.tsx","../../src/components/ui/resizable.tsx","../../src/components/ui/scroll-area.tsx","../../src/components/ui/select.tsx","../../src/components/ui/separator.tsx","../../src/components/ui/sheet.tsx","../../src/components/ui/sidebar.tsx","../../src/components/ui/skeleton.tsx","../../src/components/ui/slider.tsx","../../src/components/ui/sonner.tsx","../../src/components/ui/spinner.tsx","../../src/components/ui/switch.tsx","../../src/components/ui/table.tsx","../../src/components/ui/tabs.tsx","../../src/components/ui/textarea.tsx","../../src/components/ui/toggle-group.tsx","../../src/components/ui/toggle.tsx","../../src/components/ui/tooltip.tsx","../../src/hooks/use-mobile.ts","../../src/lib/oraclequeryclient.ts","../../src/lib/utils.ts","../../src/store/usemarketingstore.ts","../../src/store/usestore.ts","../../src/types/crm.ts","../../src/types/index.ts","../../src/utils/curvegenerator.ts"],"version":"5.9.3"}
{"root":["../../src/app.tsx","../../src/main.tsx","../../src/app/oracle/page.tsx","../../src/components/layout/loginscreen.tsx","../../src/components/layout/sidebar.tsx","../../src/components/modules/catalyst.tsx","../../src/components/modules/dashboard.tsx","../../src/components/modules/groundtruthpicker.tsx","../../src/components/modules/inventory.tsx","../../src/components/modules/oracle.tsx","../../src/components/modules/sentinel.tsx","../../src/components/modules/settings.tsx","../../src/components/oracle/leadinspector.tsx","../../src/components/oracle/pipelineview.tsx","../../src/components/oracle/mockleads.ts","../../src/components/sentinel/journeyriver/inspectorpanel.tsx","../../src/components/sentinel/journeyriver/riverpath.tsx","../../src/components/sentinel/journeyriver/index.tsx","../../src/components/ui/accordion.tsx","../../src/components/ui/alert-dialog.tsx","../../src/components/ui/alert.tsx","../../src/components/ui/aspect-ratio.tsx","../../src/components/ui/avatar.tsx","../../src/components/ui/badge.tsx","../../src/components/ui/breadcrumb.tsx","../../src/components/ui/button-group.tsx","../../src/components/ui/button.tsx","../../src/components/ui/calendar.tsx","../../src/components/ui/card.tsx","../../src/components/ui/carousel.tsx","../../src/components/ui/chart.tsx","../../src/components/ui/checkbox.tsx","../../src/components/ui/collapsible.tsx","../../src/components/ui/command.tsx","../../src/components/ui/context-menu.tsx","../../src/components/ui/dialog.tsx","../../src/components/ui/drawer.tsx","../../src/components/ui/dropdown-menu.tsx","../../src/components/ui/empty.tsx","../../src/components/ui/field.tsx","../../src/components/ui/form.tsx","../../src/components/ui/hover-card.tsx","../../src/components/ui/input-group.tsx","../../src/components/ui/input-otp.tsx","../../src/components/ui/input.tsx","../../src/components/ui/item.tsx","../../src/components/ui/kbd.tsx","../../src/components/ui/label.tsx","../../src/components/ui/menubar.tsx","../../src/components/ui/navigation-menu.tsx","../../src/components/ui/pagination.tsx","../../src/components/ui/popover.tsx","../../src/components/ui/progress.tsx","../../src/components/ui/radio-group.tsx","../../src/components/ui/resizable.tsx","../../src/components/ui/scroll-area.tsx","../../src/components/ui/select.tsx","../../src/components/ui/separator.tsx","../../src/components/ui/sheet.tsx","../../src/components/ui/sidebar.tsx","../../src/components/ui/skeleton.tsx","../../src/components/ui/slider.tsx","../../src/components/ui/sonner.tsx","../../src/components/ui/spinner.tsx","../../src/components/ui/switch.tsx","../../src/components/ui/table.tsx","../../src/components/ui/tabs.tsx","../../src/components/ui/textarea.tsx","../../src/components/ui/toggle-group.tsx","../../src/components/ui/toggle.tsx","../../src/components/ui/tooltip.tsx","../../src/hooks/use-mobile.ts","../../src/lib/oraclequeryclient.ts","../../src/lib/utils.ts","../../src/oracle/components/branchbar.tsx","../../src/oracle/components/canvasviewport.tsx","../../src/oracle/components/componentregistry.tsx","../../src/oracle/components/promptrail.tsx","../../src/oracle/components/rollbackconfirmmodal.tsx","../../src/oracle/components/sharemodal.tsx","../../src/oracle/components/renderers/activitystreamrenderer.tsx","../../src/oracle/components/renderers/barchartrenderer.tsx","../../src/oracle/components/renderers/errornoticerenderer.tsx","../../src/oracle/components/renderers/geomaprenderer.tsx","../../src/oracle/components/renderers/kpitilerenderer.tsx","../../src/oracle/components/renderers/linechartrenderer.tsx","../../src/oracle/components/renderers/pipelineboardrenderer.tsx","../../src/oracle/components/renderers/rendererwrapper.tsx","../../src/oracle/components/renderers/tablerenderer.tsx","../../src/oracle/components/renderers/timelinerenderer.tsx","../../src/oracle/components/review/mergereviewdrawer.tsx","../../src/oracle/hooks/useoracleexecution.ts","../../src/oracle/hooks/useoraclepage.ts","../../src/oracle/lib/oracleapiclient.ts","../../src/oracle/lib/oracledemodata.ts","../../src/oracle/types/canvas.ts","../../src/store/usecurrencystore.ts","../../src/store/usemarketingstore.ts","../../src/store/usestore.ts","../../src/types/crm.ts","../../src/types/index.ts","../../src/utils/curvegenerator.ts"],"version":"5.9.3"}

View File

@@ -2,14 +2,14 @@
import {
createSlot
} from "./chunk-YWBEB5PG.js";
import {
require_shim
} from "./chunk-TXHHHGR3.js";
import {
useCallbackRef,
useLayoutEffect2
} from "./chunk-23FVUG5N.js";
import "./chunk-2VUH7NEY.js";
import {
require_shim
} from "./chunk-TXHHHGR3.js";
import {
require_react_dom
} from "./chunk-YF4B4G2L.js";

View File

@@ -1,9 +1,9 @@
import {
require_client
} from "./chunk-6MXH2QM6.js";
import {
subscribeWithSelector
} from "./chunk-O4L7C4YS.js";
import {
create
} from "./chunk-7GZ4CI6Q.js";
import {
Events
} from "./chunk-OAEA5FZL.js";
@@ -22,8 +22,8 @@ import {
useInstanceHandle,
useLoader,
useThree
} from "./chunk-O5V7GNMB.js";
import "./chunk-GUQHL3N7.js";
} from "./chunk-5ESDTKMP.js";
import "./chunk-NJ4V5H3P.js";
import {
AddEquation,
AdditiveBlending,
@@ -219,9 +219,9 @@ import {
ZeroFactor
} from "./chunk-L3Z576C2.js";
import {
create
} from "./chunk-7GZ4CI6Q.js";
import "./chunk-NJ4V5H3P.js";
require_client
} from "./chunk-6MXH2QM6.js";
import "./chunk-GUQHL3N7.js";
import {
_extends
} from "./chunk-EQCCHGRT.js";

View File

@@ -28,10 +28,10 @@ import {
useLoader,
useStore,
useThree
} from "./chunk-O5V7GNMB.js";
import "./chunk-GUQHL3N7.js";
import "./chunk-L3Z576C2.js";
} from "./chunk-5ESDTKMP.js";
import "./chunk-NJ4V5H3P.js";
import "./chunk-L3Z576C2.js";
import "./chunk-GUQHL3N7.js";
import "./chunk-TXHHHGR3.js";
import "./chunk-2YVA4HRZ.js";
import "./chunk-WUR7D6NS.js";

View File

@@ -2,126 +2,132 @@
"hash": "b2c5007d",
"configHash": "d9a82a01",
"lockfileHash": "8a04eea8",
"browserHash": "5d6343ae",
"browserHash": "c208d4ff",
"optimized": {
"react": {
"src": "../../react/index.js",
"file": "react.js",
"fileHash": "4ea9824e",
"fileHash": "a26efb1d",
"needsInterop": true
},
"react-dom": {
"src": "../../react-dom/index.js",
"file": "react-dom.js",
"fileHash": "5b549105",
"fileHash": "c6e68a6f",
"needsInterop": true
},
"react/jsx-dev-runtime": {
"src": "../../react/jsx-dev-runtime.js",
"file": "react_jsx-dev-runtime.js",
"fileHash": "41193e59",
"fileHash": "7531df54",
"needsInterop": true
},
"react/jsx-runtime": {
"src": "../../react/jsx-runtime.js",
"file": "react_jsx-runtime.js",
"fileHash": "ad8008f8",
"fileHash": "a247453f",
"needsInterop": true
},
"@radix-ui/react-avatar": {
"src": "../../@radix-ui/react-avatar/dist/index.mjs",
"file": "@radix-ui_react-avatar.js",
"fileHash": "e6d8d406",
"fileHash": "d86de0d6",
"needsInterop": false
},
"@radix-ui/react-dropdown-menu": {
"src": "../../@radix-ui/react-dropdown-menu/dist/index.mjs",
"file": "@radix-ui_react-dropdown-menu.js",
"fileHash": "26caaf75",
"fileHash": "51cbea4a",
"needsInterop": false
},
"@radix-ui/react-slot": {
"src": "../../@radix-ui/react-slot/dist/index.mjs",
"file": "@radix-ui_react-slot.js",
"fileHash": "e034d698",
"fileHash": "d49e4181",
"needsInterop": false
},
"@react-three/drei": {
"src": "../../@react-three/drei/index.js",
"file": "@react-three_drei.js",
"fileHash": "d81d1332",
"fileHash": "49af0e0c",
"needsInterop": false
},
"@react-three/fiber": {
"src": "../../@react-three/fiber/dist/react-three-fiber.esm.js",
"file": "@react-three_fiber.js",
"fileHash": "dcc73392",
"fileHash": "6e6f65b1",
"needsInterop": false
},
"class-variance-authority": {
"src": "../../class-variance-authority/dist/index.mjs",
"file": "class-variance-authority.js",
"fileHash": "5fa3c3f8",
"fileHash": "0e514934",
"needsInterop": false
},
"clsx": {
"src": "../../clsx/dist/clsx.mjs",
"file": "clsx.js",
"fileHash": "1428051e",
"fileHash": "e71ef32c",
"needsInterop": false
},
"framer-motion": {
"src": "../../framer-motion/dist/es/index.mjs",
"file": "framer-motion.js",
"fileHash": "dd6ef86d",
"fileHash": "5b5818ee",
"needsInterop": false
},
"lucide-react": {
"src": "../../lucide-react/dist/esm/lucide-react.js",
"file": "lucide-react.js",
"fileHash": "df6c668f",
"fileHash": "fb6a8921",
"needsInterop": false
},
"react-dom/client": {
"src": "../../react-dom/client.js",
"file": "react-dom_client.js",
"fileHash": "23087df0",
"fileHash": "032f0a73",
"needsInterop": true
},
"react-router-dom": {
"src": "../../react-router-dom/dist/index.mjs",
"file": "react-router-dom.js",
"fileHash": "48fbca8f",
"fileHash": "fa45c285",
"needsInterop": false
},
"recharts": {
"src": "../../recharts/es6/index.js",
"file": "recharts.js",
"fileHash": "9faf094d",
"fileHash": "b3d6765a",
"needsInterop": false
},
"tailwind-merge": {
"src": "../../tailwind-merge/dist/bundle-mjs.mjs",
"file": "tailwind-merge.js",
"fileHash": "fc48e7d8",
"fileHash": "47183e49",
"needsInterop": false
},
"three": {
"src": "../../three/build/three.module.js",
"file": "three.js",
"fileHash": "a0b83871",
"fileHash": "d78c89ed",
"needsInterop": false
},
"zustand": {
"src": "../../zustand/esm/index.mjs",
"file": "zustand.js",
"fileHash": "ad25a55f",
"fileHash": "39f09270",
"needsInterop": false
},
"zustand/middleware": {
"src": "../../zustand/esm/middleware.mjs",
"file": "zustand_middleware.js",
"fileHash": "b84fa2e5",
"fileHash": "ce09abfc",
"needsInterop": false
},
"sonner": {
"src": "../../sonner/dist/index.mjs",
"file": "sonner.js",
"fileHash": "b1e28aee",
"needsInterop": false
}
},
@@ -135,45 +141,45 @@
"chunk-U7P2NEEE": {
"file": "chunk-U7P2NEEE.js"
},
"chunk-O4L7C4YS": {
"file": "chunk-O4L7C4YS.js"
},
"chunk-7GZ4CI6Q": {
"file": "chunk-7GZ4CI6Q.js"
},
"chunk-OAEA5FZL": {
"file": "chunk-OAEA5FZL.js"
},
"chunk-5ESDTKMP": {
"file": "chunk-5ESDTKMP.js"
},
"chunk-NJ4V5H3P": {
"file": "chunk-NJ4V5H3P.js"
},
"chunk-L3Z576C2": {
"file": "chunk-L3Z576C2.js"
},
"chunk-6MXH2QM6": {
"file": "chunk-6MXH2QM6.js"
},
"chunk-GUQHL3N7": {
"file": "chunk-GUQHL3N7.js"
},
"chunk-EQCCHGRT": {
"file": "chunk-EQCCHGRT.js"
},
"chunk-YWBEB5PG": {
"file": "chunk-YWBEB5PG.js"
},
"chunk-TXHHHGR3": {
"file": "chunk-TXHHHGR3.js"
},
"chunk-23FVUG5N": {
"file": "chunk-23FVUG5N.js"
},
"chunk-2VUH7NEY": {
"file": "chunk-2VUH7NEY.js"
},
"chunk-6MXH2QM6": {
"file": "chunk-6MXH2QM6.js"
},
"chunk-O4L7C4YS": {
"file": "chunk-O4L7C4YS.js"
},
"chunk-OAEA5FZL": {
"file": "chunk-OAEA5FZL.js"
},
"chunk-O5V7GNMB": {
"file": "chunk-O5V7GNMB.js"
},
"chunk-GUQHL3N7": {
"file": "chunk-GUQHL3N7.js"
},
"chunk-L3Z576C2": {
"file": "chunk-L3Z576C2.js"
},
"chunk-7GZ4CI6Q": {
"file": "chunk-7GZ4CI6Q.js"
},
"chunk-NJ4V5H3P": {
"file": "chunk-NJ4V5H3P.js"
},
"chunk-EQCCHGRT": {
"file": "chunk-EQCCHGRT.js"
},
"chunk-TXHHHGR3": {
"file": "chunk-TXHHHGR3.js"
},
"chunk-YF4B4G2L": {
"file": "chunk-YF4B4G2L.js"
},

View File

@@ -96,7 +96,7 @@ function MainLayout() {
{/* Main Content Area */}
<motion.main
className="flex-1 min-h-screen overflow-auto custom-scrollbar"
className="flex-1 h-screen flex flex-col overflow-hidden"
initial={{ marginLeft: 72 }}
animate={{ marginLeft: sidebarExpanded ? 232 : 72 }}
transition={{
@@ -107,7 +107,7 @@ function MainLayout() {
}}
>
{/* Top Bar */}
<header className="sticky top-0 z-40 px-6 py-4">
<header className="flex-none z-40 px-6 py-4">
<div
className="flex items-center justify-between px-5 py-3 rounded-2xl"
style={{
@@ -166,9 +166,10 @@ function MainLayout() {
</header>
{/* Module Content — animated on route change */}
<div className="px-8 pb-8">
<AnimatePresence mode="wait">
<motion.div
<div className="flex-1 overflow-y-auto custom-scrollbar">
<div className="px-8 pb-8 min-h-full relative">
<AnimatePresence mode="wait">
<motion.div
key={location.pathname}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
@@ -190,6 +191,7 @@ function MainLayout() {
</Routes>
</motion.div>
</AnimatePresence>
</div>
</div>
</motion.main>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@ import { motion, AnimatePresence } from 'framer-motion';
import {
Megaphone, Clapperboard, BarChart3, Globe, Settings2,
Zap, TrendingUp, Eye, MousePointerClick, DollarSign,
Upload, Play, Image, Film, RefreshCw, ArrowRight, Plus,
Upload, Play, Image, Film, RefreshCw, ArrowRight, Plus, X,
AlertTriangle, ArrowRightLeft, PlusCircle, SlidersHorizontal,
Activity, Check, Link2,
type LucideIcon,
@@ -13,7 +13,10 @@ import {
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
} from 'recharts';
import { useMarketingStore } from '@/store/useMarketingStore';
import { useCurrency } from '@/store/useCurrencyStore';
import type { Campaign, MarketingAsset, LiveOptimizationEvent, LiveEventType } from '@/types';
import { GroundTruthPicker } from './GroundTruthPicker';
import type { GroundTruthSelection } from './GroundTruthPicker';
// ── Design tokens ─────────────────────────────────────────────────────────────
const GLASS = {
@@ -243,22 +246,151 @@ function AssetCard({ asset }: { asset: MarketingAsset }) {
);
}
// ── Reference Slot ───────────────────────────────────────────────────────────
interface RefSelection { name: string; preview: string; }
function ReferenceSlot({ value, onSelect, onClear, onRemove }: {
value: RefSelection | null;
onSelect: (sel: RefSelection) => void;
onClear: () => void;
onRemove?: () => void;
}) {
const inputRef = useRef<HTMLInputElement>(null);
function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
onSelect({ name: file.name, preview: URL.createObjectURL(file) });
e.target.value = '';
}
// Show X when: slot has content (clear it), or slot is removable (remove it)
const showX = !!value || !!onRemove;
const handleX = (e: React.MouseEvent) => {
e.stopPropagation();
if (onRemove) onRemove();
else onClear();
};
return (
<div className="relative w-[60px] h-[60px]">
<motion.button
className="w-[60px] h-[60px] rounded-2xl flex flex-col items-center justify-center gap-1 overflow-hidden transition-colors"
style={{
background: value?.preview ? 'transparent' : 'rgba(255,255,255,0.03)',
border: '1px solid rgba(255,255,255,0.08)',
}}
onClick={() => inputRef.current?.click()}
whileHover={{ scale: 1.04 }}
whileTap={{ scale: 0.96 }}
title="Add reference image"
>
{value?.preview ? (
<img src={value.preview} alt={value.name} className="w-full h-full object-cover" />
) : value?.name ? (
<>
<Check className="w-4 h-4 text-blue-400" />
<span className="text-[8px] text-blue-400 font-medium text-center px-1 leading-tight line-clamp-2">{value.name}</span>
</>
) : (
<span className="text-xs font-medium" style={{ color: 'rgba(255,255,255,0.2)' }}>+</span>
)}
</motion.button>
<AnimatePresence>
{showX && (
<motion.button
className="absolute -top-2 -right-3 w-4 h-4 rounded-full flex items-center justify-center z-10"
style={{ background: 'rgba(20,20,30,0.95)', border: '1px solid rgba(255,255,255,0.22)' }}
onClick={handleX}
initial={{ opacity: 0, scale: 0.6 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.6 }}
transition={{ duration: 0.12 }}
whileHover={{ scale: 1.2, backgroundColor: 'rgba(239,68,68,0.8)' }}
title={onRemove ? 'Remove slot' : 'Clear selection'}
>
<X className="w-2 h-2 text-white" />
</motion.button>
)}
</AnimatePresence>
<input ref={inputRef} type="file" accept="image/*,video/*" className="hidden" onChange={handleFile} />
</div>
);
}
function WorkflowInput() {
const [mode, setMode] = useState<'image' | 'video'>('image');
const [prompt, setPrompt] = useState('');
const [keywords, setKeywords] = useState('');
const [textCopy, setTextCopy] = useState('');
const [groundTruth, setGroundTruth] = useState<GroundTruthSelection | null>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const [refs, setRefs] = useState<(RefSelection | null)[]>([null, null]);
const anchorRef = useRef<HTMLButtonElement>(null);
return (
<Widget delay={0.04} colSpan={1} className="!p-6" style={{ background: '#111216', borderRadius: '28px' }}>
<Widget delay={0.04} colSpan={1} className="!p-6 !overflow-visible" style={{ background: '#111216', borderRadius: '28px' }}>
<div className="flex flex-col gap-6">
{/* Top Section: Ground Truth & References */}
<div className="flex items-end gap-6">
{/* Ground Truth slot */}
<div className="flex flex-col gap-2">
<div className="flex items-center gap-3">
<button title="Select Image from Gallery or Dream Weaver / Click Image from iPad" className="w-[60px] h-[60px] rounded-2xl flex items-center justify-center transition-colors hover:bg-white/5" style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.08)' }}>
<span className="text-xs font-medium" style={{ color: 'rgba(255,255,255,0.2)' }}>Start</span>
</button>
<div className="relative">
<motion.button
ref={anchorRef}
onClick={() => setPickerOpen(v => !v)}
className="w-[60px] h-[60px] rounded-2xl flex flex-col items-center justify-center gap-1 overflow-hidden transition-colors"
style={{
background: groundTruth?.preview ? 'transparent' : pickerOpen ? 'rgba(59,130,246,0.12)' : 'rgba(255,255,255,0.03)',
border: pickerOpen ? '1px solid rgba(59,130,246,0.4)' : '1px solid rgba(255,255,255,0.08)',
}}
whileHover={{ scale: 1.04 }}
whileTap={{ scale: 0.96 }}
title="Select Ground Truth image"
>
{groundTruth?.preview ? (
<img src={groundTruth.preview} alt="ground truth" className="w-full h-full object-cover" />
) : groundTruth?.name ? (
<>
<Check className="w-4 h-4 text-green-400" />
<span className="text-[8px] text-green-400 font-medium text-center px-1 leading-tight line-clamp-2">{groundTruth.name}</span>
</>
) : (
<span className="text-xs font-medium" style={{ color: 'rgba(255,255,255,0.2)' }}>{pickerOpen ? '✕' : '+'}</span>
)}
</motion.button>
{/* Clear selection button */}
<AnimatePresence>
{groundTruth && (
<motion.button
className="absolute -top-2 right-2 w-4 h-4 rounded-full flex items-center justify-center z-10"
style={{ background: 'rgba(20,20,30,0.95)', border: '1px solid rgba(255,255,255,0.22)' }}
onClick={(e) => { e.stopPropagation(); setGroundTruth(null); setPickerOpen(false); }}
initial={{ opacity: 0, scale: 0.6 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.6 }}
transition={{ duration: 0.12 }}
whileHover={{ scale: 1.2, backgroundColor: 'rgba(239,68,68,0.8)' }}
title="Remove selection"
>
<X className="w-2 h-2 text-white" />
</motion.button>
)}
</AnimatePresence>
<AnimatePresence>
{pickerOpen && (
<GroundTruthPicker
anchorRef={anchorRef}
onSelect={sel => { setGroundTruth(sel); setPickerOpen(false); }}
onClose={() => setPickerOpen(false)}
/>
)}
</AnimatePresence>
</div>
<span className="text-xs font-semibold text-white tracking-wide">Ground Truth</span>
</div>
@@ -269,11 +401,25 @@ function WorkflowInput() {
<div className="flex flex-col gap-2">
<div className="flex items-center gap-3">
<button title="Add reference image" className="w-[60px] h-[60px] rounded-2xl flex items-center justify-center transition-colors hover:bg-white/5" style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.08)' }} />
<button title="Add reference image" className="w-[60px] h-[60px] rounded-2xl flex items-center justify-center transition-colors hover:bg-white/5" style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.08)' }} />
<button title="Add more references" className="w-[60px] h-[60px] rounded-2xl flex items-center justify-center transition-colors hover:bg-white/5" style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.08)' }}>
{refs.map((ref, i) => (
<ReferenceSlot
key={i}
value={ref}
onSelect={(sel) => setRefs(prev => prev.map((r, idx) => idx === i ? sel : r))}
onClear={() => setRefs(prev => prev.map((r, idx) => idx === i ? null : r))}
onRemove={i >= 2 ? () => setRefs(prev => prev.filter((_, idx) => idx !== i)) : undefined}
/>
))}
<motion.button
title="Add more references"
className="w-[60px] h-[60px] rounded-2xl flex items-center justify-center transition-colors"
style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.08)' }}
onClick={() => setRefs(prev => prev.length < 6 ? [...prev, null] : prev)}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<Plus className="w-5 h-5 text-white/40" />
</button>
</motion.button>
</div>
<span className="text-xs font-semibold text-white tracking-wide">References</span>
</div>
@@ -389,6 +535,7 @@ function TheStudio() {
function CampaignCommand() {
const { campaigns, adInsights } = useMarketingStore();
const { formatAmount, rate } = useCurrency();
const totalSpend = campaigns.reduce((s, c) => s + c.lifetimeSpend / 100, 0);
const totalImpr = campaigns.reduce((s, c) => s + c.impressions, 0);
@@ -398,7 +545,7 @@ function CampaignCommand() {
// Build spend-over-time from insights (last 14 days)
const spendData = adInsights.slice(0, 14).map((d) => ({
date: d.date.slice(5), // MM-DD
spend: d.spend,
spend: Math.round(d.spend * rate),
impressions: Math.round(d.impressions / 1000),
})).reverse();
@@ -407,9 +554,9 @@ function CampaignCommand() {
{/* KPI row */}
<div className="grid grid-cols-2 xl:grid-cols-4 gap-4">
<StatCard icon={Megaphone} label="Active Campaigns" value={String(campaigns.filter(c => c.status === 'ACTIVE').length)} sub={`${campaigns.length} total campaigns`} glowColor="rgba(59,130,246,0.2)" delay={0} />
<StatCard icon={DollarSign} label="Total Spend" value={`AED ${(totalSpend / 1000).toFixed(1)}K`} sub="Lifetime across all campaigns" glowColor="rgba(34,211,238,0.2)" delay={0.06} />
<StatCard icon={DollarSign} label="Total Spend" value={formatAmount(totalSpend, { compact: true })} sub="Lifetime across all campaigns" glowColor="rgba(34,211,238,0.2)" delay={0.06} />
<StatCard icon={Eye} label="Impressions" value={`${(totalImpr / 1000).toFixed(0)}K`} sub="Total ad impressions" glowColor="rgba(251,191,36,0.2)" delay={0.12} />
<StatCard icon={MousePointerClick} label="Avg CTR" value={`${avgCtr.toFixed(2)}%`} sub={`Avg CPA: AED ${avgCpa.toFixed(0)}`} glowColor="rgba(167,139,250,0.2)" delay={0.18} />
<StatCard icon={MousePointerClick} label="Avg CTR" value={`${avgCtr.toFixed(2)}%`} sub={`Avg CPA: ${formatAmount(avgCpa)}`} glowColor="rgba(167,139,250,0.2)" delay={0.18} />
</div>
{/* Spend chart + campaign list */}
@@ -463,7 +610,7 @@ function CampaignCommand() {
<div className="flex-1 min-w-0 mr-3">
<p className="text-sm font-medium text-white truncate">{c.name}</p>
<p className="text-xs mt-0.5" style={{ color: 'rgba(148,163,184,0.5)' }}>
AED {(c.lifetimeSpend / 100).toLocaleString()} · {c.impressions.toLocaleString()} impr.
{formatAmount(c.lifetimeSpend / 100)} · {c.impressions.toLocaleString()} impr.
</p>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
@@ -487,24 +634,25 @@ function CampaignCommand() {
function IntelligenceROI() {
const { campaigns, adInsights } = useMarketingStore();
const { formatAmount, symbol, rate } = useCurrency();
const cpaData = adInsights.slice(0, 7).map((d) => ({
date: d.date.slice(5),
cpa: d.cpa,
cpa: Math.round(d.cpa * rate),
roi: d.roi,
})).reverse();
const adSetPerf = [
{ name: '3BHK Dubai', ctr: 2.1, cpa: 210, spend: 3400 },
{ name: 'PH Retarget', ctr: 3.2, cpa: 2100, spend: 5800 },
{ name: '1BHK Lookalike', ctr: 1.8, cpa: 270, spend: 980 },
{ name: '3BHK Dubai', ctr: 2.1, cpa: Math.round(210 * rate), spend: Math.round(3400 * rate) },
{ name: 'PH Retarget', ctr: 3.2, cpa: Math.round(2100 * rate), spend: Math.round(5800 * rate) },
{ name: '1BHK Lookalike', ctr: 1.8, cpa: Math.round(270 * rate), spend: Math.round(980 * rate) },
];
return (
<div className="space-y-5">
{/* CPA / ROI KPIs */}
<div className="grid grid-cols-2 xl:grid-cols-4 gap-4">
<StatCard icon={DollarSign} label="Avg CPA" value={`AED ${(campaigns.reduce((s,c)=>s+c.cpa,0)/campaigns.length).toFixed(0)}`} sub="Cost per acquisition" glowColor="rgba(34,211,238,0.2)" delay={0} />
<StatCard icon={DollarSign} label="Avg CPA" value={formatAmount(campaigns.reduce((s,c)=>s+c.cpa,0)/campaigns.length)} sub="Cost per acquisition" glowColor="rgba(34,211,238,0.2)" delay={0} />
<StatCard icon={TrendingUp} label="Portfolio ROI" value={`${(campaigns.reduce((s,c)=>s+c.roi,0)/campaigns.length).toFixed(1)}%`} sub="Blended across all ad sets" glowColor="rgba(74,222,128,0.2)" delay={0.06} />
<StatCard icon={Activity} label="Avg CTR" value={`${(campaigns.reduce((s,c)=>s+c.ctr,0)/campaigns.length).toFixed(2)}%`} sub="Click-through rate" glowColor="rgba(167,139,250,0.2)" delay={0.12} />
<StatCard icon={Zap} label="Optimization Runs" value="47" sub="Agent actions today" glowColor="rgba(251,191,36,0.2)" delay={0.18} />
@@ -529,7 +677,7 @@ function IntelligenceROI() {
<XAxis dataKey="date" stroke="rgba(148,163,184,0.3)" fontSize={10} tickLine={false} axisLine={false} />
<YAxis stroke="rgba(148,163,184,0.3)" fontSize={10} tickLine={false} axisLine={false} />
<Tooltip contentStyle={{ backgroundColor: 'rgba(8,10,18,0.92)', border: '1px solid rgba(34,211,238,0.2)', borderRadius: 10 }} labelStyle={{ color: 'rgba(148,163,184,0.8)', fontSize: 11 }} itemStyle={{ color: '#67e8f9', fontSize: 12 }} />
<Area type="monotone" dataKey="cpa" stroke="#22d3ee" strokeWidth={2} fillOpacity={1} fill="url(#gCpa)" name="CPA (AED)" />
<Area type="monotone" dataKey="cpa" stroke="#22d3ee" strokeWidth={2} fillOpacity={1} fill="url(#gCpa)" name={`CPA (${symbol})`} />
</AreaChart>
</ResponsiveContainer>
</div>
@@ -547,7 +695,7 @@ function IntelligenceROI() {
<XAxis dataKey="name" stroke="rgba(148,163,184,0.3)" fontSize={10} tickLine={false} axisLine={false} />
<YAxis stroke="rgba(148,163,184,0.3)" fontSize={10} tickLine={false} axisLine={false} />
<Tooltip contentStyle={{ backgroundColor: 'rgba(8,10,18,0.92)', border: '1px solid rgba(59,130,246,0.2)', borderRadius: 10 }} labelStyle={{ color: 'rgba(148,163,184,0.8)', fontSize: 11 }} itemStyle={{ color: '#93c5fd', fontSize: 12 }} />
<Bar dataKey="spend" fill="#3b82f6" radius={[4, 4, 0, 0]} name="Spend (AED)" opacity={0.8} />
<Bar dataKey="spend" fill="#3b82f6" radius={[4, 4, 0, 0]} name={`Spend (${symbol})`} opacity={0.8} />
</BarChart>
</ResponsiveContainer>
</div>
@@ -572,6 +720,7 @@ const EVENT_ICON: Record<LiveEventType, { icon: LucideIcon; color: string; bg: s
function LiveEventItem({ event }: { event: LiveOptimizationEvent }) {
const cfg = EVENT_ICON[event.type];
const { formatText } = useCurrency();
const Icon = cfg.icon;
return (
<motion.div
@@ -596,10 +745,10 @@ function LiveEventItem({ event }: { event: LiveOptimizationEvent }) {
</span>
)}
{event.value && (
<span className="text-[10px] font-medium ml-auto" style={{ color: cfg.color }}>{event.value}</span>
<span className="text-[10px] font-medium ml-auto" style={{ color: cfg.color }}>{formatText(event.value)}</span>
)}
</div>
<p className="text-xs leading-relaxed" style={{ color: 'rgba(148,163,184,0.75)' }}>{event.message}</p>
<p className="text-xs leading-relaxed" style={{ color: 'rgba(148,163,184,0.75)' }}>{formatText(event.message)}</p>
<p className="text-[10px] mt-1" style={{ color: 'rgba(148,163,184,0.35)' }}>
{event.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
</p>

View File

@@ -0,0 +1,219 @@
import { useState, useEffect, useLayoutEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { Plus, Film, Check, X } from 'lucide-react';
// ─── Recent designs mock data ─────────────────────────────────────────────────
const RECENT_DESIGNS = [
{ id: 'rd1', name: 'Penthouse Sea View', type: 'video' as const, gradient: 'linear-gradient(135deg,#1a2a4a,#0f1928)', accent: '#60a5fa', date: '2h ago' },
{ id: 'rd2', name: 'Arabic 3BHK Poster', type: 'image' as const, gradient: 'linear-gradient(135deg,#2a1a3a,#180f28)', accent: '#a78bfa', date: '5h ago' },
{ id: 'rd3', name: 'Amenity Deck Reel', type: 'video' as const, gradient: 'linear-gradient(135deg,#1a3a2a,#0f2818)', accent: '#4ade80', date: '8h ago' },
{ id: 'rd4', name: 'Penthouse En Poster', type: 'image' as const, gradient: 'linear-gradient(135deg,#3a2a1a,#281808)', accent: '#fbbf24', date: '1d ago' },
{ id: 'rd5', name: 'Dubai Marina Aerial', type: 'video' as const, gradient: 'linear-gradient(135deg,#1a3a3a,#0f2828)', accent: '#22d3ee', date: '2d ago' },
{ id: 'rd6', name: 'Investment Lifestyle', type: 'image' as const, gradient: 'linear-gradient(135deg,#3a1a1a,#280f0f)', accent: '#f87171', date: '3d ago' },
];
// ─── Types ────────────────────────────────────────────────────────────────────
export interface GroundTruthSelection {
name: string;
preview: string;
}
interface GroundTruthPickerProps {
anchorRef: React.RefObject<HTMLButtonElement | null>;
onSelect: (sel: GroundTruthSelection) => void;
onClose: () => void;
}
// ─── Portal popup component ───────────────────────────────────────────────────
function PickerPopup({ anchorRef, onSelect, onClose }: GroundTruthPickerProps) {
const popupRef = useRef<HTMLDivElement>(null);
const galleryRef = useRef<HTMLInputElement>(null);
const cameraRef = useRef<HTMLInputElement>(null);
// ── Synchronously measure anchor and position popup ──────────────────────
const [style, setStyle] = useState<React.CSSProperties>({ visibility: 'hidden' });
useLayoutEffect(() => {
if (!anchorRef.current) return;
const r = anchorRef.current.getBoundingClientRect();
const W = 440;
const vw = window.innerWidth;
// Prefer aligning left edge with button; clamp so popup stays on screen
let left = r.left;
if (left + W > vw - 12) left = Math.max(12, vw - W - 12);
setStyle({
position: 'fixed',
top: r.bottom + 8,
left,
width: W,
visibility: 'visible',
});
}, [anchorRef]);
// ── Dismiss on outside click ──────────────────────────────────────────────
useEffect(() => {
function handleDown(e: MouseEvent) {
const target = e.target as Node;
const inPopup = popupRef.current?.contains(target);
const inAnchor = anchorRef.current?.contains(target);
if (!inPopup && !inAnchor) onClose();
}
document.addEventListener('mousedown', handleDown);
return () => document.removeEventListener('mousedown', handleDown);
}, [anchorRef, onClose]);
// ── File handler ──────────────────────────────────────────────────────────
function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
onSelect({ name: file.name, preview: URL.createObjectURL(file) });
e.target.value = '';
}
return (
<motion.div
ref={popupRef}
className="z-[99999] rounded-2xl p-4"
style={{
...style,
background: 'rgba(12,14,22,0.98)',
border: '1px solid rgba(255,255,255,0.12)',
backdropFilter: 'blur(32px)',
WebkitBackdropFilter: 'blur(32px)',
boxShadow: '0 24px 80px rgba(0,0,0,0.85), 0 0 0 1px rgba(255,255,255,0.05)',
}}
initial={{ opacity: 0, y: -6, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -6, scale: 0.97 }}
transition={{ duration: 0.16, ease: [0.4, 0, 0.2, 1] }}
>
{/* Header */}
<div className="flex items-center justify-between mb-3">
<p className="text-[10px] font-semibold uppercase tracking-widest"
style={{ color: 'rgba(148,163,184,0.5)' }}>
Recent Designs
</p>
<motion.button
onClick={onClose}
className="w-7 h-7 flex items-center justify-center rounded-xl"
style={{
background: 'rgba(255,255,255,0.06)',
border: '1px solid rgba(255,255,255,0.1)',
color: 'rgba(148,163,184,0.7)',
}}
whileHover={{ scale: 1.1, backgroundColor: 'rgba(255,255,255,0.14)' }}
whileTap={{ scale: 0.9 }}
title="Close"
>
<X className="w-3.5 h-3.5" />
</motion.button>
</div>
{/* 3×2 recent designs grid */}
<div className="grid grid-cols-3 gap-2 mb-2">
{RECENT_DESIGNS.map((d, i) => (
<motion.button
key={d.id}
className="relative rounded-xl overflow-hidden flex flex-col items-start p-2 group text-left"
style={{
background: d.gradient,
border: '1px solid rgba(255,255,255,0.07)',
aspectRatio: '1',
}}
onClick={() => onSelect({ name: d.name, preview: '' })}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.15, delay: i * 0.04 }}
whileHover={{ scale: 1.03 }}
whileTap={{ scale: 0.97 }}
>
<span
className="text-[9px] font-semibold uppercase px-1.5 py-0.5 rounded-full mb-auto z-10"
style={{ background: `${d.accent}22`, color: d.accent }}
>
{d.type === 'video' ? '▶ Video' : '■ Image'}
</span>
{/* Glow */}
<div className="absolute bottom-0 right-0 w-12 h-12 pointer-events-none"
style={{ background: `radial-gradient(circle,${d.accent}44 0%,transparent 70%)`, filter: 'blur(8px)' }} />
<div className="w-full mt-1 z-10">
<p className="text-[10px] font-medium text-white leading-tight line-clamp-1">{d.name}</p>
<p className="text-[9px] mt-0.5" style={{ color: 'rgba(148,163,184,0.45)' }}>{d.date}</p>
</div>
{/* Hover overlay */}
<div className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center rounded-xl"
style={{ background: 'rgba(255,255,255,0.08)' }}>
<Check className="w-5 h-5 text-white" />
</div>
</motion.button>
))}
</div>
{/* Bottom: gallery + camera */}
<div className="grid grid-cols-2 gap-2 pt-3"
style={{ borderTop: '1px solid rgba(255,255,255,0.05)' }}>
{/* Gallery */}
<div className="relative">
<motion.button
className="w-full flex items-center justify-center gap-2 rounded-xl py-3 text-xs font-medium"
style={{
background: 'rgba(255,255,255,0.04)',
border: '1px solid rgba(255,255,255,0.08)',
color: 'rgba(148,163,184,0.75)',
}}
onClick={() => galleryRef.current?.click()}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.97 }}
>
<Plus className="w-3.5 h-3.5" />
Add from Gallery
</motion.button>
<input ref={galleryRef} type="file" accept="image/*,video/*" className="hidden" onChange={handleFile} />
</div>
{/* Camera */}
<div className="relative">
<motion.button
className="w-full flex items-center justify-center gap-2 rounded-xl py-3 text-xs font-medium"
style={{
background: 'rgba(255,255,255,0.04)',
border: '1px solid rgba(255,255,255,0.08)',
color: 'rgba(148,163,184,0.75)',
}}
onClick={() => cameraRef.current?.click()}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.97 }}
>
<Film className="w-3.5 h-3.5" />
Take Photo
</motion.button>
<input ref={cameraRef} type="file" accept="image/*" capture="environment" className="hidden" onChange={handleFile} />
</div>
</div>
</motion.div>
);
}
// ─── Public export — renders via portal ──────────────────────────────────────
export function GroundTruthPicker(props: GroundTruthPickerProps) {
return createPortal(
<AnimatePresence>
<PickerPopup {...props} />
</AnimatePresence>,
document.body,
);
}

View File

@@ -20,7 +20,7 @@ import { Canvas } from '@react-three/fiber';
import { Bounds, Html, OrbitControls, useGLTF } from '@react-three/drei';
import * as THREE from 'three';
import { useStore } from '@/store/useStore';
import { formatCurrency } from '@/lib/utils';
import { useCurrency } from '@/store/useCurrencyStore';
import type { Unit } from '@/types';
// Penthouse preview images — one per unit (u1u8) for card thumbnails
@@ -227,6 +227,7 @@ function PropertyDetailModal({
}) {
const details = UNIT_DETAILS[unit.id] ?? DEFAULT_DETAILS;
const preview = UNIT_PREVIEWS[unit.id] ?? UNIT_PREVIEWS['u1'];
const { formatAmount } = useCurrency();
const statusColors: Record<string, string> = {
available: 'text-emerald-300 border-emerald-400/30 bg-emerald-500/10',
@@ -282,8 +283,8 @@ function PropertyDetailModal({
{/* Overlay price */}
<div className="absolute bottom-4 left-5">
<p className="text-xs text-zinc-400">Starting from</p>
<p className="text-3xl font-bold tracking-tight text-white">{formatCurrency(unit.price)}</p>
<p className="text-xs text-zinc-400">{formatCurrency(pricePerSqm)} / m²</p>
<p className="text-3xl font-bold tracking-tight text-white">{formatAmount(unit.price)}</p>
<p className="text-xs text-zinc-400">{formatAmount(pricePerSqm)} / m²</p>
</div>
</div>
@@ -371,8 +372,8 @@ function PropertyDetailModal({
{/* Pricing card */}
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
<p className="mb-3 text-xs uppercase tracking-widest text-zinc-500">Pricing</p>
<p className="text-2xl font-bold text-zinc-100">{formatCurrency(unit.price)}</p>
<p className="mt-0.5 text-xs text-zinc-500">{formatCurrency(pricePerSqm)} per m²</p>
<p className="text-2xl font-bold text-zinc-100">{formatAmount(unit.price)}</p>
<p className="mt-0.5 text-xs text-zinc-500">{formatAmount(pricePerSqm)} per m²</p>
<div className="my-3 border-t border-white/10" />
<div className="space-y-1.5 text-sm">
<div className="flex justify-between">
@@ -441,6 +442,7 @@ function UnitCard({
}) {
const preview = UNIT_PREVIEWS[unit.id] ?? UNIT_PREVIEWS['u1'];
const [hovered, setHovered] = useState(false);
const { formatAmount } = useCurrency();
// Status accent color for glow
const statusGlow =
@@ -524,7 +526,7 @@ function UnitCard({
{/* Price */}
<div className="mb-3">
<p className="stat-label mb-0.5">Starting from</p>
<p className="text-xl font-bold leading-none tracking-tight text-white">{formatCurrency(unit.price)}</p>
<p className="text-xl font-bold leading-none tracking-tight text-white">{formatAmount(unit.price)}</p>
</div>
{/* Divider */}
@@ -731,6 +733,7 @@ function StudioWindow({
}
function RightMapPane({ units }: { units: Unit[] }) {
const { formatAmount } = useCurrency();
return (
<div className="relative h-full min-h-[36rem] overflow-hidden rounded-2xl border border-white/10 bg-zinc-900/70">
<iframe title="Dubai Map" src={MAP_EMBED_URL} className="h-full w-full border-0" loading="lazy" referrerPolicy="no-referrer-when-downgrade" />
@@ -741,7 +744,7 @@ function RightMapPane({ units }: { units: Unit[] }) {
<div className="absolute bottom-3 left-3 right-3 grid grid-cols-2 gap-2 rounded-xl border border-white/15 bg-zinc-900/75 p-2 backdrop-blur-xl">
{units.slice(0, 4).map((unit) => (
<div key={unit.id} className="rounded-lg border border-white/10 bg-white/5 px-2 py-1 text-xs text-zinc-200">
{unit.unitNumber} - {formatCurrency(unit.price)}
{unit.unitNumber} - {formatAmount(unit.price)}
</div>
))}
</div>
@@ -762,6 +765,7 @@ function UnitRow({
onOpen3D: (u: Unit) => void;
onOpenBlueprint: (u: Unit) => void;
}) {
const { formatAmount } = useCurrency();
return (
<motion.div
className="flex items-center gap-4 px-4 py-3 rounded-xl cursor-pointer transition-colors"
@@ -795,7 +799,7 @@ function UnitRow({
<p className="text-xs" style={{ color: 'hsl(var(--muted-fg))' }}>{unit.view}</p>
</div>
<div className="col-span-1">
<p className="text-sm font-bold text-white">{formatCurrency(unit.price)}</p>
<p className="text-sm font-bold text-white">{formatAmount(unit.price)}</p>
</div>
</div>

View File

@@ -17,6 +17,8 @@ import {
type LucideIcon,
} from 'lucide-react';
import { useStore } from '@/store/useStore';
import { useCurrency, CURRENCY_OPTIONS } from '@/store/useCurrencyStore';
import type { CurrencyCode } from '@/store/useCurrencyStore';
// ── Design tokens (matching inventory glassmorphism) ─────────────────────────
const GLASS = {
@@ -44,7 +46,7 @@ function GlassCard({
}) {
return (
<motion.div
className={`rounded-2xl overflow-hidden ${className}`}
className={`relative rounded-2xl ${className}`}
style={GLASS}
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
@@ -508,6 +510,7 @@ function DisplaySettings() {
const [compactMode, setCompactMode] = useState(false);
const [language, setLanguage] = useState('en');
const [timezone, setTimezone] = useState('dxb');
const { currency, setCurrency } = useCurrency();
return (
<GlassCard delay={0.25}>
@@ -540,6 +543,21 @@ function DisplaySettings() {
]}
/>
</SettingsRow>
{/* ── Currency ── */}
<SettingsRow
label="Currency"
description="Default currency shown across the entire app"
>
<DarkSelect
value={currency}
onChange={(v) => setCurrency(v as CurrencyCode)}
options={CURRENCY_OPTIONS.map((o) => ({
value: o.code,
label: `${o.flag} ${o.symbol}${o.label}`,
}))}
/>
</SettingsRow>
</div>
</GlassCard>
);
@@ -615,25 +633,25 @@ export function Settings() {
return (
<div className="space-y-4">
{/* Row 1: System + iOS */}
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-2 gap-4 relative z-40">
<SystemStatusCard />
<IOSConnectionCard />
</div>
{/* Row 2: Profile + Notifications */}
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-2 gap-4 relative z-30">
<ProfileSettings />
<NotificationSettings />
</div>
{/* Row 3: Security + Display */}
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-2 gap-4 relative z-20">
<SecuritySettings />
<DisplaySettings />
</div>
{/* Row 4: Data + About */}
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-2 gap-4 relative z-10">
<DataSettings />
<AboutSection />
</div>

View File

@@ -31,13 +31,10 @@ export function formatDistanceToNow(date: Date): string {
return date.toLocaleDateString();
}
export function formatCurrency(amount: number, currency: string = 'AED'): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount);
import { useCurrencyStore } from '@/store/useCurrencyStore';
export function formatCurrency(amount: number): string {
return useCurrencyStore.getState().formatAmount(amount);
}
export function formatNumber(num: number): string {

View File

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

View File

@@ -0,0 +1,184 @@
/**
* 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 }));
}
/** 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">
{SECTION_LABELS[sectionId] ?? sectionId.replace(/^sec_/, '').replace(/_/g, ' ')}
</h2>
<div className="flex-1 h-[1px]" style={{ background: 'rgba(255,255,255,0.05)' }} />
<span className="text-[10px] text-zinc-700">{sectionComps.length}</span>
</div>
{/* Flex wrap for half/third width components */}
<div className="flex flex-wrap gap-4">
{sectionComps.map((comp) => (
<ComponentFlexWrapper
key={`${comp.componentId}-${comp.version}`}
comp={comp}
>
<ComponentRegistry
component={comp}
ctx={{
...ctx,
isSelected: selectedComponentId === comp.componentId,
onSelect: onSelectComponent,
}}
/>
</ComponentFlexWrapper>
))}
</div>
</section>
))}
{/* In-flight execution placeholder */}
<AnimatePresence>
{inFlightExecution && (
<motion.div
key="in-flight-placeholder"
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
className="w-full rounded-2xl p-6 flex items-center gap-4"
style={{
background: 'rgba(59,130,246,0.06)',
border: '1px dashed rgba(59,130,246,0.25)',
backdropFilter: 'blur(12px)',
}}
>
<Loader2 className="w-5 h-5 text-blue-400 animate-spin flex-shrink-0" />
<div>
<p className="text-sm font-medium text-blue-300">Oracle is analyzing your prompt</p>
<p className="text-xs text-zinc-500 mt-0.5 line-clamp-1">
"{inFlightExecution.prompt}"
</p>
</div>
<div
className="ml-auto text-xs font-mono px-2 py-1 rounded-lg"
style={{ background: 'rgba(59,130,246,0.12)', color: '#60a5fa' }}
>
{inFlightExecution.status}
</div>
</motion.div>
)}
</AnimatePresence>
{/* Bottom padding for floating prompt bar */}
<div className="h-32" />
</div>
</div>
);
}
function EmptyCanvasState() {
return (
<div className="flex flex-col items-center justify-center py-24 gap-6">
<div
className="w-20 h-20 rounded-2xl flex items-center justify-center"
style={{ background: 'rgba(59,130,246,0.08)', border: '1px solid rgba(59,130,246,0.15)' }}
>
<svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg" className="w-10 h-10">
<rect x="6" y="6" width="15" height="15" rx="3" stroke="#3B82F6" strokeWidth="1.5" />
<rect x="27" y="6" width="15" height="15" rx="3" stroke="#22D3EE" strokeWidth="1.5" />
<rect x="6" y="27" width="15" height="15" rx="3" stroke="#A78BFA" strokeWidth="1.5" />
<rect x="27" y="27" width="15" height="15" rx="3" stroke="#3B82F6" strokeWidth="1.5" opacity="0.5" strokeDasharray="3 2" />
</svg>
</div>
<div className="text-center">
<h3 className="text-base font-medium text-zinc-200 mb-2">Canvas is empty</h3>
<p className="text-sm text-zinc-500 max-w-sm">
Ask Oracle anything type a prompt below to generate analytical components on your canvas.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,163 @@
/**
* 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';
// ── 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 'kpiTile':
return <KpiTileRenderer component={component} ctx={ctx} />;
case 'errorNotice':
return <ErrorNoticeRenderer component={component} ctx={ctx} />;
case 'timeline':
return <TimelineRenderer component={component} ctx={ctx} />;
case 'barChart':
return (
<Suspense fallback={skeleton}>
<BarChartRenderer component={component} ctx={ctx} />
</Suspense>
);
case 'lineChart':
case 'forecastChart':
return (
<Suspense fallback={skeleton}>
<LineChartRenderer component={component} ctx={ctx} />
</Suspense>
);
case 'geoMap':
case 'heatmap':
return (
<Suspense fallback={skeleton}>
<GeoMapRenderer component={component} ctx={ctx} />
</Suspense>
);
case 'table':
return (
<Suspense fallback={skeleton}>
<TableRenderer component={component} ctx={ctx} />
</Suspense>
);
case 'pipelineBoard':
return (
<Suspense fallback={skeleton}>
<PipelineBoardRenderer component={component} ctx={ctx} />
</Suspense>
);
case 'activityStream':
return (
<Suspense fallback={skeleton}>
<ActivityStreamRenderer component={component} ctx={ctx} />
</Suspense>
);
case 'scatterPlot':
case 'customMLVisualization':
// Phase 2 renderers — show a meaningful placeholder with the right visual treatment
return (
<ErrorNoticeRenderer
component={{
...component,
visualizationParameters: {
errorCode: 'renderer_pending',
message: `The ${component.type} renderer is scheduled for Phase 2 synthesis. Data has been captured and is available.`,
severity: 'info',
},
}}
ctx={ctx}
/>
);
default:
return (
<ErrorNoticeRenderer
component={{
...component,
visualizationParameters: {
errorCode: 'unknown_type',
message: `Unknown component type: ${String(component.type)}`,
},
}}
ctx={ctx}
/>
);
}
}

View File

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

View File

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

View File

@@ -0,0 +1,261 @@
/**
* ShareModal — Fork-based sharing workflow.
* Explains the direct_fork_only semantics (recipient gets an editable copy,
* not live edit access to owner's canvas).
*/
import { useState, useEffect } 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';
interface ShareModalProps {
page: CanvasPage | null;
isOpen: boolean;
onClose: () => void;
onShare: (params: {
recipientEmail: string;
visibility: 'private' | 'team';
message: string;
sourceRevision: number;
}) => Promise<void>;
}
const TEAM_MEMBERS = [
{ id: 'u2', name: 'Elena Rostova', email: 'elena@binghatti.ae', avatar: 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=80&q=80', role: 'Senior Broker' },
{ id: 'u3', name: 'Priya Sharma', email: 'priya@binghatti.ae', avatar: 'https://images.unsplash.com/photo-1554151228-14d9def656e4?auto=format&fit=crop&w=80&q=80', role: 'Senior Broker' },
{ id: 'u4', name: 'Carlos Mendez', email: 'carlos@binghatti.ae', avatar: 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?auto=format&fit=crop&w=80&q=80', role: 'Broker' },
{ id: 'u5', name: 'Ravi Kapoor', email: 'ravi@binghatti.ae', avatar: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?auto=format&fit=crop&w=80&q=80', role: 'Broker' },
];
export function ShareModal({ page, isOpen, onClose, onShare }: ShareModalProps) {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const [recipient, setRecipient] = useState<{ id: string; name: string; email: string } | null>(null);
const [visibility, setVisibility] = useState<'private' | 'team'>('private');
const [message, setMessage] = useState('');
const [submitting, setSubmitting] = useState(false);
const [success, setSuccess] = useState(false);
const [memberDropOpen, setMemberDropOpen] = useState(false);
const handleShare = async () => {
if (!recipient || !page) return;
setSubmitting(true);
try {
await onShare({
recipientEmail: recipient.email,
visibility,
message,
sourceRevision: page.headRevision,
});
setSuccess(true);
setTimeout(() => {
setSuccess(false);
onClose();
setRecipient(null);
setMessage('');
}, 2000);
} catch {
// stay open on error
} finally {
setSubmitting(false);
}
};
const content = (
<AnimatePresence>
{isOpen && (
<>
{/* Backdrop */}
<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)',
}}
>
{/* Header */}
<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>
{/* Fork explanation */}
<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> an editable copy
of this canvas at revision {page?.headRevision}. They can build on it and open a merge
request to propose their changes back.
</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?.name} can now access their copy.</p>
</div>
) : (
<div className="space-y-4">
{/* Recipient picker */}
<div>
<label className="text-xs font-medium text-zinc-400 mb-1.5 block">Recipient</label>
<div className="relative">
<button
onClick={() => setMemberDropOpen((p) => !p)}
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>{recipient?.name ?? 'Select team member…'}</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 }}
>
{TEAM_MEMBERS.map((m) => (
<button
key={m.id}
className="w-full flex items-center gap-3 px-3 py-2.5 hover:bg-white/5 transition-colors text-left"
onClick={() => { setRecipient(m); setMemberDropOpen(false); }}
>
<img src={m.avatar} className="w-7 h-7 rounded-full" alt={m.name} />
<div>
<p className="text-sm text-zinc-200">{m.name}</p>
<p className="text-[10px] text-zinc-500">{m.role}</p>
</div>
</button>
))}
</motion.div>
)}
</AnimatePresence>
</div>
</div>
{/* Visibility */}
<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>
{/* Message */}
<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>
{/* Actions */}
<div className="flex gap-2 pt-1">
<Button
variant="outline"
onClick={onClose}
className="flex-1 border-white/10 bg-white/5 hover:bg-white/10 text-zinc-300"
>
Cancel
</Button>
<Button
onClick={() => void handleShare()}
disabled={!recipient || submitting}
className="flex-1 bg-blue-600 hover:bg-blue-500 text-white"
>
{submitting ? 'Creating fork…' : 'Share (Create Fork)'}
</Button>
</div>
</div>
)}
</div>
</motion.div>
</>
)}
</AnimatePresence>
);
if (!mounted) return null;
return createPortal(content, document.body);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,130 @@
/**
* 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;
}
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) };
}

View File

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

View File

@@ -0,0 +1,199 @@
/**
* 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';
const BASE_URL = (import.meta.env.VITE_ORACLE_API_URL as string | undefined) ?? '';
const WS_URL = (import.meta.env.VITE_ORACLE_WS_URL as string | undefined) ?? '';
function apiUrl(path: string): string {
return `${BASE_URL}/api/oracle/v1${path}`;
}
async function apiFetch<T>(
path: string,
options?: RequestInit & { idempotencyKey?: string },
): Promise<T> {
if (!BASE_URL) {
throw new Error('Oracle API is not configured. Set VITE_ORACLE_API_URL to a live backend.');
}
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'X-Oracle-Contract-Version': 'v1',
...(options?.idempotencyKey ? { 'Idempotency-Key': options.idempotencyKey } : {}),
};
const token = localStorage.getItem('oracle_jwt');
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 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;
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.onmessage = (event) => {
try {
handlers.onMessage(JSON.parse(event.data as string) as OracleWSMessage);
} catch {
// Ignore malformed messages from the transport.
}
};
ws.onclose = () => {
handlers.onClose();
if (!stopped) {
retryTimeout = setTimeout(() => {
handlers.onReconnect();
connect();
}, 3000);
}
};
ws.onerror = () => {
ws.close();
};
}
connect();
return () => {
stopped = true;
if (retryTimeout) clearTimeout(retryTimeout);
ws?.close();
};
}

View File

@@ -0,0 +1,455 @@
/**
* Oracle Demo Data — In-memory seed canvas used when backend is not available.
* Preserves visual richness while the system is in development/demo mode.
* These objects conform exactly to the CanvasPage/CanvasComponent contract.
*/
import type { CanvasPage, UserProfile, CanvasComponent } from '../types/canvas';
// ── Demo user profile ─────────────────────────────────────────────────────────
export const IN_MEMORY_ME: UserProfile = {
userId: 'user_sales_director_001',
tenantId: 'tenant_binghatti_demo',
email: 'ahmed.alfarsi@binghatti.ae',
displayName: 'Ahmed Al-Farsi',
role: 'sales_director',
timezone: 'Asia/Dubai',
locale: 'en-AE',
defaultPageId: 'page_01_main_broker',
canvasPreferences: {
defaultDensity: 'comfortable',
defaultPlacementMode: 'append_after_last_visible_component',
showLineageBadges: true,
},
policyProfileId: 'policy_sales_director_standard_v4',
createdAt: '2026-01-15T09:00:00Z',
updatedAt: '2026-04-09T00:00:00Z',
};
// ── Default style signature ───────────────────────────────────────────────────
const VELOCITY_GLASS_STYLE = {
theme: 'velocity_glass',
paletteToken: 'ocean_signal',
motionProfile: 'calm_reveal',
density: 'comfortable' as const,
radiusScale: 'lg',
typographyScale: 'balanced',
};
// ── Demo components ───────────────────────────────────────────────────────────
const PIPELINE_BOARD: CanvasComponent = {
componentId: 'cmp_demo_pipeline_board',
type: 'pipelineBoard',
title: 'Active Pipeline by Stage',
description: 'Current deal distribution across funnel stages for Q2 2026.',
dataSourceDescriptor: {
descriptorId: 'dsd_demo_pipeline',
sourceType: 'postgres',
connectorId: 'velocity-core-postgres',
dataset: 'deals',
authContextRef: 'authctx_sales_director_team_scope',
queryTemplate: 'SELECT stage, COUNT(*) as count, SUM(value) as value FROM deals WHERE tenant_id = :tenant_id GROUP BY stage',
queryParameters: { tenant_id: 'tenant_binghatti_demo' },
rowLimit: 100,
freshnessSlaSeconds: 120,
cachePolicy: { mode: 'ttl', ttlSeconds: 120 },
privacyTier: 'standard',
lineageRefs: [],
},
visualizationParameters: {
stages: ['New Leads', 'Qualified', 'Proposal Sent', 'Negotiation'],
showValue: true,
colorByStage: true,
},
dataBindings: {
dimensions: ['stage'],
measures: ['count', 'value'],
series: [],
filters: [],
},
version: 1,
provenance: {
originType: 'catalog',
templateId: 'tpl_pipeline_board_v2',
promptExecutionId: 'pex_demo_seed_001',
createdBy: 'user_sales_director_001',
createdAt: '2026-04-09T08:00:00Z',
},
renderingHints: {
estimatedHeightPx: 400,
skeletonVariant: 'pipeline',
virtualizationPriority: 9,
},
layout: {
orderIndex: 100,
sectionId: 'sec_pipeline',
widthMode: 'full',
minHeightPx: 380,
stickyHeader: false,
},
accessControls: {
visibilityScope: 'private',
allowedRoles: ['senior_broker', 'sales_director'],
redactionPolicy: 'none',
},
styleSignature: VELOCITY_GLASS_STYLE,
validationState: {
schema: 'pass',
policy: 'pass',
a11y: 'pass',
performance: 'pass',
status: 'validated',
},
auditLog: ['aud_demo_create_001'],
dataRows: [
{ stage: 'New Leads', count: 14, value: 18500000, leads: [
{ id: 'l1', name: 'Mohammed Al-Rashid', company: 'Rashid Group', value: 'AED 15M', avatar: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?auto=format&fit=crop&w=80&q=80' },
{ id: 'l2', name: 'Sarah Chen', company: 'Chen Capital', value: 'AED 8M', avatar: 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=80&q=80' },
{ id: 'l3', name: 'James Wilson', company: 'Wilson RE', value: 'AED 4.5M', avatar: 'https://images.unsplash.com/photo-1506794778202-cad84cf45f1d?auto=format&fit=crop&w=80&q=80' },
]},
{ stage: 'Qualified', count: 9, value: 42000000, leads: [
{ id: 'l4', name: 'Fatima Hassan', company: 'Hassan Holdings', value: 'AED 22M', avatar: 'https://images.unsplash.com/photo-1554151228-14d9def656e4?auto=format&fit=crop&w=80&q=80' },
{ id: 'l5', name: 'David Kumar', company: 'Kumar RE', value: 'AED 20M', avatar: 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?auto=format&fit=crop&w=80&q=80' },
]},
{ stage: 'Proposal Sent', count: 5, value: 28000000, leads: [
{ id: 'l6', name: 'Elena Rostova', company: 'Rostova Ventures', value: 'AED 12M', avatar: 'https://images.unsplash.com/photo-1487412720507-e7ab37603c6f?auto=format&fit=crop&w=80&q=80' },
{ id: 'l7', name: 'Oliver Park', company: 'Park Investments', value: 'AED 16M', avatar: 'https://images.unsplash.com/photo-1560250097-0b93528c311a?auto=format&fit=crop&w=80&q=80' },
]},
{ stage: 'Negotiation', count: 3, value: 65000000, leads: [
{ id: 'l8', name: 'Priya Sharma', company: 'Sharma Family Office', value: 'AED 32M', avatar: 'https://images.unsplash.com/photo-1542206395-9feb3edaa68d?auto=format&fit=crop&w=80&q=80' },
{ id: 'l9', name: 'Carlos Mendez', company: 'Mendez Capital', value: 'AED 33M', avatar: 'https://images.unsplash.com/photo-1500648767791-00dcc994a43e?auto=format&fit=crop&w=80&q=80' },
]},
],
};
const WHALE_LEADS_BAR: CanvasComponent = {
componentId: 'cmp_demo_whale_bar',
type: 'barChart',
title: 'Whale Leads by Source This Week',
description: 'Compares QD-weighted whale lead volume across lead sources in the current week.',
dataSourceDescriptor: {
descriptorId: 'dsd_demo_whale_bar',
sourceType: 'postgres',
connectorId: 'velocity-core-postgres',
dataset: 'lead_daily_snapshot',
authContextRef: 'authctx_sales_director_team_scope',
queryTemplate: "SELECT source, SUM(qd_weighted_score) as qd_weighted_volume FROM lead_daily_snapshot WHERE tenant_id = :tenant_id AND lead_class = 'whale' GROUP BY source ORDER BY qd_weighted_volume DESC",
queryParameters: { tenant_id: 'tenant_binghatti_demo' },
rowLimit: 20,
freshnessSlaSeconds: 120,
cachePolicy: { mode: 'ttl', ttlSeconds: 120 },
privacyTier: 'standard',
lineageRefs: ['lin_demo_leadsnap'],
},
visualizationParameters: {
xAxis: 'source',
yAxis: 'qd_weighted_volume',
sort: 'desc',
showLabels: true,
colorScale: ['#0EA5E9', '#22D3EE', '#3B82F6'],
legend: false,
},
dataBindings: {
dimensions: ['source'],
measures: ['qd_weighted_volume'],
series: [],
filters: [{ field: 'lead_class', operator: '=', value: 'whale' }],
},
version: 1,
provenance: {
originType: 'prompt_generated',
templateId: 'tpl_bar_source_quality_v3',
promptExecutionId: 'pex_demo_seed_002',
createdBy: 'user_sales_director_001',
createdAt: '2026-04-09T10:00:00Z',
},
renderingHints: {
estimatedHeightPx: 340,
skeletonVariant: 'chart',
virtualizationPriority: 8,
},
layout: {
orderIndex: 200,
sectionId: 'sec_leads',
widthMode: 'half',
minHeightPx: 320,
stickyHeader: false,
},
accessControls: {
visibilityScope: 'private',
allowedRoles: ['senior_broker', 'sales_director', 'marketing_operator'],
redactionPolicy: 'aggregate_only',
},
styleSignature: { ...VELOCITY_GLASS_STYLE, paletteToken: 'ocean_signal' },
validationState: { schema: 'pass', policy: 'pass', a11y: 'pass', performance: 'pass', status: 'validated' },
auditLog: ['aud_demo_create_002'],
dataRows: [
{ source: 'WhatsApp', qd_weighted_volume: 182.4 },
{ source: 'Website', qd_weighted_volume: 149.2 },
{ source: 'Walk-in', qd_weighted_volume: 93.7 },
{ source: 'Referral', qd_weighted_volume: 87.1 },
{ source: 'Instagram', qd_weighted_volume: 54.3 },
],
};
const INVESTOR_GEO_MAP: CanvasComponent = {
componentId: 'cmp_demo_geo_investor',
type: 'geoMap',
title: 'Investor Interest Density by Dubai District',
description: 'Maps high-intent leads with at least one positive Sentinel spike in the last 30 days.',
dataSourceDescriptor: {
descriptorId: 'dsd_demo_geo',
sourceType: 'derived_dataset',
connectorId: 'velocity-core-postgres',
dataset: 'lead_geo_interest_rollup',
authContextRef: 'authctx_sales_director_team_scope',
queryTemplate: 'SELECT district, lat, lng, lead_count, avg_qd_score FROM lead_geo_interest_rollup WHERE tenant_id = :tenant_id AND activity_window = :window',
queryParameters: { tenant_id: 'tenant_binghatti_demo', window: '30d' },
rowLimit: 100,
freshnessSlaSeconds: 300,
cachePolicy: { mode: 'ttl', ttlSeconds: 300 },
privacyTier: 'restricted',
lineageRefs: ['lin_demo_rollup', 'lin_demo_sentinel'],
},
visualizationParameters: {
mapStyle: 'dubai_district_heat',
intensityField: 'lead_count',
tooltipFields: ['district', 'lead_count', 'avg_qd_score'],
interactive: true,
},
dataBindings: {
dimensions: ['district'],
measures: ['lead_count', 'avg_qd_score'],
series: ['district'],
filters: [{ field: 'activity_window', operator: '=', value: '30d' }],
},
version: 1,
provenance: {
originType: 'catalog',
templateId: 'tpl_geo_investor_heat_v2',
promptExecutionId: 'pex_demo_seed_002',
createdBy: 'user_sales_director_001',
createdAt: '2026-04-09T10:00:01Z',
},
renderingHints: {
estimatedHeightPx: 420,
skeletonVariant: 'map',
virtualizationPriority: 9,
},
layout: {
orderIndex: 300,
sectionId: 'sec_leads',
widthMode: 'half',
minHeightPx: 400,
stickyHeader: false,
},
accessControls: {
visibilityScope: 'private',
allowedRoles: ['senior_broker', 'sales_director'],
redactionPolicy: 'district_level_only',
},
styleSignature: { ...VELOCITY_GLASS_STYLE, paletteToken: 'aqua_signal' },
validationState: { schema: 'pass', policy: 'pass', a11y: 'pass', performance: 'pass', status: 'validated' },
auditLog: ['aud_demo_create_003'],
dataRows: [
{ district: 'Downtown Dubai', lat: 25.1972, lng: 55.2744, lead_count: 38, avg_qd_score: 87.2, x: 52, y: 48 },
{ district: 'Dubai Marina', lat: 25.0777, lng: 55.1386, lead_count: 29, avg_qd_score: 82.1, x: 28, y: 68 },
{ district: 'Palm Jumeirah', lat: 25.1124, lng: 55.1390, lead_count: 24, avg_qd_score: 91.4, x: 22, y: 60 },
{ district: 'Business Bay', lat: 25.1850, lng: 55.2617, lead_count: 19, avg_qd_score: 74.8, x: 48, y: 44 },
{ district: 'Dubai Hills', lat: 25.1124, lng: 55.2454, lead_count: 15, avg_qd_score: 71.3, x: 44, y: 58 },
{ district: 'JBR', lat: 25.0794, lng: 55.1322, lead_count: 11, avg_qd_score: 68.9, x: 26, y: 70 },
{ district: 'DIFC', lat: 25.2048, lng: 55.2708, lead_count: 9, avg_qd_score: 79.5, x: 50, y: 38 },
],
};
const BROKER_PERFORMANCE: CanvasComponent = {
componentId: 'cmp_demo_broker_perf',
type: 'table',
title: 'Broker Performance Leaderboard',
description: 'Ranked by QD-adjusted deal value closed this month.',
dataSourceDescriptor: {
descriptorId: 'dsd_demo_brokers',
sourceType: 'postgres',
connectorId: 'velocity-core-postgres',
dataset: 'broker_performance',
authContextRef: 'authctx_sales_director_team_scope',
queryTemplate: 'SELECT broker_id, name, deals_closed, revenue_generated, avg_response_time_min FROM broker_performance WHERE tenant_id = :tenant_id ORDER BY revenue_generated DESC',
queryParameters: { tenant_id: 'tenant_binghatti_demo' },
rowLimit: 20,
freshnessSlaSeconds: 300,
cachePolicy: { mode: 'ttl', ttlSeconds: 300 },
privacyTier: 'standard',
lineageRefs: [],
},
visualizationParameters: {
columns: ['name', 'deals_closed', 'revenue_generated', 'avg_response_time_min'],
rankBy: 'revenue_generated',
showTopBadge: true,
},
dataBindings: {
dimensions: ['name'],
measures: ['deals_closed', 'revenue_generated'],
series: [],
filters: [],
},
version: 1,
provenance: {
originType: 'catalog',
templateId: 'tpl_broker_performance_v1',
promptExecutionId: 'pex_demo_seed_003',
createdBy: 'user_sales_director_001',
createdAt: '2026-04-09T11:00:00Z',
},
renderingHints: {
estimatedHeightPx: 320,
skeletonVariant: 'table',
virtualizationPriority: 7,
},
layout: {
orderIndex: 400,
sectionId: 'sec_team',
widthMode: 'full',
minHeightPx: 300,
stickyHeader: true,
},
accessControls: {
visibilityScope: 'private',
allowedRoles: ['sales_director'],
redactionPolicy: 'none',
},
styleSignature: { ...VELOCITY_GLASS_STYLE, paletteToken: 'indigo_signal' },
validationState: { schema: 'pass', policy: 'pass', a11y: 'pass', performance: 'pass', status: 'validated' },
auditLog: ['aud_demo_create_004'],
dataRows: [
{ name: 'Elena Rostova', deals_closed: 12, revenue_generated: 'AED 28.4M', avg_response_time_min: 8, rank: 1, avatar: 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=80&q=80' },
{ name: 'Priya Sharma', deals_closed: 10, revenue_generated: 'AED 24.1M', avg_response_time_min: 11, rank: 2, avatar: 'https://images.unsplash.com/photo-1554151228-14d9def656e4?auto=format&fit=crop&w=80&q=80' },
{ name: 'Carlos Mendez', deals_closed: 9, revenue_generated: 'AED 19.7M', avg_response_time_min: 14, rank: 3, avatar: 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?auto=format&fit=crop&w=80&q=80' },
{ name: 'Ravi Kapoor', deals_closed: 7, revenue_generated: 'AED 15.2M', avg_response_time_min: 22, rank: 4, avatar: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?auto=format&fit=crop&w=80&q=80' },
{ name: 'Minati Ganrison', deals_closed: 6, revenue_generated: 'AED 11.8M', avg_response_time_min: 19, rank: 5, avatar: 'https://images.unsplash.com/photo-1487412720507-e7ab37603c6f?auto=format&fit=crop&w=80&q=80' },
],
};
const FOLLOWUP_QUEUE: CanvasComponent = {
componentId: 'cmp_demo_followup_queue',
type: 'activityStream',
title: 'Follow-up Gap Queue',
description: 'High-scoring leads with no contact in the last 72 hours.',
dataSourceDescriptor: {
descriptorId: 'dsd_demo_queue',
sourceType: 'postgres',
connectorId: 'velocity-core-postgres',
dataset: 'lead_follow_up_gaps',
authContextRef: 'authctx_sales_director_team_scope',
queryTemplate: 'SELECT lead_id, name, last_contact_hours_ago, qd_score, assigned_broker FROM lead_follow_up_gaps WHERE tenant_id = :tenant_id AND last_contact_hours_ago > 72 ORDER BY qd_score DESC',
queryParameters: { tenant_id: 'tenant_binghatti_demo' },
rowLimit: 10,
freshnessSlaSeconds: 60,
cachePolicy: { mode: 'ttl', ttlSeconds: 60 },
privacyTier: 'restricted',
lineageRefs: ['lin_demo_sentinel'],
},
visualizationParameters: {
showUrgencyIndicator: true,
enableQuickAction: true,
quickActions: ['call', 'whatsapp', 'email', 'assign'],
},
dataBindings: {
dimensions: ['name', 'assigned_broker'],
measures: ['qd_score', 'last_contact_hours_ago'],
series: [],
filters: [{ field: 'last_contact_hours_ago', operator: '>', value: 72 }],
},
version: 1,
provenance: {
originType: 'catalog',
templateId: 'tpl_followup_queue_v1',
promptExecutionId: 'pex_demo_seed_004',
createdBy: 'user_sales_director_001',
createdAt: '2026-04-09T12:00:00Z',
},
renderingHints: {
estimatedHeightPx: 380,
skeletonVariant: 'table',
virtualizationPriority: 10,
},
layout: {
orderIndex: 500,
sectionId: 'sec_actions',
widthMode: 'full',
minHeightPx: 360,
stickyHeader: false,
},
accessControls: {
visibilityScope: 'private',
allowedRoles: ['senior_broker', 'sales_director'],
redactionPolicy: 'team_scope',
},
styleSignature: { ...VELOCITY_GLASS_STYLE, paletteToken: 'amber_signal' },
validationState: { schema: 'pass', policy: 'pass', a11y: 'pass', performance: 'pass', status: 'validated' },
auditLog: ['aud_demo_create_005'],
dataRows: [
{ lead_id: 'l10', name: 'Alexander Petrov', last_contact_hours_ago: 96, qd_score: 88.4, assigned_broker: 'Elena Rostova', urgency: 'critical', avatar: 'https://images.unsplash.com/photo-1500648767791-00dcc994a43e?auto=format&fit=crop&w=80&q=80' },
{ lead_id: 'l11', name: 'Nadia Okafor', last_contact_hours_ago: 84, qd_score: 81.2, assigned_broker: 'Priya Sharma', urgency: 'high', avatar: 'https://images.unsplash.com/photo-1542206395-9feb3edaa68d?auto=format&fit=crop&w=80&q=80' },
{ lead_id: 'l12', name: 'Tariq Al-Mansoori', last_contact_hours_ago: 78, qd_score: 76.9, assigned_broker: 'Carlos Mendez', urgency: 'medium', avatar: 'https://images.unsplash.com/photo-1560250097-0b93528c311a?auto=format&fit=crop&w=80&q=80' },
{ lead_id: 'l13', name: 'Sophie Leclerc', last_contact_hours_ago: 73, qd_score: 72.1, assigned_broker: 'Ravi Kapoor', urgency: 'medium', avatar: 'https://images.unsplash.com/photo-1506794778202-cad84cf45f1d?auto=format&fit=crop&w=80&q=80' },
],
};
// ── Demo Canvas Page ──────────────────────────────────────────────────────────
export const IN_MEMORY_DEMO_PAGE: CanvasPage = {
pageId: 'page_01_main_broker',
tenantId: 'tenant_binghatti_demo',
ownerId: 'user_sales_director_001',
branchId: 'branch_main',
branchName: 'main',
pageType: 'main',
title: 'Oracle — Pipeline & Investor Signals',
createdAt: '2026-04-09T08:00:00Z',
updatedAt: '2026-04-09T12:00:00Z',
isShared: false,
forks: [],
mainBranchPointer: {
pageId: 'page_01_main_broker',
branchId: 'branch_main',
revision: 5,
},
baseRevision: 0,
headRevision: 5,
sharingPolicy: {
shareMode: 'direct_fork_only',
allowReshare: false,
defaultForkVisibility: 'private',
},
presence: {
activeViewers: 1,
activeEditors: 1,
lastPresenceAt: new Date().toISOString(),
},
lineage: [
{
lineageRecordId: 'lin_demo_seed',
tenantId: 'tenant_binghatti_demo',
sourceKind: 'prompt',
sourceId: 'pex_demo_seed_001',
transformationType: 'prompt_to_component_bundle',
producedKind: 'page_revision',
producedId: 'page_01_main_broker:5',
createdAt: '2026-04-09T12:00:00Z',
},
],
audit: {
lastAuditEventId: 'aud_demo_rev5',
eventCount: 12,
},
components: [
PIPELINE_BOARD,
WHALE_LEADS_BAR,
INVESTOR_GEO_MAP,
BROKER_PERFORMANCE,
FOLLOWUP_QUEUE,
],
};

View File

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

View File

@@ -0,0 +1,158 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
// ── Currency config ───────────────────────────────────────────────────────────
export type CurrencyCode = 'USD' | 'AED' | 'INR';
export interface CurrencyOption {
code: CurrencyCode;
label: string;
symbol: string;
locale: string;
flag: string;
}
export const CURRENCY_OPTIONS: CurrencyOption[] = [
{ code: 'USD', label: 'US Dollar', symbol: '$', locale: 'en-US', flag: '🇺🇸' },
{ code: 'AED', label: 'UAE Dirham', symbol: 'AED', locale: 'en-AE', flag: '🇦🇪' },
{ code: 'INR', label: 'Indian Rupee', symbol: '₹', locale: 'en-IN', flag: '🇮🇳' },
];
// ── Store ─────────────────────────────────────────────────────────────────────
interface CurrencyState {
currency: CurrencyCode;
setCurrency: (code: CurrencyCode) => void;
/** Format a numeric amount with the active currency */
formatAmount: (amount: number, options?: { compact?: boolean }) => string;
/** Format a text string containing currency prefixes (e.g. AED 15M -> $ 15M) */
formatText: (text: string) => string;
/** Active currency option object */
option: () => CurrencyOption;
}
export const useCurrencyStore = create<CurrencyState>()(
persist(
(set, get) => ({
currency: 'USD',
setCurrency: (code) => set({ currency: code }),
option: () =>
CURRENCY_OPTIONS.find((o) => o.code === get().currency) ?? CURRENCY_OPTIONS[0],
formatAmount: (amount, opts) => {
const { currency, option } = get();
const { locale } = option();
// Base assumption: Raw numbers in mock data are in AED.
let convertedAmount = amount;
if (currency === 'USD') convertedAmount = amount * 0.272; // AED -> USD
if (currency === 'INR') convertedAmount = amount * 25.135112; // AED -> INR (0.272 * 92.4085)
if (opts?.compact) {
// Compact notation: 1.5M, 450K, etc. — prefix with symbol
const abs = Math.abs(convertedAmount);
const sign = convertedAmount < 0 ? '-' : '';
const sym = CURRENCY_OPTIONS.find((o) => o.code === currency)?.symbol ?? currency;
if (currency === 'INR' && abs >= 10_000_000) return `${sign}${sym} ${(abs / 10_000_000).toFixed(1)}Cr`;
if (abs >= 1_000_000) return `${sign}${sym} ${(abs / 1_000_000).toFixed(1)}M`;
if (abs >= 1_000) return `${sign}${sym} ${(abs / 1_000).toFixed(0)}K`;
return `${sign}${sym} ${abs.toFixed(0)}`;
}
return new Intl.NumberFormat(locale, {
style: 'currency',
currency,
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(convertedAmount);
},
formatText: (text) => {
const { currency } = get();
const sym = CURRENCY_OPTIONS.find((o) => o.code === currency)?.symbol ?? currency;
return text.replace(/(AED|INR|\$)\s*([\d\.\-]+)\s*([MKCr]*)\+?/g, (match, prefix, numRange, suffix) => {
const parts = numRange.split('-');
const convertedParts = parts.map((p: string) => {
const parsedNum = parseFloat(p);
if (isNaN(parsedNum)) return p;
// Step 1: Normalize everything to AED Base
let baseAed = parsedNum;
if (prefix === 'INR') baseAed = parsedNum / 25.135112;
if (prefix === '$') baseAed = parsedNum / 0.272;
// Step 2: Handle suffixes (Cr -> M mapping)
let multiplier = 1;
if (suffix === 'Cr') {
if (currency !== 'INR') {
multiplier = 10; // 1 Cr = 10M
}
} else if (suffix === 'M') {
if (currency === 'INR') {
multiplier = 0.1; // 10M = 1 Cr
}
}
// Step 3: Convert from AED to Target Currency
let targetNum = baseAed;
if (currency === 'USD') targetNum = baseAed * 0.272;
if (currency === 'INR') targetNum = baseAed * 25.135112;
targetNum = targetNum * multiplier;
return targetNum >= 10 ? targetNum.toFixed(0) : parseFloat(targetNum.toFixed(1)).toString();
});
const hasPlus = match.includes('+');
// Determine final output suffix
let outSuffix = suffix;
if (suffix === 'Cr' && currency !== 'INR') outSuffix = 'M';
if (suffix === 'M' && currency === 'INR') outSuffix = 'Cr';
return `${sym} ${convertedParts.join('-')}${outSuffix}${hasPlus ? '+' : ''}`;
});
},
}),
{
name: 'pv-currency', // localStorage key
partialize: (state) => ({ currency: state.currency }),
},
),
);
// ── Convenience hook ──────────────────────────────────────────────────────────
/** Use anywhere — returns stable primitives + function refs. No infinite loop. */
export function useCurrency() {
// Select each piece individually so zustand compares with Object.is on primitives
const currency = useCurrencyStore((s) => s.currency);
const setCurrency = useCurrencyStore((s) => s.setCurrency);
const formatAmount = useCurrencyStore((s) => s.formatAmount);
const formatText = useCurrencyStore((s) => s.formatText);
// Derive display values from the stable `currency` string (no selector object)
const option = CURRENCY_OPTIONS.find((o) => o.code === currency) ?? CURRENCY_OPTIONS[0];
const rate = currency === 'USD' ? 0.272 : currency === 'INR' ? 25.135112 : 1;
return {
currency,
symbol: option.symbol,
flag: option.flag,
label: option.label,
option,
rate,
formatAmount,
formatText,
setCurrency,
};
}