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:
4
app/dist/index.html
vendored
4
app/dist/index.html
vendored
@@ -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>
|
||||
|
||||
2
app/node_modules/.tmp/tsconfig.app.tsbuildinfo
generated
vendored
2
app/node_modules/.tmp/tsconfig.app.tsbuildinfo
generated
vendored
@@ -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"}
|
||||
6
app/node_modules/.vite/deps/@radix-ui_react-avatar.js
generated
vendored
6
app/node_modules/.vite/deps/@radix-ui_react-avatar.js
generated
vendored
@@ -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";
|
||||
|
||||
16
app/node_modules/.vite/deps/@react-three_drei.js
generated
vendored
16
app/node_modules/.vite/deps/@react-three_drei.js
generated
vendored
@@ -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";
|
||||
|
||||
6
app/node_modules/.vite/deps/@react-three_fiber.js
generated
vendored
6
app/node_modules/.vite/deps/@react-three_fiber.js
generated
vendored
@@ -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";
|
||||
|
||||
108
app/node_modules/.vite/deps/_metadata.json
generated
vendored
108
app/node_modules/.vite/deps/_metadata.json
generated
vendored
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
219
app/src/components/modules/GroundTruthPicker.tsx
Normal file
219
app/src/components/modules/GroundTruthPicker.tsx
Normal 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,
|
||||
);
|
||||
}
|
||||
@@ -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 (u1–u8) 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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
201
app/src/oracle/components/BranchBar.tsx
Normal file
201
app/src/oracle/components/BranchBar.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* BranchBar — Top-of-page branch status bar
|
||||
* Shows: page title, branch identity badge, revision number, execution status,
|
||||
* share action, merge request indicator, rollback affordance.
|
||||
* Must remain visible at all times (sticky).
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
GitBranch, GitMerge, Share2, RotateCcw, Dot, Users,
|
||||
CheckCircle2, Clock, AlertCircle, Loader2, GitFork
|
||||
} from 'lucide-react';
|
||||
import type { CanvasPage, PromptExecution, MergeRequest } from '../types/canvas';
|
||||
|
||||
interface BranchBarProps {
|
||||
page: CanvasPage | null;
|
||||
inFlightExecution: PromptExecution | null;
|
||||
mergeRequests?: MergeRequest[];
|
||||
isConnected: boolean;
|
||||
onShare: () => void;
|
||||
onRollback: () => void;
|
||||
onOpenMergeReview: () => void;
|
||||
}
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
received: { icon: Clock, color: '#94a3b8', label: 'Received' },
|
||||
planning: { icon: Loader2, color: '#60a5fa', label: 'Planning…', spin: true },
|
||||
validated: { icon: CheckCircle2, color: '#34d399', label: 'Validated' },
|
||||
executing: { icon: Loader2, color: '#a78bfa', label: 'Executing…', spin: true },
|
||||
completed: { icon: CheckCircle2, color: '#34d399', label: 'Completed' },
|
||||
failed: { icon: AlertCircle, color: '#f87171', label: 'Failed' },
|
||||
clarification_required: { icon: AlertCircle, color: '#fbbf24', label: 'Clarification needed' },
|
||||
} as const;
|
||||
|
||||
export function BranchBar({
|
||||
page,
|
||||
inFlightExecution,
|
||||
mergeRequests = [],
|
||||
isConnected,
|
||||
onShare,
|
||||
onRollback,
|
||||
onOpenMergeReview,
|
||||
}: BranchBarProps) {
|
||||
const [shareHovered, setShareHovered] = useState(false);
|
||||
const openMRCount = mergeRequests.filter((mr) => mr.status === 'open').length;
|
||||
const isFork = page?.pageType === 'fork';
|
||||
|
||||
const executionStatus = inFlightExecution?.status;
|
||||
const statusCfg = executionStatus ? STATUS_CONFIG[executionStatus] : null;
|
||||
const StatusIcon = statusCfg?.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative z-20 px-4 pt-3 pb-2 flex-shrink-0"
|
||||
style={{ borderBottom: '1px solid rgba(255,255,255,0.06)' }}
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-between gap-4 px-4 py-2.5 rounded-2xl"
|
||||
style={{
|
||||
background: 'rgba(10, 11, 18, 0.85)',
|
||||
border: '1px solid rgba(255,255,255,0.08)',
|
||||
backdropFilter: 'blur(24px)',
|
||||
WebkitBackdropFilter: 'blur(24px)',
|
||||
}}
|
||||
>
|
||||
{/* Left: Page title + branch identity */}
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{isFork ? (
|
||||
<GitFork className="w-4 h-4 text-violet-400" />
|
||||
) : (
|
||||
<GitBranch className="w-4 h-4 text-blue-400" />
|
||||
)}
|
||||
<span
|
||||
className="text-xs font-semibold px-2 py-0.5 rounded-full flex-shrink-0"
|
||||
style={{
|
||||
background: isFork ? 'rgba(139,92,246,0.15)' : 'rgba(59,130,246,0.15)',
|
||||
color: isFork ? '#c4b5fd' : '#93c5fd',
|
||||
border: `1px solid ${isFork ? 'rgba(139,92,246,0.3)' : 'rgba(59,130,246,0.3)'}`,
|
||||
}}
|
||||
>
|
||||
{page?.branchName ?? 'main'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span className="text-sm font-medium text-zinc-200 truncate">
|
||||
{page?.title ?? 'Oracle Canvas'}
|
||||
</span>
|
||||
{page && (
|
||||
<span className="text-xs text-zinc-600 flex-shrink-0 font-mono">
|
||||
rev.{page.headRevision}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Center: execution status */}
|
||||
<AnimatePresence mode="wait">
|
||||
{statusCfg && StatusIcon && (
|
||||
<motion.div
|
||||
key={executionStatus}
|
||||
initial={{ opacity: 0, scale: 0.85 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.85 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="flex items-center gap-1.5 flex-shrink-0"
|
||||
>
|
||||
<StatusIcon
|
||||
className="w-3.5 h-3.5"
|
||||
style={{
|
||||
color: statusCfg.color,
|
||||
// @ts-expect-error spin is a custom property
|
||||
animation: statusCfg.spin ? 'spin 1s linear infinite' : undefined,
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs font-medium" style={{ color: statusCfg.color }}>
|
||||
{statusCfg.label}
|
||||
</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Right: actions */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{/* Presence indicator */}
|
||||
{page && page.presence.activeViewers > 1 && (
|
||||
<div className="flex items-center gap-1 text-xs text-zinc-500">
|
||||
<Users className="w-3.5 h-3.5" />
|
||||
<span>{page.presence.activeViewers}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Connection dot */}
|
||||
<div className="flex items-center gap-1">
|
||||
<Dot
|
||||
className="w-5 h-5 -mx-1.5"
|
||||
style={{ color: isConnected ? '#34d399' : '#6b7280' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Rollback */}
|
||||
<button
|
||||
onClick={onRollback}
|
||||
title="View revision history and rollback"
|
||||
className="flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg transition-all"
|
||||
style={{
|
||||
color: '#71717a',
|
||||
background: 'transparent',
|
||||
border: '1px solid rgba(255,255,255,0.07)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255,255,255,0.05)';
|
||||
e.currentTarget.style.color = '#a1a1aa';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
e.currentTarget.style.color = '#71717a';
|
||||
}}
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
<span className="hidden sm:inline">History</span>
|
||||
</button>
|
||||
|
||||
{/* Merge Requests */}
|
||||
{openMRCount > 0 && (
|
||||
<button
|
||||
onClick={onOpenMergeReview}
|
||||
className="flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg transition-all"
|
||||
style={{
|
||||
background: 'rgba(251,191,36,0.12)',
|
||||
color: '#fbbf24',
|
||||
border: '1px solid rgba(251,191,36,0.25)',
|
||||
}}
|
||||
>
|
||||
<GitMerge className="w-3.5 h-3.5" />
|
||||
<span>{openMRCount} open</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Share */}
|
||||
<motion.button
|
||||
onClick={onShare}
|
||||
onMouseEnter={() => setShareHovered(true)}
|
||||
onMouseLeave={() => setShareHovered(false)}
|
||||
className="flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-lg font-medium transition-all"
|
||||
style={{
|
||||
background: shareHovered ? 'rgba(59,130,246,0.25)' : 'rgba(59,130,246,0.15)',
|
||||
color: '#93c5fd',
|
||||
border: '1px solid rgba(59,130,246,0.3)',
|
||||
}}
|
||||
whileTap={{ scale: 0.96 }}
|
||||
>
|
||||
<Share2 className="w-3.5 h-3.5" />
|
||||
<span className="hidden sm:inline">Share</span>
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
184
app/src/oracle/components/CanvasViewport.tsx
Normal file
184
app/src/oracle/components/CanvasViewport.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
163
app/src/oracle/components/ComponentRegistry.tsx
Normal file
163
app/src/oracle/components/ComponentRegistry.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
205
app/src/oracle/components/PromptRail.tsx
Normal file
205
app/src/oracle/components/PromptRail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
208
app/src/oracle/components/RollbackConfirmModal.tsx
Normal file
208
app/src/oracle/components/RollbackConfirmModal.tsx
Normal 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);
|
||||
}
|
||||
261
app/src/oracle/components/ShareModal.tsx
Normal file
261
app/src/oracle/components/ShareModal.tsx
Normal 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);
|
||||
}
|
||||
135
app/src/oracle/components/renderers/ActivityStreamRenderer.tsx
Normal file
135
app/src/oracle/components/renderers/ActivityStreamRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
71
app/src/oracle/components/renderers/BarChartRenderer.tsx
Normal file
71
app/src/oracle/components/renderers/BarChartRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
61
app/src/oracle/components/renderers/ErrorNoticeRenderer.tsx
Normal file
61
app/src/oracle/components/renderers/ErrorNoticeRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
115
app/src/oracle/components/renderers/GeoMapRenderer.tsx
Normal file
115
app/src/oracle/components/renderers/GeoMapRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
69
app/src/oracle/components/renderers/KpiTileRenderer.tsx
Normal file
69
app/src/oracle/components/renderers/KpiTileRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
app/src/oracle/components/renderers/LineChartRenderer.tsx
Normal file
54
app/src/oracle/components/renderers/LineChartRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
121
app/src/oracle/components/renderers/PipelineBoardRenderer.tsx
Normal file
121
app/src/oracle/components/renderers/PipelineBoardRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
184
app/src/oracle/components/renderers/RendererWrapper.tsx
Normal file
184
app/src/oracle/components/renderers/RendererWrapper.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
97
app/src/oracle/components/renderers/TableRenderer.tsx
Normal file
97
app/src/oracle/components/renderers/TableRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
75
app/src/oracle/components/renderers/TimelineRenderer.tsx
Normal file
75
app/src/oracle/components/renderers/TimelineRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
386
app/src/oracle/components/review/MergeReviewDrawer.tsx
Normal file
386
app/src/oracle/components/review/MergeReviewDrawer.tsx
Normal 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);
|
||||
}
|
||||
130
app/src/oracle/hooks/useOracleExecution.ts
Normal file
130
app/src/oracle/hooks/useOracleExecution.ts
Normal 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) };
|
||||
}
|
||||
120
app/src/oracle/hooks/useOraclePage.ts
Normal file
120
app/src/oracle/hooks/useOraclePage.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* useOraclePage — Page hydration, branch state, component projection
|
||||
* Owns the canonical page state including revision tracking and optimistic updates.
|
||||
*/
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import type { CanvasPage, CanvasComponent, OracleWSMessage } from '../types/canvas';
|
||||
import { fetchCanvasPage, connectPageSocket } from '../lib/oracleApiClient';
|
||||
|
||||
export interface OraclePageState {
|
||||
page: CanvasPage | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
isConnected: boolean;
|
||||
// Actions
|
||||
refresh: () => Promise<void>;
|
||||
optimisticallyAppendComponent: (comp: CanvasComponent) => void;
|
||||
applyRevision: (headRevision: number, components: CanvasComponent[]) => void;
|
||||
}
|
||||
|
||||
export function useOraclePage(pageId: string | null): OraclePageState {
|
||||
const [page, setPage] = useState<CanvasPage | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const disconnectRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!pageId) {
|
||||
setPage(null);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await fetchCanvasPage(pageId);
|
||||
setPage(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load page');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [pageId]);
|
||||
|
||||
// Connect WebSocket
|
||||
useEffect(() => {
|
||||
if (!pageId) {
|
||||
setIsConnected(false);
|
||||
return () => undefined;
|
||||
}
|
||||
const disconnect = connectPageSocket(pageId, {
|
||||
onMessage: (msg: OracleWSMessage) => handleWSMessage(msg),
|
||||
onReconnect: () => 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,
|
||||
};
|
||||
}
|
||||
199
app/src/oracle/lib/oracleApiClient.ts
Normal file
199
app/src/oracle/lib/oracleApiClient.ts
Normal 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();
|
||||
};
|
||||
}
|
||||
455
app/src/oracle/lib/oracleDemoData.ts
Normal file
455
app/src/oracle/lib/oracleDemoData.ts
Normal 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,
|
||||
],
|
||||
};
|
||||
488
app/src/oracle/types/canvas.ts
Normal file
488
app/src/oracle/types/canvas.ts
Normal 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>;
|
||||
}
|
||||
158
app/src/store/useCurrencyStore.ts
Normal file
158
app/src/store/useCurrencyStore.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user