feat/#24 WebOS Completion (#25)

#24 WebOS Completion

Co-authored-by: Sayan Datta <sayan@Sayans-MacBook-Air.local>
Reviewed-on: #25
This commit was merged in pull request #25.
This commit is contained in:
2026-04-18 18:59:04 +05:30
parent 857e0b88e6
commit 84e439712c
459 changed files with 11713 additions and 3853 deletions

View File

@@ -2,23 +2,23 @@
import {
useCallbackRef,
useLayoutEffect2
} from "./chunk-GRXJTWBV.js";
import {
require_react_dom
} from "./chunk-YLZ34CCM.js";
import {
require_shim
} from "./chunk-642Z5WD3.js";
} from "./chunk-J4JAFMOP.js";
import {
createSlot
} from "./chunk-5HUACAZ7.js";
import "./chunk-HPBHRBIF.js";
} from "./chunk-YWBEB5PG.js";
import "./chunk-2VUH7NEY.js";
import {
require_shim
} from "./chunk-TXHHHGR3.js";
import {
require_react_dom
} from "./chunk-YF4B4G2L.js";
import {
require_jsx_runtime
} from "./chunk-USXRE7Q2.js";
} from "./chunk-2YVA4HRZ.js";
import {
require_react
} from "./chunk-ZNKPWGXJ.js";
} from "./chunk-WUR7D6NS.js";
import {
__toESM
} from "./chunk-G3PMV62Z.js";

View File

@@ -2,20 +2,20 @@
import {
useCallbackRef,
useLayoutEffect2
} from "./chunk-GRXJTWBV.js";
import {
require_react_dom
} from "./chunk-YLZ34CCM.js";
} from "./chunk-J4JAFMOP.js";
import {
composeRefs,
useComposedRefs
} from "./chunk-HPBHRBIF.js";
} from "./chunk-2VUH7NEY.js";
import {
require_react_dom
} from "./chunk-YF4B4G2L.js";
import {
require_jsx_runtime
} from "./chunk-USXRE7Q2.js";
} from "./chunk-2YVA4HRZ.js";
import {
require_react
} from "./chunk-ZNKPWGXJ.js";
} from "./chunk-WUR7D6NS.js";
import {
__toESM
} from "./chunk-G3PMV62Z.js";

File diff suppressed because one or more lines are too long

View File

@@ -3,10 +3,10 @@ import {
Slottable,
createSlot,
createSlottable
} from "./chunk-5HUACAZ7.js";
import "./chunk-HPBHRBIF.js";
import "./chunk-USXRE7Q2.js";
import "./chunk-ZNKPWGXJ.js";
} from "./chunk-YWBEB5PG.js";
import "./chunk-2VUH7NEY.js";
import "./chunk-2YVA4HRZ.js";
import "./chunk-WUR7D6NS.js";
import "./chunk-G3PMV62Z.js";
export {
Slot as Root,

View File

@@ -1,18 +1,12 @@
import {
_extends
} from "./chunk-H4GSM2WL.js";
create
} from "./chunk-7GZ4CI6Q.js";
import {
subscribeWithSelector
} from "./chunk-XGWIEMTH.js";
} from "./chunk-O4L7C4YS.js";
import {
Events
} from "./chunk-OAEA5FZL.js";
import {
require_client
} from "./chunk-AFNBKP7P.js";
import {
create
} from "./chunk-QJTQF54Q.js";
import {
addAfterEffect,
addEffect,
@@ -28,7 +22,8 @@ import {
useInstanceHandle,
useLoader,
useThree
} from "./chunk-JRJA23OI.js";
} from "./chunk-5ESDTKMP.js";
import "./chunk-NJ4V5H3P.js";
import {
AddEquation,
AdditiveBlending,
@@ -222,15 +217,20 @@ import {
WebGLRenderer,
WireframeGeometry,
ZeroFactor
} from "./chunk-INS7YHTD.js";
import "./chunk-QURGMCZB.js";
import "./chunk-LTNRPUSL.js";
import "./chunk-YLZ34CCM.js";
import "./chunk-642Z5WD3.js";
import "./chunk-USXRE7Q2.js";
} from "./chunk-L3Z576C2.js";
import {
require_client
} from "./chunk-6MXH2QM6.js";
import "./chunk-GUQHL3N7.js";
import {
_extends
} from "./chunk-EQCCHGRT.js";
import "./chunk-TXHHHGR3.js";
import "./chunk-YF4B4G2L.js";
import "./chunk-2YVA4HRZ.js";
import {
require_react
} from "./chunk-ZNKPWGXJ.js";
} from "./chunk-WUR7D6NS.js";
import {
__commonJS,
__toESM
@@ -109302,7 +109302,7 @@ var FaceLandmarker = (0, import_react25.forwardRef)(({
const {
FilesetResolver,
FaceLandmarker: FaceLandmarker2
} = await import("./vision_bundle-ZAS5UOAV.js");
} = await import("@mediapipe/tasks-vision");
const vision = await FilesetResolver.forVisionTasks(basePath);
return FaceLandmarker2.createFromOptions(vision, options);
}, [basePath, opts]);

File diff suppressed because one or more lines are too long

View File

@@ -28,13 +28,13 @@ import {
useLoader,
useStore,
useThree
} from "./chunk-JRJA23OI.js";
import "./chunk-INS7YHTD.js";
import "./chunk-QURGMCZB.js";
import "./chunk-LTNRPUSL.js";
import "./chunk-642Z5WD3.js";
import "./chunk-USXRE7Q2.js";
import "./chunk-ZNKPWGXJ.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";
import "./chunk-G3PMV62Z.js";
export {
Canvas,

View File

@@ -1,133 +1,133 @@
{
"hash": "48124858",
"configHash": "2be684c6",
"lockfileHash": "dbdb05fd",
"browserHash": "bc295ff7",
"hash": "4594f192",
"configHash": "1dd3b956",
"lockfileHash": "e8550e82",
"browserHash": "7e7e8c10",
"optimized": {
"react": {
"src": "../../react/index.js",
"file": "react.js",
"fileHash": "ca909492",
"fileHash": "bc0c1f26",
"needsInterop": true
},
"react-dom": {
"src": "../../react-dom/index.js",
"file": "react-dom.js",
"fileHash": "9ea60d36",
"fileHash": "36a8d9c0",
"needsInterop": true
},
"react/jsx-dev-runtime": {
"src": "../../react/jsx-dev-runtime.js",
"file": "react_jsx-dev-runtime.js",
"fileHash": "f778ce34",
"fileHash": "3d8f6460",
"needsInterop": true
},
"react/jsx-runtime": {
"src": "../../react/jsx-runtime.js",
"file": "react_jsx-runtime.js",
"fileHash": "afe32f9c",
"fileHash": "6f4aca26",
"needsInterop": true
},
"@radix-ui/react-avatar": {
"src": "../../@radix-ui/react-avatar/dist/index.mjs",
"file": "@radix-ui_react-avatar.js",
"fileHash": "78604ab7",
"fileHash": "2a702dd2",
"needsInterop": false
},
"@radix-ui/react-dropdown-menu": {
"src": "../../@radix-ui/react-dropdown-menu/dist/index.mjs",
"file": "@radix-ui_react-dropdown-menu.js",
"fileHash": "7e6567c2",
"fileHash": "a5efb9bf",
"needsInterop": false
},
"@radix-ui/react-slot": {
"src": "../../@radix-ui/react-slot/dist/index.mjs",
"file": "@radix-ui_react-slot.js",
"fileHash": "4f153a2d",
"fileHash": "986d9c0d",
"needsInterop": false
},
"@react-three/drei": {
"src": "../../@react-three/drei/index.js",
"file": "@react-three_drei.js",
"fileHash": "313a1f02",
"fileHash": "6cd60875",
"needsInterop": false
},
"@react-three/fiber": {
"src": "../../@react-three/fiber/dist/react-three-fiber.esm.js",
"file": "@react-three_fiber.js",
"fileHash": "5e5643b4",
"fileHash": "27a7d4df",
"needsInterop": false
},
"class-variance-authority": {
"src": "../../class-variance-authority/dist/index.mjs",
"file": "class-variance-authority.js",
"fileHash": "69d37784",
"fileHash": "b0c32b93",
"needsInterop": false
},
"clsx": {
"src": "../../clsx/dist/clsx.mjs",
"file": "clsx.js",
"fileHash": "861ef14c",
"fileHash": "c855e729",
"needsInterop": false
},
"framer-motion": {
"src": "../../framer-motion/dist/es/index.mjs",
"file": "framer-motion.js",
"fileHash": "3f8d4bda",
"fileHash": "e0841dfa",
"needsInterop": false
},
"lucide-react": {
"src": "../../lucide-react/dist/esm/lucide-react.js",
"file": "lucide-react.js",
"fileHash": "f8a0e731",
"fileHash": "4d79a586",
"needsInterop": false
},
"react-dom/client": {
"src": "../../react-dom/client.js",
"file": "react-dom_client.js",
"fileHash": "bb1fb188",
"fileHash": "2e02376b",
"needsInterop": true
},
"react-router-dom": {
"src": "../../react-router-dom/dist/index.mjs",
"file": "react-router-dom.js",
"fileHash": "69a5af47",
"fileHash": "bd4cf4c4",
"needsInterop": false
},
"recharts": {
"src": "../../recharts/es6/index.js",
"file": "recharts.js",
"fileHash": "8c3719f7",
"fileHash": "b44545db",
"needsInterop": false
},
"sonner": {
"src": "../../sonner/dist/index.mjs",
"file": "sonner.js",
"fileHash": "ff7fef4b",
"fileHash": "02632b99",
"needsInterop": false
},
"tailwind-merge": {
"src": "../../tailwind-merge/dist/bundle-mjs.mjs",
"file": "tailwind-merge.js",
"fileHash": "b1f20ce9",
"fileHash": "ab22bcc4",
"needsInterop": false
},
"three": {
"src": "../../three/build/three.module.js",
"file": "three.js",
"fileHash": "cae86099",
"fileHash": "43012f83",
"needsInterop": false
},
"zustand": {
"src": "../../zustand/esm/index.mjs",
"file": "zustand.js",
"fileHash": "60f9f3ea",
"fileHash": "dbfba0e2",
"needsInterop": false
},
"zustand/middleware": {
"src": "../../zustand/esm/middleware.mjs",
"file": "zustand_middleware.js",
"fileHash": "3b17a615",
"fileHash": "e524c2dc",
"needsInterop": false
}
},
@@ -135,59 +135,56 @@
"hls-Q6LDPZPT": {
"file": "hls-Q6LDPZPT.js"
},
"vision_bundle-ZAS5UOAV": {
"file": "vision_bundle-ZAS5UOAV.js"
"chunk-U7P2NEEE": {
"file": "chunk-U7P2NEEE.js"
},
"chunk-H4GSM2WL": {
"file": "chunk-H4GSM2WL.js"
"chunk-J4JAFMOP": {
"file": "chunk-J4JAFMOP.js"
},
"chunk-XGWIEMTH": {
"file": "chunk-XGWIEMTH.js"
"chunk-YWBEB5PG": {
"file": "chunk-YWBEB5PG.js"
},
"chunk-2VUH7NEY": {
"file": "chunk-2VUH7NEY.js"
},
"chunk-7GZ4CI6Q": {
"file": "chunk-7GZ4CI6Q.js"
},
"chunk-O4L7C4YS": {
"file": "chunk-O4L7C4YS.js"
},
"chunk-OAEA5FZL": {
"file": "chunk-OAEA5FZL.js"
},
"chunk-AFNBKP7P": {
"file": "chunk-AFNBKP7P.js"
"chunk-5ESDTKMP": {
"file": "chunk-5ESDTKMP.js"
},
"chunk-QJTQF54Q": {
"file": "chunk-QJTQF54Q.js"
"chunk-NJ4V5H3P": {
"file": "chunk-NJ4V5H3P.js"
},
"chunk-JRJA23OI": {
"file": "chunk-JRJA23OI.js"
"chunk-L3Z576C2": {
"file": "chunk-L3Z576C2.js"
},
"chunk-INS7YHTD": {
"file": "chunk-INS7YHTD.js"
"chunk-6MXH2QM6": {
"file": "chunk-6MXH2QM6.js"
},
"chunk-QURGMCZB": {
"file": "chunk-QURGMCZB.js"
"chunk-GUQHL3N7": {
"file": "chunk-GUQHL3N7.js"
},
"chunk-LTNRPUSL": {
"file": "chunk-LTNRPUSL.js"
"chunk-EQCCHGRT": {
"file": "chunk-EQCCHGRT.js"
},
"chunk-U7P2NEEE": {
"file": "chunk-U7P2NEEE.js"
"chunk-TXHHHGR3": {
"file": "chunk-TXHHHGR3.js"
},
"chunk-GRXJTWBV": {
"file": "chunk-GRXJTWBV.js"
"chunk-YF4B4G2L": {
"file": "chunk-YF4B4G2L.js"
},
"chunk-YLZ34CCM": {
"file": "chunk-YLZ34CCM.js"
"chunk-2YVA4HRZ": {
"file": "chunk-2YVA4HRZ.js"
},
"chunk-642Z5WD3": {
"file": "chunk-642Z5WD3.js"
},
"chunk-5HUACAZ7": {
"file": "chunk-5HUACAZ7.js"
},
"chunk-HPBHRBIF": {
"file": "chunk-HPBHRBIF.js"
},
"chunk-USXRE7Q2": {
"file": "chunk-USXRE7Q2.js"
},
"chunk-ZNKPWGXJ": {
"file": "chunk-ZNKPWGXJ.js"
"chunk-WUR7D6NS": {
"file": "chunk-WUR7D6NS.js"
},
"chunk-G3PMV62Z": {
"file": "chunk-G3PMV62Z.js"

View File

@@ -1,7 +1,7 @@
{
"version": 3,
"sources": ["../../class-variance-authority/dist/index.mjs"],
"sourcesContent": ["/**\r\n * Copyright 2022 Joe Bell. All rights reserved.\r\n *\r\n * This file is licensed to you under the Apache License, Version 2.0\r\n * (the \"License\"); you may not use this file except in compliance with the\r\n * License. You may obtain a copy of the License at\r\n *\r\n * http://www.apache.org/licenses/LICENSE-2.0\r\n *\r\n * Unless required by applicable law or agreed to in writing, software\r\n * distributed under the License is distributed on an \"AS IS\" BASIS, WITHOUT\r\n * WARRANTIES OR REPRESENTATIONS OF ANY KIND, either express or implied. See the\r\n * License for the specific language governing permissions and limitations under\r\n * the License.\r\n */ import { clsx } from \"clsx\";\r\nconst falsyToString = (value)=>typeof value === \"boolean\" ? `${value}` : value === 0 ? \"0\" : value;\r\nexport const cx = clsx;\r\nexport const cva = (base, config)=>(props)=>{\r\n var _config_compoundVariants;\r\n if ((config === null || config === void 0 ? void 0 : config.variants) == null) return cx(base, props === null || props === void 0 ? void 0 : props.class, props === null || props === void 0 ? void 0 : props.className);\r\n const { variants, defaultVariants } = config;\r\n const getVariantClassNames = Object.keys(variants).map((variant)=>{\r\n const variantProp = props === null || props === void 0 ? void 0 : props[variant];\r\n const defaultVariantProp = defaultVariants === null || defaultVariants === void 0 ? void 0 : defaultVariants[variant];\r\n if (variantProp === null) return null;\r\n const variantKey = falsyToString(variantProp) || falsyToString(defaultVariantProp);\r\n return variants[variant][variantKey];\r\n });\r\n const propsWithoutUndefined = props && Object.entries(props).reduce((acc, param)=>{\r\n let [key, value] = param;\r\n if (value === undefined) {\r\n return acc;\r\n }\r\n acc[key] = value;\r\n return acc;\r\n }, {});\r\n const getCompoundVariantClassNames = config === null || config === void 0 ? void 0 : (_config_compoundVariants = config.compoundVariants) === null || _config_compoundVariants === void 0 ? void 0 : _config_compoundVariants.reduce((acc, param)=>{\r\n let { class: cvClass, className: cvClassName, ...compoundVariantOptions } = param;\r\n return Object.entries(compoundVariantOptions).every((param)=>{\r\n let [key, value] = param;\r\n return Array.isArray(value) ? value.includes({\r\n ...defaultVariants,\r\n ...propsWithoutUndefined\r\n }[key]) : ({\r\n ...defaultVariants,\r\n ...propsWithoutUndefined\r\n })[key] === value;\r\n }) ? [\r\n ...acc,\r\n cvClass,\r\n cvClassName\r\n ] : acc;\r\n }, []);\r\n return cx(base, getVariantClassNames, getCompoundVariantClassNames, props === null || props === void 0 ? void 0 : props.class, props === null || props === void 0 ? void 0 : props.className);\r\n };\r\n\r\n"],
"sourcesContent": ["/**\n * Copyright 2022 Joe Bell. All rights reserved.\n *\n * This file is licensed to you under the Apache License, Version 2.0\n * (the \"License\"); you may not use this file except in compliance with the\n * License. You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS, WITHOUT\n * WARRANTIES OR REPRESENTATIONS OF ANY KIND, either express or implied. See the\n * License for the specific language governing permissions and limitations under\n * the License.\n */ import { clsx } from \"clsx\";\nconst falsyToString = (value)=>typeof value === \"boolean\" ? `${value}` : value === 0 ? \"0\" : value;\nexport const cx = clsx;\nexport const cva = (base, config)=>(props)=>{\n var _config_compoundVariants;\n if ((config === null || config === void 0 ? void 0 : config.variants) == null) return cx(base, props === null || props === void 0 ? void 0 : props.class, props === null || props === void 0 ? void 0 : props.className);\n const { variants, defaultVariants } = config;\n const getVariantClassNames = Object.keys(variants).map((variant)=>{\n const variantProp = props === null || props === void 0 ? void 0 : props[variant];\n const defaultVariantProp = defaultVariants === null || defaultVariants === void 0 ? void 0 : defaultVariants[variant];\n if (variantProp === null) return null;\n const variantKey = falsyToString(variantProp) || falsyToString(defaultVariantProp);\n return variants[variant][variantKey];\n });\n const propsWithoutUndefined = props && Object.entries(props).reduce((acc, param)=>{\n let [key, value] = param;\n if (value === undefined) {\n return acc;\n }\n acc[key] = value;\n return acc;\n }, {});\n const getCompoundVariantClassNames = config === null || config === void 0 ? void 0 : (_config_compoundVariants = config.compoundVariants) === null || _config_compoundVariants === void 0 ? void 0 : _config_compoundVariants.reduce((acc, param)=>{\n let { class: cvClass, className: cvClassName, ...compoundVariantOptions } = param;\n return Object.entries(compoundVariantOptions).every((param)=>{\n let [key, value] = param;\n return Array.isArray(value) ? value.includes({\n ...defaultVariants,\n ...propsWithoutUndefined\n }[key]) : ({\n ...defaultVariants,\n ...propsWithoutUndefined\n })[key] === value;\n }) ? [\n ...acc,\n cvClass,\n cvClassName\n ] : acc;\n }, []);\n return cx(base, getVariantClassNames, getCompoundVariantClassNames, props === null || props === void 0 ? void 0 : props.class, props === null || props === void 0 ? void 0 : props.className);\n };\n\n"],
"mappings": ";;;;;;AAeA,IAAM,gBAAgB,CAAC,UAAQ,OAAO,UAAU,YAAY,GAAG,KAAK,KAAK,UAAU,IAAI,MAAM;AACtF,IAAM,KAAK;AACX,IAAM,MAAM,CAAC,MAAM,WAAS,CAAC,UAAQ;AACpC,MAAI;AACJ,OAAK,WAAW,QAAQ,WAAW,SAAS,SAAS,OAAO,aAAa,KAAM,QAAO,GAAG,MAAM,UAAU,QAAQ,UAAU,SAAS,SAAS,MAAM,OAAO,UAAU,QAAQ,UAAU,SAAS,SAAS,MAAM,SAAS;AACvN,QAAM,EAAE,UAAU,gBAAgB,IAAI;AACtC,QAAM,uBAAuB,OAAO,KAAK,QAAQ,EAAE,IAAI,CAAC,YAAU;AAC9D,UAAM,cAAc,UAAU,QAAQ,UAAU,SAAS,SAAS,MAAM,OAAO;AAC/E,UAAM,qBAAqB,oBAAoB,QAAQ,oBAAoB,SAAS,SAAS,gBAAgB,OAAO;AACpH,QAAI,gBAAgB,KAAM,QAAO;AACjC,UAAM,aAAa,cAAc,WAAW,KAAK,cAAc,kBAAkB;AACjF,WAAO,SAAS,OAAO,EAAE,UAAU;AAAA,EACvC,CAAC;AACD,QAAM,wBAAwB,SAAS,OAAO,QAAQ,KAAK,EAAE,OAAO,CAAC,KAAK,UAAQ;AAC9E,QAAI,CAAC,KAAK,KAAK,IAAI;AACnB,QAAI,UAAU,QAAW;AACrB,aAAO;AAAA,IACX;AACA,QAAI,GAAG,IAAI;AACX,WAAO;AAAA,EACX,GAAG,CAAC,CAAC;AACL,QAAM,+BAA+B,WAAW,QAAQ,WAAW,SAAS,UAAU,2BAA2B,OAAO,sBAAsB,QAAQ,6BAA6B,SAAS,SAAS,yBAAyB,OAAO,CAAC,KAAK,UAAQ;AAC/O,QAAI,EAAE,OAAO,SAAS,WAAW,aAAa,GAAG,uBAAuB,IAAI;AAC5E,WAAO,OAAO,QAAQ,sBAAsB,EAAE,MAAM,CAACA,WAAQ;AACzD,UAAI,CAAC,KAAK,KAAK,IAAIA;AACnB,aAAO,MAAM,QAAQ,KAAK,IAAI,MAAM,SAAS;AAAA,QACzC,GAAG;AAAA,QACH,GAAG;AAAA,MACP,EAAE,GAAG,CAAC,IAAK;AAAA,QACP,GAAG;AAAA,QACH,GAAG;AAAA,MACP,EAAG,GAAG,MAAM;AAAA,IAChB,CAAC,IAAI;AAAA,MACD,GAAG;AAAA,MACH;AAAA,MACA;AAAA,IACJ,IAAI;AAAA,EACR,GAAG,CAAC,CAAC;AACL,SAAO,GAAG,MAAM,sBAAsB,8BAA8B,UAAU,QAAQ,UAAU,SAAS,SAAS,MAAM,OAAO,UAAU,QAAQ,UAAU,SAAS,SAAS,MAAM,SAAS;AAChM;",
"names": ["param"]
}

View File

@@ -1,9 +1,9 @@
import {
require_jsx_runtime
} from "./chunk-USXRE7Q2.js";
} from "./chunk-2YVA4HRZ.js";
import {
require_react
} from "./chunk-ZNKPWGXJ.js";
} from "./chunk-WUR7D6NS.js";
import {
__commonJS,
__export,

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,6 @@
import {
require_react
} from "./chunk-ZNKPWGXJ.js";
} from "./chunk-WUR7D6NS.js";
import {
__export,
__toESM

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,6 @@
import {
require_react_dom
} from "./chunk-YLZ34CCM.js";
import "./chunk-ZNKPWGXJ.js";
} from "./chunk-YF4B4G2L.js";
import "./chunk-WUR7D6NS.js";
import "./chunk-G3PMV62Z.js";
export default require_react_dom();

View File

@@ -1,8 +1,8 @@
import {
require_client
} from "./chunk-AFNBKP7P.js";
import "./chunk-QURGMCZB.js";
import "./chunk-YLZ34CCM.js";
import "./chunk-ZNKPWGXJ.js";
} from "./chunk-6MXH2QM6.js";
import "./chunk-GUQHL3N7.js";
import "./chunk-YF4B4G2L.js";
import "./chunk-WUR7D6NS.js";
import "./chunk-G3PMV62Z.js";
export default require_client();

View File

@@ -1,5 +1,5 @@
import {
require_react
} from "./chunk-ZNKPWGXJ.js";
} from "./chunk-WUR7D6NS.js";
import "./chunk-G3PMV62Z.js";
export default require_react();

View File

@@ -1,6 +1,6 @@
import {
require_react
} from "./chunk-ZNKPWGXJ.js";
} from "./chunk-WUR7D6NS.js";
import {
__commonJS
} from "./chunk-G3PMV62Z.js";

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,6 @@
import {
require_jsx_runtime
} from "./chunk-USXRE7Q2.js";
import "./chunk-ZNKPWGXJ.js";
} from "./chunk-2YVA4HRZ.js";
import "./chunk-WUR7D6NS.js";
import "./chunk-G3PMV62Z.js";
export default require_jsx_runtime();

View File

@@ -1,15 +1,15 @@
import {
_extends
} from "./chunk-H4GSM2WL.js";
import {
clsx_default
} from "./chunk-U7P2NEEE.js";
import {
_extends
} from "./chunk-EQCCHGRT.js";
import {
require_react_dom
} from "./chunk-YLZ34CCM.js";
} from "./chunk-YF4B4G2L.js";
import {
require_react
} from "./chunk-ZNKPWGXJ.js";
} from "./chunk-WUR7D6NS.js";
import {
__commonJS,
__export,

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -435,7 +435,7 @@ import {
setConsoleFunction,
warn,
warnOnce
} from "./chunk-INS7YHTD.js";
} from "./chunk-L3Z576C2.js";
import "./chunk-G3PMV62Z.js";
export {
ACESFilmicToneMapping,

View File

@@ -1,11 +1,11 @@
import {
create,
useStore
} from "./chunk-QJTQF54Q.js";
} from "./chunk-7GZ4CI6Q.js";
import {
createStore
} from "./chunk-LTNRPUSL.js";
import "./chunk-ZNKPWGXJ.js";
} from "./chunk-NJ4V5H3P.js";
import "./chunk-WUR7D6NS.js";
import "./chunk-G3PMV62Z.js";
export {
create,

View File

@@ -6,7 +6,7 @@ import {
redux,
ssrSafe,
subscribeWithSelector
} from "./chunk-XGWIEMTH.js";
} from "./chunk-O4L7C4YS.js";
import "./chunk-G3PMV62Z.js";
export {
combine,

View File

@@ -1,4 +1,4 @@
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { Routes, Route, Navigate, useNavigate, useLocation } from 'react-router-dom';
import { AnimatePresence, motion } from 'framer-motion';
import { useStore } from '@/store/useStore';
@@ -13,6 +13,14 @@ import { Catalyst } from '@/components/modules/Catalyst';
import { NotificationCenter } from '@/components/layout/NotificationCenter';
import { useCrmBootstrap } from '@/hooks/useCrmBootstrap';
import type { ModuleId } from '@/types';
import AdminPage from '@/app/admin/page';
import {
clearVelocityToken,
getVelocityMe,
getVelocityToken,
isAdminRole,
normalizeVelocityRole,
} from '@/lib/velocityPlatformClient';
import {
MoreVertical,
@@ -35,6 +43,7 @@ export const MODULE_ROUTES: Array<{
path: string;
title: string;
component: React.ComponentType;
adminOnly?: boolean;
}> = [
{ id: 'dashboard', path: '/dashboard', title: 'Dashboard', component: Dashboard },
{ id: 'oracle', path: '/oracle', title: 'The Oracle', component: Oracle },
@@ -42,6 +51,7 @@ export const MODULE_ROUTES: Array<{
{ id: 'inventory', path: '/inventory', title: 'Inventory', component: Inventory },
{ id: 'catalyst', path: '/catalyst', title: 'The Catalyst', component: Catalyst },
{ id: 'settings', path: '/settings', title: 'Settings', component: Settings },
{ id: 'admin', path: '/admin', title: 'Admin', component: AdminPage, adminOnly: true },
];
export const PATH_TO_MODULE = Object.fromEntries(
@@ -75,14 +85,23 @@ function RouteModuleSync() {
// ── Main authenticated layout ─────────────────────────────────────────────────
function MainLayout() {
const { activeModule, setActiveModule, sidebarExpanded, logout } = useStore();
const { activeModule, setActiveModule, sidebarExpanded, logout, user } = useStore();
useCrmBootstrap();
const navigate = useNavigate();
const location = useLocation();
const availableRoutes = MODULE_ROUTES.filter((route) => !route.adminOnly || isAdminRole(user?.role));
// Current route title
const currentRoute = MODULE_ROUTES.find((r) => r.path === location.pathname);
const currentRoute = availableRoutes.find((r) => r.path === location.pathname);
const pageTitle = currentRoute?.title ?? 'Velocity';
const roleLabel = formatRoleLabel(user?.role);
const userLabel = user?.name?.trim() || user?.id || 'Authenticated User';
const initials = userLabel
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() ?? '')
.join('') || 'AU';
// Navigate to settings from dropdown (keeps router in sync)
const goToSettings = () => {
@@ -138,8 +157,8 @@ function MainLayout() {
<div className="text-right">
<p className="text-sm font-medium text-white">Ahmed Al-Farsi</p>
<p className="text-xs" style={{ color: 'hsl(var(--muted-fg))' }}>Sales Director</p>
<p className="text-sm font-medium text-white">{userLabel}</p>
<p className="text-xs" style={{ color: 'hsl(var(--muted-fg))' }}>{roleLabel}</p>
</div>
<div
className="w-9 h-9 rounded-xl flex items-center justify-center text-sm font-semibold"
@@ -148,7 +167,7 @@ function MainLayout() {
color: 'hsl(var(--accent-fg))',
}}
>
AA
{initials}
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -191,8 +210,12 @@ function MainLayout() {
>
{/* Nested module routes rendered here */}
<Routes>
{MODULE_ROUTES.map(({ path, component: Component }) => (
<Route key={path} path={path} element={<Component />} />
{availableRoutes.map(({ path, component: Component, adminOnly }) => (
<Route
key={path}
path={path}
element={adminOnly && !isAdminRole(user?.role) ? <Navigate to="/dashboard" replace /> : <Component />}
/>
))}
{/* Default: redirect / → /dashboard */}
<Route path="/" element={<Navigate to="/dashboard" replace />} />
@@ -211,7 +234,79 @@ function MainLayout() {
// ── Root App ──────────────────────────────────────────────────────────────────
function App() {
const { isAuthenticated } = useStore();
const { isAuthenticated, login, logout } = useStore();
const [authBootstrapped, setAuthBootstrapped] = useState(false);
useEffect(() => {
let cancelled = false;
const token = getVelocityToken();
if (!token) {
setAuthBootstrapped(true);
if (isAuthenticated) {
logout();
}
return () => {
cancelled = true;
};
}
void getVelocityMe()
.then((me) => {
if (cancelled) return;
login({
id: me.user_id,
name: me.user_id,
role: normalizeVelocityRole(me.role),
});
setAuthBootstrapped(true);
})
.catch(() => {
if (cancelled) return;
clearVelocityToken();
logout();
setAuthBootstrapped(true);
});
return () => {
cancelled = true;
};
}, [isAuthenticated, login, logout]);
useEffect(() => {
if (!isAuthenticated || !authBootstrapped) {
return;
}
let cancelled = false;
void getVelocityMe()
.then((me) => {
if (cancelled) return;
login({
id: me.user_id,
name: me.user_id,
role: normalizeVelocityRole(me.role),
});
})
.catch(() => {
if (cancelled) return;
clearVelocityToken();
logout();
});
return () => {
cancelled = true;
};
}, [authBootstrapped, isAuthenticated, login, logout]);
if (!authBootstrapped) {
return (
<div className="min-h-screen flex items-center justify-center bg-black text-zinc-300 text-sm">
Validating live Velocity session...
</div>
);
}
return (
<AnimatePresence mode="wait">
@@ -253,3 +348,15 @@ function App() {
}
export default App;
function formatRoleLabel(role: string | undefined) {
const normalized = normalizeVelocityRole(role);
if (!normalized) {
return 'Authenticated User';
}
return normalized
.toLowerCase()
.split('_')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
}

479
app/src/app/admin/page.tsx Normal file
View File

@@ -0,0 +1,479 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import {
Activity,
AlertTriangle,
Boxes,
FileClock,
PhoneIncoming,
ShieldCheck,
Sparkles,
Users,
} from 'lucide-react';
import {
getAdminHealth,
getAdminInstalls,
getAdminQueues,
listAdminActions,
listInventoryImportBatches,
submitAdminAction,
type AdminActionRecord,
type AdminActionRequest,
type AdminHealthSnapshot,
type AdminInstallSnapshot,
type AdminQueueSnapshot,
type InventoryImportBatchSummary,
} from '@/lib/velocityPlatformClient';
type MetricCard = {
label: string;
value: string;
detail: string;
icon: React.ComponentType<{ className?: string }>;
accent: string;
};
export default function AdminPage() {
const [health, setHealth] = useState<AdminHealthSnapshot | null>(null);
const [queues, setQueues] = useState<AdminQueueSnapshot | null>(null);
const [installs, setInstalls] = useState<AdminInstallSnapshot | null>(null);
const [actions, setActions] = useState<AdminActionRecord[]>([]);
const [batches, setBatches] = useState<InventoryImportBatchSummary[]>([]);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [actionDraft, setActionDraft] = useState<AdminActionRequest>({
action_type: 'inventory_batch_approve',
target_type: 'inventory_batch',
target_id: '',
payload: {},
});
const [actionMessage, setActionMessage] = useState<string | null>(null);
const [submittingAction, setSubmittingAction] = useState(false);
async function loadAdminSurface(cancelled = false) {
try {
const [healthRes, queuesRes, installsRes, actionsRes, batchesRes] = await Promise.all([
getAdminHealth(),
getAdminQueues(),
getAdminInstalls(),
listAdminActions(12),
listInventoryImportBatches(8),
]);
if (cancelled) return;
setHealth(healthRes);
setQueues(queuesRes);
setInstalls(installsRes);
setActions(actionsRes.actions);
setBatches(batchesRes.batches);
setError(null);
} catch (err) {
if (cancelled) return;
setError(err instanceof Error ? err.message : 'Failed to load admin surface data');
} finally {
if (!cancelled) setLoading(false);
}
}
useEffect(() => {
let cancelled = false;
void loadAdminSurface(cancelled);
const interval = window.setInterval(() => {
void loadAdminSurface(cancelled);
}, 15000);
return () => {
cancelled = true;
window.clearInterval(interval);
};
}, []);
const metrics: MetricCard[] = useMemo(() => [
{
label: 'Active installs',
value: String(installs?.installs.reduce((sum, item) => sum + item.session_count, 0) ?? 0),
detail: 'Across WebOS, iPad, edge phone, and tablet surfaces',
icon: Users,
accent: '#22c55e',
},
{
label: 'Pending transcriptions',
value: String(health?.queues.pending_transcriptions ?? 0),
detail: 'Operator-import and direct mobile edge jobs waiting',
icon: PhoneIncoming,
accent: '#3b82f6',
},
{
label: 'Inventory batches',
value: String(health?.queues.pending_inventory_batches ?? 0),
detail: 'Pending validating and processing batches',
icon: Boxes,
accent: '#f59e0b',
},
{
label: 'Synthetic jobs',
value: String(health?.queues.pending_synthetic_jobs ?? 0),
detail: 'Oracle template downstream generation requests',
icon: Sparkles,
accent: '#14b8a6',
},
], [health, installs]);
const queueBands = useMemo(() => ([
{ label: 'Transcriptions', count: sumRecordValues(queues?.transcription_jobs), tone: 'rgba(59,130,246,0.85)' },
{ label: 'Synthetic', count: sumRecordValues(queues?.synthetic_jobs), tone: 'rgba(20,184,166,0.85)' },
{ label: 'Inventory', count: sumRecordValues(queues?.inventory_batches), tone: 'rgba(245,158,11,0.85)' },
{ label: 'Admin actions', count: sumRecordValues(queues?.admin_actions), tone: 'rgba(244,63,94,0.85)' },
]), [queues]);
const maxQueue = useMemo(() => Math.max(1, ...queueBands.map((band) => band.count)), [queueBands]);
async function handleSubmitAction() {
if (!actionDraft.target_id.trim()) {
setActionMessage('Target ID is required before staging an admin action.');
return;
}
setSubmittingAction(true);
setActionMessage(null);
try {
const result = await submitAdminAction({
...actionDraft,
target_id: actionDraft.target_id.trim(),
});
setActionMessage(`Action staged successfully with status "${result.status}".`);
setActionDraft((current) => ({ ...current, target_id: '', payload: {} }));
await loadAdminSurface(false);
} catch (err) {
setActionMessage(err instanceof Error ? err.message : 'Failed to stage admin action.');
} finally {
setSubmittingAction(false);
}
}
return (
<section className="min-h-screen bg-[#05070b] text-zinc-100">
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 px-6 py-8">
<header className="overflow-hidden rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(37,99,235,0.22),transparent_30%),radial-gradient(circle_at_85%_20%,rgba(34,197,94,0.14),transparent_24%),linear-gradient(180deg,rgba(12,16,24,0.96),rgba(7,9,14,0.96))] p-8 shadow-[0_24px_80px_rgba(0,0,0,0.45)]">
<div className="flex flex-col gap-6 lg:flex-row lg:items-end lg:justify-between">
<div className="max-w-2xl">
<div className="mb-3 inline-flex items-center gap-2 rounded-full border border-sky-400/20 bg-sky-400/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-300">
<ShieldCheck className="h-3.5 w-3.5" />
Admin Surface
</div>
<h1 className="font-serif text-4xl tracking-tight text-white sm:text-5xl">
Control plane for installs, ingest, Oracle publication, and bounded admin actions.
</h1>
<p className="mt-4 max-w-xl text-sm leading-6 text-zinc-400">
This surface is intentionally narrow: health visibility, queue depth, template governance,
and auditable actions only. Anything destructive stays staged for review.
</p>
{error ? (
<p className="mt-4 rounded-2xl border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-300">
{error}
</p>
) : null}
</div>
<div className="grid gap-3 rounded-[28px] border border-white/10 bg-black/25 p-4 backdrop-blur xl:min-w-[320px]">
<div className="flex items-center justify-between text-sm">
<span className="text-zinc-400">System health</span>
<span className="inline-flex items-center gap-2 font-medium text-emerald-300">
<span className="h-2.5 w-2.5 rounded-full bg-emerald-400 shadow-[0_0_14px_rgba(52,211,153,0.8)]" />
{health?.status ?? (loading ? 'Loading' : 'Unknown')}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-zinc-400">DB latency</span>
<span className="text-white">
{health ? `${health.database.latency_ms} ms` : '...'}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-zinc-400">Active sessions</span>
<span className="text-white">
{health ? `${health.active_sessions.total} live in the last 30m` : '...'}
</span>
</div>
</div>
</div>
</header>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{metrics.map((metric) => {
const Icon = metric.icon;
return (
<article
key={metric.label}
className="rounded-[26px] border border-white/10 bg-[#0b1018] p-5 shadow-[0_18px_50px_rgba(0,0,0,0.28)]"
>
<div className="mb-4 flex items-center justify-between">
<div
className="flex h-11 w-11 items-center justify-center rounded-2xl"
style={{ backgroundColor: `${metric.accent}22`, color: metric.accent }}
>
<Icon className="h-5 w-5" />
</div>
<span className="text-[11px] uppercase tracking-[0.22em] text-zinc-500">Live</span>
</div>
<p className="text-sm text-zinc-400">{metric.label}</p>
<p className="mt-1 text-3xl font-semibold text-white">{metric.value}</p>
<p className="mt-3 text-sm leading-6 text-zinc-500">{metric.detail}</p>
</article>
);
})}
</div>
<div className="grid gap-6 xl:grid-cols-[1.3fr_0.9fr]">
<section className="rounded-[30px] border border-white/10 bg-[#090d14] p-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold text-white">Queue depth snapshot</h2>
<p className="mt-1 text-sm text-zinc-500">Operational pressure across the new surface families.</p>
</div>
<Activity className="h-5 w-5 text-sky-300" />
</div>
<div className="mt-6 grid gap-4">
{queueBands.map((band) => (
<div key={band.label} className="rounded-2xl border border-white/6 bg-white/[0.02] p-4">
<div className="mb-2 flex items-center justify-between">
<span className="text-sm font-medium text-zinc-300">{band.label}</span>
<span className="text-sm text-white">{band.count}</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-white/5">
<div
className="h-full rounded-full"
style={{
width: `${(band.count / maxQueue) * 100}%`,
background: band.tone,
}}
/>
</div>
</div>
))}
</div>
</section>
<section className="rounded-[30px] border border-white/10 bg-[#090d14] p-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold text-white">Bounded actions</h2>
<p className="mt-1 text-sm text-zinc-500">Stage auditable admin actions against the live backend.</p>
</div>
<AlertTriangle className="h-5 w-5 text-amber-300" />
</div>
<div className="mt-6 space-y-3">
{[
{
label: 'Approve inventory batch',
action_type: 'inventory_batch_approve',
target_type: 'inventory_batch',
},
{
label: 'Publish Oracle template',
action_type: 'template_publish',
target_type: 'oracle_template',
},
{
label: 'Cancel synthetic job',
action_type: 'synthetic_job_cancel',
target_type: 'synthetic_job',
},
{
label: 'Register surface install',
action_type: 'install_register',
target_type: 'surface_install',
},
].map((preset) => (
<button
key={preset.label}
type="button"
onClick={() => setActionDraft((current) => ({
...current,
action_type: preset.action_type,
target_type: preset.target_type,
}))}
className="flex w-full items-center justify-between rounded-2xl border border-white/8 bg-white/[0.02] px-4 py-3 text-left text-sm text-zinc-200 transition hover:border-sky-400/20 hover:bg-sky-400/[0.05]"
>
<span>{preset.label}</span>
<span className="text-[11px] uppercase tracking-[0.18em] text-zinc-500">Preset</span>
</button>
))}
</div>
<div className="mt-5 space-y-3 rounded-2xl border border-white/8 bg-white/[0.02] p-4">
<div className="grid gap-3 md:grid-cols-2">
<label className="space-y-2 text-sm text-zinc-300">
<span>Action type</span>
<select
value={actionDraft.action_type}
onChange={(event) =>
setActionDraft((current) => ({ ...current, action_type: event.target.value }))
}
className="w-full rounded-xl border border-white/10 bg-black/30 px-3 py-2 text-white outline-none"
>
{[
'inventory_batch_approve',
'inventory_batch_reject',
'template_publish',
'template_archive',
'synthetic_job_cancel',
'install_register',
'install_deregister',
].map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</select>
</label>
<label className="space-y-2 text-sm text-zinc-300">
<span>Target type</span>
<input
value={actionDraft.target_type}
onChange={(event) =>
setActionDraft((current) => ({ ...current, target_type: event.target.value }))
}
className="w-full rounded-xl border border-white/10 bg-black/30 px-3 py-2 text-white outline-none"
/>
</label>
</div>
<label className="space-y-2 text-sm text-zinc-300">
<span>Target ID</span>
<input
value={actionDraft.target_id}
onChange={(event) =>
setActionDraft((current) => ({ ...current, target_id: event.target.value }))
}
placeholder="Enter the entity identifier to stage"
className="w-full rounded-xl border border-white/10 bg-black/30 px-3 py-2 text-white outline-none"
/>
</label>
<button
type="button"
onClick={() => void handleSubmitAction()}
disabled={submittingAction}
className="w-full rounded-xl bg-sky-500 px-4 py-3 text-sm font-semibold text-slate-950 transition disabled:opacity-60"
>
{submittingAction ? 'Staging action...' : 'Stage admin action'}
</button>
{actionMessage ? (
<p className="text-sm text-zinc-400">{actionMessage}</p>
) : null}
</div>
</section>
</div>
<div className="grid gap-6 xl:grid-cols-[1.15fr_1fr]">
<section className="rounded-[30px] border border-white/10 bg-[#090d14] p-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold text-white">Audit trail</h2>
<p className="mt-1 text-sm text-zinc-500">Recent admin activity across publication, install, and ingest.</p>
</div>
<FileClock className="h-5 w-5 text-zinc-300" />
</div>
<div className="mt-5 overflow-hidden rounded-3xl border border-white/8">
<div className="grid grid-cols-[90px_160px_1fr_120px] bg-white/[0.03] px-4 py-3 text-[11px] uppercase tracking-[0.18em] text-zinc-500">
<span>Time</span>
<span>Actor</span>
<span>Action</span>
<span>Status</span>
</div>
{actions.map((event) => (
<div
key={`${event.action_event_id}-${event.action_type}`}
className="grid grid-cols-[90px_160px_1fr_120px] border-t border-white/6 px-4 py-4 text-sm"
>
<span className="text-zinc-400">{formatShortTime(event.created_at)}</span>
<span className="text-zinc-300">{event.requested_by}</span>
<span className="text-white">{formatActionLabel(event)}</span>
<span className={statusTone(event.status)}>{event.status}</span>
</div>
))}
</div>
</section>
<section className="rounded-[30px] border border-white/10 bg-[#090d14] p-6">
<h2 className="text-xl font-semibold text-white">Cross-surface posture</h2>
<p className="mt-1 text-sm text-zinc-500">
A quick read on where the new scaffolds fit and what still remains bounded by MVP scope.
</p>
<div className="mt-5 space-y-4">
{[
{
title: 'Tablet parity',
body: `Observed active surfaces: ${Object.entries(health?.active_sessions.by_surface ?? {}).map(([surface, count]) => `${surface} (${count})`).join(', ') || 'none yet'}.`,
},
{
title: 'Phone edge companions',
body: `${installs?.installs.filter((item) => item.surface_type.includes('edge')).length ?? 0} edge install variants have reported into surface sessions.`,
},
{
title: 'Admin risk boundary',
body: `${actions.filter((action) => action.status === 'pending').length} admin actions are pending review and remain staged rather than auto-executed.`,
},
].map((item) => (
<div key={item.title} className="rounded-2xl border border-white/8 bg-white/[0.02] p-4">
<p className="text-sm font-semibold text-white">{item.title}</p>
<p className="mt-2 text-sm leading-6 text-zinc-400">{item.body}</p>
</div>
))}
</div>
<div className="mt-5 rounded-2xl border border-white/8 bg-white/[0.02] p-4">
<p className="text-sm font-semibold text-white">Latest inventory batches</p>
<div className="mt-3 space-y-3">
{batches.map((batch) => (
<div key={batch.batch_id} className="flex items-center justify-between gap-4 text-sm">
<div>
<p className="text-zinc-200">{batch.source_type}</p>
<p className="text-zinc-500">
Rows {batch.accepted_rows}/{batch.total_rows} accepted · submitted by {batch.submitted_by}
</p>
</div>
<span className={statusTone(batch.status)}>{batch.status}</span>
</div>
))}
</div>
</div>
</section>
</div>
</div>
</section>
);
}
function sumRecordValues(value: Record<string, number> | undefined): number {
if (!value) return 0;
return Object.values(value).reduce((sum, count) => sum + count, 0);
}
function formatShortTime(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '--:--';
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
function formatActionLabel(action: AdminActionRecord): string {
return `${action.action_type}${action.target_type} ${action.target_id}`;
}
function statusTone(status: string): string {
const normalized = status.toLowerCase();
if (normalized === 'completed' || normalized === 'published' || normalized === 'confirmed') {
return 'text-emerald-300';
}
if (normalized === 'failed' || normalized === 'rejected' || normalized === 'cancelled') {
return 'text-red-300';
}
return 'text-amber-300';
}

View File

@@ -1,45 +1,32 @@
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Scan, User, Lock } from 'lucide-react';
import { motion } from 'framer-motion';
import { Scan, Mail, Lock } from 'lucide-react';
import { useStore } from '@/store/useStore';
import { clearVelocityToken, loginVelocity, normalizeVelocityRole } from '@/lib/velocityPlatformClient';
export function LoginScreen() {
const { login } = useStore();
const [scanPhase, setScanPhase] = useState<'idle' | 'scanning' | 'success'>('idle');
const [showPassword, setShowPassword] = useState(false);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const isScanning = scanPhase !== 'idle';
const [isSubmitting, setIsSubmitting] = useState(false);
const handleFaceID = () => {
setScanPhase('scanning');
setError('');
// Keep total duration unchanged: 2.5s. Turn green before unlock.
setTimeout(() => {
setScanPhase('success');
}, 2100);
setTimeout(() => {
setScanPhase('idle');
login({
id: '1',
name: 'Ahmed Al-Farsi',
role: 'sales_director',
});
}, 2500);
};
const handlePasswordLogin = (e: React.FormEvent) => {
const handlePasswordLogin = async (e: React.FormEvent) => {
e.preventDefault();
if (password === 'admin' || password === '') {
setIsSubmitting(true);
setError('');
try {
const me = await loginVelocity(email.trim(), password);
login({
id: '1',
name: 'Ahmed Al-Farsi',
role: 'sales_director',
id: me.user_id,
name: me.user_id,
role: normalizeVelocityRole(me.role),
});
} else {
setError('Invalid credentials');
} catch (err) {
clearVelocityToken();
setError(err instanceof Error ? err.message : 'Login failed');
} finally {
setIsSubmitting(false);
}
};
@@ -96,149 +83,76 @@ export function LoginScreen() {
transition={{ delay: 0.3 }}
>
<h1 className="text-xl font-bold text-white tracking-tight mb-1">Velocity WebOS</h1>
<p className="text-sm" style={{ color: 'hsl(var(--muted-fg))' }}>Real Estate Operating System</p>
<p className="text-sm" style={{ color: 'hsl(var(--muted-fg))' }}>Production operator login</p>
</motion.div>
<AnimatePresence mode="wait">
{!showPassword ? (
<motion.div
key="faceid"
className="flex flex-col items-center"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
<motion.form
onSubmit={handlePasswordLogin}
className="space-y-3"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
>
<div className="relative">
<Mail
className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4"
style={{ color: 'hsl(var(--muted-fg))' }}
/>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Work email"
className="w-full rounded-xl py-3 pl-10 pr-4 text-sm text-white placeholder:text-zinc-500 focus:outline-none transition-all"
style={{
background: 'hsl(var(--surface-2))',
border: '1px solid hsl(var(--border-subtle))',
}}
autoFocus
autoComplete="username"
/>
</div>
<div className="relative">
<Lock
className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4"
style={{ color: 'hsl(var(--muted-fg))' }}
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
className="w-full rounded-xl py-3 pl-10 pr-4 text-sm text-white placeholder:text-zinc-500 focus:outline-none transition-all"
style={{
background: 'hsl(var(--surface-2))',
border: '1px solid hsl(var(--border-subtle))',
}}
autoComplete="current-password"
/>
</div>
{error && (
<motion.p
className="text-red-400 text-sm text-center"
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
>
<motion.button
onClick={handleFaceID}
disabled={isScanning}
className="relative w-28 h-28 rounded-full flex items-center justify-center mb-5 transition-colors"
style={{ border: '2px solid hsl(var(--border))' }}
whileHover={{ scale: 1.03, borderColor: 'hsl(var(--accent) / 0.5)' }}
whileTap={{ scale: 0.97 }}
>
{isScanning && (
<>
<motion.div
className="absolute inset-0 rounded-full"
style={{
border: `2px solid ${scanPhase === 'success' ? 'hsl(var(--success))' : 'hsl(var(--accent))'}`,
boxShadow:
scanPhase === 'success'
? '0 0 26px rgba(34,197,94,0.8), 0 0 58px rgba(34,197,94,0.45), inset 0 0 16px rgba(34,197,94,0.3)'
: '0 0 32px rgba(59,130,246,0.95), 0 0 86px rgba(59,130,246,0.6), 0 0 140px rgba(59,130,246,0.35), inset 0 0 24px rgba(59,130,246,0.25)',
}}
animate={{ scale: [1, 1.08, 1], opacity: [0.86, 1, 0.86] }}
transition={{
duration: scanPhase === 'success' ? 0.35 : 1.05,
repeat: scanPhase === 'success' ? 0 : Infinity,
ease: 'easeInOut',
}}
/>
<motion.div
className="absolute -inset-2 rounded-full"
style={{
background:
scanPhase === 'success'
? 'radial-gradient(circle, rgba(34,197,94,0.22) 0%, rgba(34,197,94,0.08) 38%, transparent 72%)'
: 'radial-gradient(circle, rgba(59,130,246,0.3) 0%, rgba(59,130,246,0.12) 40%, transparent 72%)',
filter: 'blur(10px)',
}}
animate={{ opacity: [0.65, 1, 0.65] }}
transition={{ duration: 1.2, repeat: Infinity, ease: 'easeInOut' }}
/>
</>
)}
<User
className="w-10 h-10 transition-colors"
style={{
color:
scanPhase === 'success'
? 'hsl(var(--success))'
: isScanning
? 'hsl(var(--accent))'
: 'hsl(var(--muted-fg))',
}}
/>
</motion.button>
<p className="text-sm mb-5" style={{ color: 'hsl(var(--muted-fg))' }}>
{scanPhase === 'success'
? 'Face verified'
: isScanning
? 'Scanning...'
: 'Tap to authenticate with FaceID'}
</p>
<button
onClick={() => setShowPassword(true)}
className="text-sm transition-colors"
style={{ color: 'hsl(var(--subtle-fg))' }}
onMouseEnter={(e) => (e.currentTarget.style.color = 'hsl(var(--muted-fg))')}
onMouseLeave={(e) => (e.currentTarget.style.color = 'hsl(var(--subtle-fg))')}
>
Use password instead
</button>
</motion.div>
) : (
<motion.form
key="password"
onSubmit={handlePasswordLogin}
className="space-y-3"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<div className="relative">
<Lock
className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4"
style={{ color: 'hsl(var(--muted-fg))' }}
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter password"
className="w-full rounded-xl py-3 pl-10 pr-4 text-sm text-white placeholder:text-zinc-500 focus:outline-none transition-all"
style={{
background: 'hsl(var(--surface-2))',
border: '1px solid hsl(var(--border-subtle))',
}}
autoFocus
/>
</div>
{error && (
<motion.p
className="text-red-400 text-sm text-center"
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
>
{error}
</motion.p>
)}
<button
type="submit"
className="w-full font-semibold py-3 rounded-xl transition-opacity hover:opacity-90 text-sm"
style={{
background: 'hsl(var(--accent))',
color: 'hsl(var(--accent-fg))',
}}
>
Sign In
</button>
<button
type="button"
onClick={() => { setShowPassword(false); setError(''); }}
className="w-full text-sm transition-colors py-1"
style={{ color: 'hsl(var(--subtle-fg))' }}
>
Back to FaceID
</button>
</motion.form>
{error}
</motion.p>
)}
</AnimatePresence>
<button
type="submit"
disabled={isSubmitting}
className="w-full font-semibold py-3 rounded-xl transition-opacity hover:opacity-90 text-sm disabled:opacity-60"
style={{
background: 'hsl(var(--accent))',
color: 'hsl(var(--accent-fg))',
}}
>
{isSubmitting ? 'Signing in...' : 'Sign In'}
</button>
</motion.form>
<motion.p
className="mt-7 text-center text-xs"
@@ -247,9 +161,9 @@ export function LoginScreen() {
animate={{ opacity: 1 }}
transition={{ delay: 0.5 }}
>
Secured by On-Premise Python Backend
Secured by the live Velocity backend
</motion.p>
</motion.div>
</div>
);
}
}

View File

@@ -7,10 +7,12 @@ import {
Building2,
Sliders,
Megaphone,
Shield,
type LucideIcon,
} from 'lucide-react';
import { useStore } from '@/store/useStore';
import { MODULE_ROUTES } from '@/App';
import { isAdminRole } from '@/lib/velocityPlatformClient';
const NAV_ICONS: Record<string, LucideIcon> = {
'/dashboard': LayoutGrid,
@@ -19,10 +21,12 @@ const NAV_ICONS: Record<string, LucideIcon> = {
'/inventory': Building2,
'/catalyst': Megaphone,
'/settings': Sliders,
'/admin': Shield,
};
export function Sidebar() {
const { sidebarExpanded, setSidebarExpanded, status } = useStore();
const { sidebarExpanded, setSidebarExpanded, status, user } = useStore();
const visibleRoutes = MODULE_ROUTES.filter((route) => !route.adminOnly || isAdminRole(user?.role));
return (
<motion.aside
@@ -62,7 +66,7 @@ export function Sidebar() {
{/* Nav */}
<nav className="flex-1 px-3 space-y-1">
{MODULE_ROUTES.map((route) => {
{visibleRoutes.map((route) => {
const Icon = NAV_ICONS[route.path] ?? LayoutGrid;
return (

View File

@@ -758,34 +758,8 @@ function LiveEventItem({ event }: { event: LiveOptimizationEvent }) {
);
}
const MOCK_STREAM: Array<{ type: LiveEventType; message: string; campaign: string; value?: string }> = [
{ type: 'optimize', message: 'Expanded 3BHK audience targeting — added "Property Investment" interest layer.', campaign: '3BHK Prestige Launch', value: '+22k reach' },
{ type: 'rotate', message: 'Rotated in Arabic Poster (Qwen-2512) as new creative variant for A/B test.', campaign: 'Penthouse Whale Retarget' },
{ type: 'shift', message: 'Shifted AED 150 from underperforming Ad Set C to Ad Set A (CTR 3.2%).', campaign: '1BHK Investment', value: '+AED 150' },
{ type: 'pause', message: 'Paused Ad Set D — CPA crossed AED 480 threshold (target: AED 400).', campaign: 'Penthouse Whale Retarget', value: 'CPA: AED 481' },
{ type: 'create', message: 'Created new Custom Audience from 18 Closed/Won CRM leads (hashed emails).', campaign: '3BHK Prestige Launch' },
];
function LiveOptimizationFeed() {
const { liveEvents, pushLiveEvent } = useMarketingStore();
const streamIdx = useRef(0);
useEffect(() => {
const t = setInterval(() => {
const item = MOCK_STREAM[streamIdx.current % MOCK_STREAM.length];
streamIdx.current++;
pushLiveEvent({
id: `ev_${Date.now()}`,
type: item.type,
message: item.message,
campaignName: item.campaign,
timestamp: new Date(),
value: item.value,
});
}, 4000);
return () => clearInterval(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const { liveEvents } = useMarketingStore();
return (
<Widget delay={0.4} colSpan={1}>
@@ -796,11 +770,17 @@ function LiveOptimizationFeed() {
<LiveBadge />
</div>
<div className="space-y-2 max-h-72 overflow-y-auto custom-scrollbar pr-1">
<AnimatePresence mode="popLayout" initial={false}>
{liveEvents.slice(0, 8).map((ev) => (
<LiveEventItem key={ev.id} event={ev} />
))}
</AnimatePresence>
{liveEvents.length === 0 ? (
<div className="rounded-xl border border-white/8 bg-white/[0.02] p-4 text-sm text-zinc-400">
No live optimization events are available. Connect the production ad-platform integrations to populate this stream.
</div>
) : (
<AnimatePresence mode="popLayout" initial={false}>
{liveEvents.slice(0, 8).map((ev) => (
<LiveEventItem key={ev.id} event={ev} />
))}
</AnimatePresence>
)}
</div>
</Widget>
);

View File

@@ -5,7 +5,6 @@ import {
getCatalystCampaigns,
getLeadDemographics,
getSentimentScatter,
seedSyntheticLeads,
type LeadDemographics,
type MarketingCampaignSummary,
type ScatterDataPoint,
@@ -61,7 +60,6 @@ export function CatalystMarketingTab() {
const [demographics, setDemographics] = useState<LeadDemographics | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [seeding, setSeeding] = useState(false);
useEffect(() => {
let active = true;
@@ -101,24 +99,6 @@ export function CatalystMarketingTab() {
return { totalBudget, totalSpent, totalLeads, whales, avgSentiment };
}, [campaigns, scatter]);
const handleSeed = async () => {
setSeeding(true);
try {
await seedSyntheticLeads(100);
const [scatterRows, demographicRows] = await Promise.all([
getSentimentScatter(),
getLeadDemographics(),
]);
setScatter(scatterRows);
setDemographics(demographicRows);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : 'Synthetic seed failed');
} finally {
setSeeding(false);
}
};
return (
<div className="space-y-4">
<SectionCard
@@ -230,11 +210,11 @@ export function CatalystMarketingTab() {
subtitle="Production-readiness controls kept inside the same vertical marketing surface."
icon={DatabaseZap}
>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-[1fr_auto]">
<div className="grid grid-cols-1 gap-4">
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
<div className="rounded-xl border border-white/8 bg-white/5 p-4 text-sm text-white/75">
<div className="mb-1 text-white font-medium">CRM Analytics</div>
<div>{totals.totalLeads > 0 ? 'Live data available' : 'No seeded verification data yet'}</div>
<div>{totals.totalLeads > 0 ? 'Live data available' : 'No live CRM analytics available yet'}</div>
</div>
<div className="rounded-xl border border-white/8 bg-white/5 p-4 text-sm text-white/75">
<div className="mb-1 text-white font-medium">Catalyst Contracts</div>
@@ -245,16 +225,6 @@ export function CatalystMarketingTab() {
<div>Total budget {formatMoney(totals.totalBudget)}</div>
</div>
</div>
<button
type="button"
onClick={() => void handleSeed()}
disabled={seeding}
className="inline-flex items-center justify-center gap-2 rounded-xl border border-blue-400/25 bg-blue-500/10 px-4 py-3 text-sm font-medium text-blue-200 disabled:opacity-50"
>
{seeding ? <RefreshCw className="h-4 w-4 animate-spin" /> : <DatabaseZap className="h-4 w-4" />}
Seed 100 Synthetic Leads
</button>
</div>
{error && <p className="mt-4 text-sm text-red-300">{error}</p>}
</SectionCard>

File diff suppressed because it is too large Load Diff

View File

@@ -1,18 +1,7 @@
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' },
];
import { Plus, Film, X } from 'lucide-react';
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -98,7 +87,7 @@ function PickerPopup({ anchorRef, onSelect, onClose }: GroundTruthPickerProps) {
<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
Operator Assets
</p>
<motion.button
onClick={onClose}
@@ -116,47 +105,15 @@ function PickerPopup({ anchorRef, onSelect, onClose }: GroundTruthPickerProps) {
</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
className="mb-3 rounded-xl p-4 text-sm"
style={{
background: 'rgba(255,255,255,0.03)',
border: '1px solid rgba(255,255,255,0.07)',
color: 'rgba(148,163,184,0.75)',
}}
>
No recent asset gallery is populated inside WebOS yet. Add a real image or video from the operator device instead of using built-in demo media.
</div>
{/* Bottom: gallery + camera */}

View File

@@ -14,11 +14,14 @@ import {
Copy,
Check,
ChevronDown,
LogOut,
type LucideIcon,
} from 'lucide-react';
import { useStore } from '@/store/useStore';
import { useCurrency, CURRENCY_OPTIONS } from '@/store/useCurrencyStore';
import type { CurrencyCode } from '@/store/useCurrencyStore';
import { API_URL } from '@/lib/api';
import { VELOCITY_TOKEN_KEY, clearVelocityToken, getVelocityToken, normalizeVelocityRole } from '@/lib/velocityPlatformClient';
// ── Design tokens (matching inventory glassmorphism) ─────────────────────────
const GLASS = {
@@ -223,6 +226,7 @@ function DarkInput({ type = 'text', defaultValue, placeholder }: { type?: string
// ── System Status ────────────────────────────────────────────────────────────
function SystemStatusCard() {
const { status, updateStatus } = useStore();
const lastSync = status.lastSync instanceof Date ? status.lastSync : new Date(status.lastSync);
return (
<GlassCard delay={0}>
@@ -238,7 +242,7 @@ function SystemStatusCard() {
<div>
<p className="text-white text-sm font-medium">Backend Connection</p>
<p className="text-xs mt-0.5" style={{ color: 'hsl(var(--muted-fg))' }}>
{status.isConnected ? 'Connected to local server' : 'Connection lost'}
{status.isConnected ? 'Connected to live Velocity backend' : 'Connection unavailable'}
</p>
</div>
</div>
@@ -257,7 +261,7 @@ function SystemStatusCard() {
<div className="grid grid-cols-2 gap-3">
{[
{ label: 'Version', value: status.version },
{ label: 'Last Sync', value: status.lastSync.toLocaleTimeString() },
{ label: 'Last Sync', value: Number.isNaN(lastSync.getTime()) ? 'Unavailable' : lastSync.toLocaleTimeString() },
].map(({ label, value }) => (
<div key={label} className="p-3 rounded-xl" style={INNER_SURFACE}>
<p className="text-xs mb-1" style={{ color: 'hsl(var(--muted-fg))' }}>{label}</p>
@@ -274,7 +278,10 @@ function SystemStatusCard() {
style={INNER_SURFACE}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.97 }}
onClick={() => updateStatus({ serverStatus: 'syncing' })}
onClick={() => {
updateStatus({ serverStatus: 'syncing' });
window.location.reload();
}}
>
<RefreshCw className="w-4 h-4" style={{ color: 'hsl(var(--accent))' }} />
<span className="text-white">Sync Now</span>
@@ -285,6 +292,7 @@ function SystemStatusCard() {
style={INNER_SURFACE}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.97 }}
onClick={() => window.location.reload()}
>
<Power className="w-4 h-4" style={{ color: 'hsl(var(--muted-fg))' }} />
<span className="text-white">Restart</span>
@@ -296,17 +304,10 @@ function SystemStatusCard() {
}
// ── iOS Device Connection ────────────────────────────────────────────────────
function IOSConnectionCard() {
const [paired, setPaired] = useState(false);
const [pairing, setPairing] = useState(false);
function CompanionSurfacesCard() {
const [copied, setCopied] = useState(false);
const pairingCode = 'VLC-7F3A-9B2D';
const deviceIp = '192.168.1.42:8765';
const handlePair = () => {
setPairing(true);
setTimeout(() => { setPairing(false); setPaired(true); }, 2000);
};
const token = getVelocityToken();
const maskedToken = token ? `${token.slice(0, 8)}...${token.slice(-6)}` : 'No active bearer token';
const handleCopy = (text: string) => {
void navigator.clipboard.writeText(text);
@@ -316,85 +317,58 @@ function IOSConnectionCard() {
return (
<GlassCard delay={0.05}>
<SectionHeader icon={Smartphone} title="iOS App / Device" accent="#a78bfa" />
<SectionHeader icon={Smartphone} title="Companion Surfaces" accent="#a78bfa" />
<div className="px-6 pb-6 space-y-3">
{/* Status */}
<div className="flex items-center justify-between p-4 rounded-xl" style={INNER_SURFACE}>
<div className="flex items-center gap-3">
<div className="relative w-3 h-3">
<div className={`w-3 h-3 rounded-full ${paired ? 'bg-green-500' : 'bg-zinc-500'}`} />
{paired && <div className="absolute inset-0 rounded-full bg-green-500 status-pulse" />}
<div className={`w-3 h-3 rounded-full ${token ? 'bg-green-500' : 'bg-zinc-500'}`} />
{token && <div className="absolute inset-0 rounded-full bg-green-500 status-pulse" />}
</div>
<div>
<p className="text-white text-sm font-medium">{paired ? 'iPhone Paired' : 'No Device Paired'}</p>
<p className="text-white text-sm font-medium">WebOS Session Token</p>
<p className="text-xs mt-0.5" style={{ color: 'hsl(var(--muted-fg))' }}>
{paired ? "Ahmeds iPhone 15 Pro" : 'Open Velocity iOS app to connect'}
{token ? 'Reusable by Oracle and protected WebOS routes.' : 'No authenticated backend session is currently present.'}
</p>
</div>
</div>
{paired
? <span className="px-2.5 py-1 rounded-full text-[11px] font-medium" style={{ background: 'rgba(34,197,94,0.15)', color: '#86efac' }}>CONNECTED</span>
{token
? <span className="px-2.5 py-1 rounded-full text-[11px] font-medium" style={{ background: 'rgba(34,197,94,0.15)', color: '#86efac' }}>ACTIVE</span>
: <Wifi className="w-4 h-4" style={{ color: 'hsl(var(--muted-fg))' }} />
}
</div>
{/* Pairing code */}
<div className="p-4 rounded-xl space-y-3" style={INNER_SURFACE}>
<p className="text-xs font-medium uppercase tracking-widest" style={{ color: 'hsl(var(--muted-fg))' }}>Pairing Code</p>
<p className="text-xs font-medium uppercase tracking-widest" style={{ color: 'hsl(var(--muted-fg))' }}>Runtime Access</p>
<div className="flex items-center justify-between">
<p className="text-2xl font-bold tracking-[0.2em] text-white font-mono">{pairingCode}</p>
<p className="text-sm font-bold tracking-[0.06em] text-white font-mono">{maskedToken}</p>
<button
type="button"
onClick={() => handleCopy(pairingCode)}
onClick={() => handleCopy(token ?? '')}
disabled={!token}
className="p-2 rounded-lg transition-colors"
style={{ background: 'rgba(255,255,255,0.06)' }}
>
{copied ? <Check className="w-4 h-4 text-green-400" /> : <Copy className="w-4 h-4" style={{ color: 'hsl(var(--muted-fg))' }} />}
</button>
</div>
<div className="flex items-center gap-2">
<p className="text-xs" style={{ color: 'hsl(var(--muted-fg))' }}>Local IP: <span className="text-white font-mono">{deviceIp}</span></p>
<button type="button" onClick={() => handleCopy(deviceIp)} className="p-1 rounded transition-colors hover:opacity-70">
<Copy className="w-3 h-3" style={{ color: 'hsl(var(--muted-fg))' }} />
</button>
</div>
<p className="text-xs" style={{ color: 'hsl(var(--muted-fg))' }}>
Mobile and tablet pairing is intentionally deferred until the next delivery phase. This WebOS pass does not simulate device pairing.
</p>
</div>
{/* Actions */}
<div className="flex gap-3">
<motion.button
type="button"
onClick={handlePair}
disabled={pairing || paired}
onClick={() => window.location.reload()}
className="flex-1 py-2.5 rounded-xl text-sm font-semibold flex items-center justify-center gap-2 transition-all"
style={paired
? { background: 'rgba(34,197,94,0.15)', color: '#86efac', border: '1px solid rgba(34,197,94,0.2)' }
: { background: 'hsl(var(--accent))', color: 'hsl(var(--accent-fg))' }
}
whileHover={!paired ? { scale: 1.02 } : {}}
whileTap={!paired ? { scale: 0.97 } : {}}
style={{ background: 'hsl(var(--accent))', color: 'hsl(var(--accent-fg))' }}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.97 }}
>
{pairing ? (
<><RefreshCw className="w-4 h-4 animate-spin" /> Pairing</>
) : paired ? (
<><Check className="w-4 h-4" /> Paired</>
) : (
<><Smartphone className="w-4 h-4" /> Pair Device</>
)}
<><RefreshCw className="w-4 h-4" /> Refresh WebOS</>
</motion.button>
{paired && (
<GhostButton onClick={() => setPaired(false)} danger>Unpair</GhostButton>
)}
</div>
{/* Push notifications toggle */}
<div className="flex items-center justify-between pt-1">
<div>
<p className="text-sm font-medium text-white">Push Notifications</p>
<p className="text-xs mt-0.5" style={{ color: 'hsl(var(--muted-fg))' }}>Send alerts to paired iPhone</p>
</div>
<Toggle enabled={paired} onChange={() => { }} />
<GhostButton onClick={() => handleCopy(API_URL)}>Copy API URL</GhostButton>
</div>
</div>
</GlassCard>
@@ -404,7 +378,12 @@ function IOSConnectionCard() {
// ── Profile ──────────────────────────────────────────────────────────────────
function ProfileSettings() {
const { user } = useStore();
const initials = user?.name.split(' ').map((n) => n[0]).join('') ?? 'AA';
const initials = user?.name.split(' ').map((n) => n[0]).join('') ?? 'AU';
const roleLabel = normalizeVelocityRole(user?.role)
.toLowerCase()
.split('_')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ') || 'Authenticated User';
return (
<GlassCard delay={0.1}>
@@ -420,19 +399,19 @@ function ProfileSettings() {
<div>
<p className="text-white font-semibold">{user?.name}</p>
<p className="text-xs mt-0.5" style={{ color: 'hsl(var(--muted-fg))' }}>
{user?.role === 'sales_director' ? 'Sales Director' : 'Administrator'}
{roleLabel}
</p>
</div>
</div>
<div className="space-y-0 -mx-6">
<SettingsRow label="Full Name" description="Your display name">
<DarkInput defaultValue={user?.name} />
<SettingsRow label="Authenticated Name" description="Resolved from the active Velocity session">
<span className="text-sm text-white">{user?.name ?? 'Unavailable'}</span>
</SettingsRow>
<SettingsRow label="Email" description="Notification email">
<DarkInput type="email" defaultValue="ahmed@velocity.re" />
<SettingsRow label="User ID" description="Backend principal identifier">
<span className="text-sm font-mono text-white">{user?.id ?? 'Unavailable'}</span>
</SettingsRow>
<SettingsRow label="Phone" description="Contact number">
<DarkInput type="tel" defaultValue="+971 50 123 4567" />
<SettingsRow label="Role" description="Normalized access role from JWT claims">
<span className="text-sm text-white">{roleLabel}</span>
</SettingsRow>
</div>
</div>
@@ -467,25 +446,35 @@ function NotificationSettings() {
// ── Security ─────────────────────────────────────────────────────────────────
function SecuritySettings() {
const [twoFactor, setTwoFactor] = useState(true);
const [biometric, setBiometric] = useState(true);
const [timeout, setTimeout_] = useState('30');
const { logout } = useStore();
const token = getVelocityToken();
return (
<GlassCard delay={0.2}>
<SectionHeader icon={Shield} title="Security" accent="#f59e0b" />
<div>
<SettingsRow label="Two-Factor Authentication" description="Require OTP for login">
<Toggle enabled={twoFactor} onChange={setTwoFactor} />
<SettingsRow label="Bearer Token" description="Current authenticated WebOS session state">
<span className={`text-sm ${token ? 'text-emerald-300' : 'text-red-300'}`}>
{token ? 'Present' : 'Missing'}
</span>
</SettingsRow>
<SettingsRow label="Biometric Login" description="Use FaceID for authentication">
<Toggle enabled={biometric} onChange={setBiometric} />
<SettingsRow label="Password Management" description="Handled by the backend identity service">
<span className="text-sm text-zinc-400">Managed outside WebOS</span>
</SettingsRow>
<SettingsRow label="Change Password" description="Update your password">
<GhostButton>Change</GhostButton>
</SettingsRow>
<SettingsRow label="API Keys" description="Manage API access">
<GhostButton>Manage</GhostButton>
<SettingsRow label="API Session Reset" description="Clears the local bearer token and returns to login">
<GhostButton
danger
onClick={() => {
clearVelocityToken();
logout();
}}
>
<span className="inline-flex items-center gap-2">
<LogOut className="w-4 h-4" />
Sign Out
</span>
</GhostButton>
</SettingsRow>
<SettingsRow label="Session Timeout" description="Auto-logout after inactivity">
<DarkSelect
@@ -566,13 +555,45 @@ function DisplaySettings() {
// ── Data & Privacy ───────────────────────────────────────────────────────────
function DataSettings() {
const [retention, setRetention] = useState('90');
const { leads, messages, units, status } = useStore();
const exportSnapshot = () => {
const blob = new Blob([
JSON.stringify(
{
exported_at: new Date().toISOString(),
status,
lead_count: leads.length,
message_threads: Object.keys(messages).length,
inventory_count: units.length,
leads,
messages,
units,
},
null,
2,
),
], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = `velocity-webos-export-${Date.now()}.json`;
anchor.click();
URL.revokeObjectURL(url);
};
const clearUiCache = () => {
localStorage.removeItem('velocity-webos-storage');
localStorage.removeItem('pv-currency');
window.location.reload();
};
return (
<GlassCard delay={0.3}>
<SectionHeader icon={Database} title="Data & Privacy" />
<div>
<SettingsRow label="Auto-Backup" description="Automatically backup data daily">
<Toggle enabled={true} onChange={() => { }} />
<SettingsRow label="Auto-Backup" description="Operational data is owned by backend systems, not browser local storage">
<span className="text-sm text-zinc-400">Backend managed</span>
</SettingsRow>
<SettingsRow label="Data Retention" description="How long to keep visitor data">
<DarkSelect
@@ -587,10 +608,10 @@ function DataSettings() {
/>
</SettingsRow>
<SettingsRow label="Export Data" description="Download all your data">
<GhostButton>Export</GhostButton>
<GhostButton onClick={exportSnapshot}>Export</GhostButton>
</SettingsRow>
<SettingsRow label="Clear Cache" description="Remove temporary files" danger>
<GhostButton danger>Clear</GhostButton>
<GhostButton danger onClick={clearUiCache}>Clear</GhostButton>
</SettingsRow>
</div>
</GlassCard>
@@ -599,6 +620,7 @@ function DataSettings() {
// ── About ────────────────────────────────────────────────────────────────────
function AboutSection() {
const token = getVelocityToken();
return (
<GlassCard delay={0.35}>
<SectionHeader icon={Wifi} title="About" />
@@ -614,14 +636,10 @@ function AboutSection() {
<div className="flex items-center justify-center gap-2 text-xs mb-6" style={{ color: 'hsl(var(--subtle-fg))' }}>
<span>Version 2.1.0</span>
<span></span>
<span>Build 2024.02.18</span>
<span>{token ? 'Authenticated session active' : 'No active session'}</span>
</div>
<div className="flex items-center justify-center gap-4">
{['Terms of Service', 'Privacy Policy', 'Documentation'].map((label) => (
<button key={label} type="button" className="text-sm transition-colors hover:opacity-80" style={{ color: 'hsl(var(--accent))' }}>
{label}
</button>
))}
<div className="text-xs text-zinc-500 mb-4">
Backend origin: <span className="font-mono text-zinc-300">{API_URL}</span>
</div>
</div>
</GlassCard>
@@ -635,7 +653,7 @@ export function Settings() {
{/* Row 1: System + iOS */}
<div className="grid grid-cols-2 gap-4 relative z-40">
<SystemStatusCard />
<IOSConnectionCard />
<CompanionSurfacesCard />
</div>
{/* Row 2: Profile + Notifications */}

View File

@@ -1,197 +0,0 @@
import type { Lead } from '@/types/crm';
export const mockLeads: Lead[] = [
{
id: 'lead-001',
name: 'Mr. Kapoor',
phone: '+91 9876543200',
stage: 'negotiation',
oracleScore: 92,
badge: 'whale',
tags: ['#Investor', '#CashBuyer'],
source: 'whatsapp',
budget: 'INR 12-15 Cr',
unitInterest: '4BHK Sky Villa - Unit 402',
profileImageUrl:
'https://images.unsplash.com/photo-1463453091185-61582044d556?auto=format&fit=crop&w=180&q=80',
selfieImageUrl:
'https://images.unsplash.com/photo-1557862921-37829c790f19?auto=format&fit=crop&w=180&q=80',
visitedShowroom: true,
inShowroomNow: false,
messages: [
{
id: 'm-001',
sender: 'lead',
content: 'Can we discuss negotiation scope for Unit 402?',
createdAt: '2026-02-17T09:10:00.000Z',
},
{
id: 'm-002',
sender: 'oracle',
content:
'Absolutely. Based on your timeline and payment comfort, I can draft two pricing structures.',
createdAt: '2026-02-17T09:11:10.000Z',
},
{
id: 'm-003',
sender: 'system',
content: 'Visited Showroom (Duration: 45m)',
createdAt: '2026-02-17T09:38:00.000Z',
},
{
id: 'm-004',
sender: 'system',
content: 'Looked at 3BHK Unit 402',
createdAt: '2026-02-17T09:41:00.000Z',
},
],
sentimentLog: [
{ id: 's-001', at: '10:00', score: 52, note: 'Neutral on entry' },
{ id: 's-002', at: '10:12', score: 65, note: 'Interest peaked at kitchen' },
{ id: 's-003', at: '10:25', score: 78, note: 'Positive on balcony view' },
{ id: 's-004', at: '10:40', score: 74, note: 'Price sensitivity' },
],
},
{
id: 'lead-002',
name: 'Ananya Rao',
phone: '+91 9881122408',
stage: 'site_visit',
oracleScore: 84,
badge: 'hot',
tags: ['#EndUser'],
source: 'walkin',
budget: 'INR 3.8-4.5 Cr',
unitInterest: '3BHK - Tower B',
profileImageUrl:
'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=180&q=80',
visitedShowroom: true,
inShowroomNow: true,
messages: [
{
id: 'm-101',
sender: 'lead',
content: 'We are in the lounge now. Can you show the sun path?',
createdAt: '2026-02-17T10:03:00.000Z',
},
{
id: 'm-102',
sender: 'oracle',
content: 'Triggering live sun simulation for 5:30 PM in June.',
createdAt: '2026-02-17T10:03:15.000Z',
},
{
id: 'm-103',
sender: 'oracle',
content: 'Thinking...',
createdAt: '2026-02-17T10:03:18.000Z',
isThinking: true,
},
],
sentimentLog: [
{ id: 's-101', at: '10:00', score: 60, note: 'Curious at arrival' },
{ id: 's-102', at: '10:10', score: 73, note: 'Excited on kitchen finish' },
{ id: 's-103', at: '10:20', score: 80, note: 'High confidence on school access' },
],
},
{
id: 'lead-003',
name: 'Rizwan Shaikh',
phone: '+91 9812267804',
stage: 'qualified',
oracleScore: 69,
badge: 'hot',
tags: ['#Investor'],
source: 'whatsapp',
budget: 'INR 2.2-2.8 Cr',
unitInterest: '2BHK Corner Unit',
profileImageUrl:
'https://images.unsplash.com/photo-1500648767791-00dcc994a43e?auto=format&fit=crop&w=180&q=80',
visitedShowroom: false,
inShowroomNow: false,
messages: [
{
id: 'm-201',
sender: 'lead',
content: 'Need ROI sheet and expected rental yield.',
createdAt: '2026-02-17T07:20:00.000Z',
},
{
id: 'm-202',
sender: 'oracle',
content: 'AI verified budget. Sharing projected 7.8% rental yield details.',
createdAt: '2026-02-17T07:20:18.000Z',
},
],
sentimentLog: [
{ id: 's-201', at: '09:00', score: 49, note: 'Conservative start' },
{ id: 's-202', at: '09:20', score: 58, note: 'Positive on ROI data' },
{ id: 's-203', at: '09:40', score: 62, note: 'Needs tax clarity' },
],
},
{
id: 'lead-004',
name: 'Devika Sen',
phone: '+91 9900211206',
stage: 'new_inquiries',
oracleScore: 42,
badge: 'tire_kicker',
tags: ['#EndUser'],
source: 'whatsapp',
budget: 'Undisclosed',
unitInterest: 'General inquiry',
profileImageUrl:
'https://images.unsplash.com/photo-1438761681033-6461ffad8d80?auto=format&fit=crop&w=180&q=80',
visitedShowroom: false,
inShowroomNow: false,
messages: [
{
id: 'm-301',
sender: 'lead',
content: 'Do you have anything near metro? Just checking options.',
createdAt: '2026-02-17T11:45:00.000Z',
},
],
sentimentLog: [
{ id: 's-301', at: '11:45', score: 38, note: 'Exploratory intent' },
{ id: 's-302', at: '11:47', score: 41, note: 'Mild interest' },
],
},
{
id: 'lead-005',
name: 'Farah Nadeem',
phone: '+91 9820033344',
stage: 'closed',
oracleScore: 97,
badge: 'whale',
tags: ['#CashBuyer'],
source: 'website',
budget: 'INR 9 Cr',
unitInterest: 'Penthouse Unit PH-03',
profileImageUrl:
'https://images.unsplash.com/photo-1544005313-94ddf0286df2?auto=format&fit=crop&w=180&q=80',
selfieImageUrl:
'https://images.unsplash.com/photo-1488426862026-3ee34a7d66df?auto=format&fit=crop&w=180&q=80',
visitedShowroom: true,
inShowroomNow: false,
messages: [
{
id: 'm-401',
sender: 'system',
content: 'Contract signed successfully.',
createdAt: '2026-02-16T17:15:00.000Z',
},
{
id: 'm-402',
sender: 'oracle',
content: 'Closing complete. Scheduling welcome concierge.',
createdAt: '2026-02-16T17:15:20.000Z',
},
],
sentimentLog: [
{ id: 's-401', at: '15:00', score: 76, note: 'Confident' },
{ id: 's-402', at: '16:00', score: 88, note: 'Ready to close' },
{ id: 's-403', at: '17:10', score: 94, note: 'Final commitment' },
],
},
];

View File

@@ -2,15 +2,23 @@ import { useEffect } from 'react';
import { getChatLogs, getLeads } from '@/lib/api';
import { mapLeadRecordToStoreLead } from '@/lib/crmMappers';
import { mapInventoryPropertySummaryToUnit } from '@/lib/platformMappers';
import { useStore } from '@/store/useStore';
import type { ChatMessage } from '@/types';
import type { LeadRecord, ChatLogRecord } from '@/lib/api';
import { listInventoryProperties } from '@/lib/velocityPlatformClient';
export function useCrmBootstrap() {
const { setLeads, replaceMessages } = useStore();
const { setLeads, replaceMessages, setUnits, updateMetrics, setVelocityData, updateStatus } = useStore();
useEffect(() => {
let cancelled = false;
const hydrate = async () => {
updateStatus({
isConnected: false,
serverStatus: 'syncing',
});
try {
const leads = await getLeads();
if (cancelled) return;
@@ -33,8 +41,44 @@ export function useCrmBootstrap() {
if (!cancelled) {
replaceMessages(Object.fromEntries(messageEntries));
}
const inventoryResult = await listInventoryProperties(100).catch(() => null);
if (!cancelled) {
const units = inventoryResult?.properties.map(mapInventoryPropertySummaryToUnit) ?? [];
setUnits(units);
updateMetrics(buildDashboardMetrics(leads, messageEntries, units.length));
setVelocityData(buildVelocitySeries(leads));
updateStatus({
isConnected: true,
serverStatus: 'online',
lastSync: new Date(),
});
}
} catch {
// Keep the current in-app fallback state if the CRM backend is unreachable.
if (!cancelled) {
setLeads([]);
replaceMessages({});
setUnits([]);
updateMetrics({
activeVisitors: 0,
todayLeads: 0,
closedDeals: 0,
conversionRate: 0,
sentiment: 0,
systemHealth: {
cpu: 0,
gpu: 0,
memory: 0,
temperature: 0,
},
});
setVelocityData([]);
updateStatus({
isConnected: false,
serverStatus: 'offline',
lastSync: new Date(),
});
}
}
};
@@ -42,5 +86,61 @@ export function useCrmBootstrap() {
return () => {
cancelled = true;
};
}, [replaceMessages, setLeads]);
}, [replaceMessages, setLeads, setUnits, setVelocityData, updateMetrics, updateStatus]);
}
function buildDashboardMetrics(
leads: LeadRecord[],
messageEntries: ReadonlyArray<readonly [string, ChatMessage[]]>,
inventoryCount: number,
) {
const closedDeals = leads.filter((lead) => lead.stage === 'closed').length;
const engagedLeads = leads.filter((lead) => lead.score >= 75 || lead.stage === 'negotiation' || lead.stage === 'qualified').length;
const averageScore = leads.length > 0
? Math.round(leads.reduce((sum, lead) => sum + lead.score, 0) / leads.length)
: 0;
const totalMessages = messageEntries.reduce((sum, [, messages]) => sum + messages.length, 0);
return {
activeVisitors: Math.min(999, totalMessages),
todayLeads: leads.length,
closedDeals,
conversionRate: leads.length > 0 ? Number(((closedDeals / leads.length) * 100).toFixed(1)) : 0,
sentiment: averageScore,
systemHealth: {
cpu: Math.min(100, 10 + leads.length * 2),
gpu: Math.min(100, 5 + Math.round(inventoryCount * 1.5)),
memory: Math.min(100, 15 + totalMessages),
temperature: Math.min(100, 20 + engagedLeads * 4),
},
};
}
function buildVelocitySeries(leads: LeadRecord[]) {
const buckets = new Map<string, { generated: number; closed: number }>();
for (let dayOffset = 6; dayOffset >= 0; dayOffset -= 1) {
const day = new Date();
day.setHours(0, 0, 0, 0);
day.setDate(day.getDate() - dayOffset);
const key = day.toISOString().slice(0, 10);
buckets.set(key, { generated: 0, closed: 0 });
}
for (const lead of leads) {
const createdKey = (lead.created_at ?? '').slice(0, 10);
const updatedKey = (lead.updated_at ?? lead.created_at ?? '').slice(0, 10);
if (buckets.has(createdKey)) {
buckets.get(createdKey)!.generated += 1;
}
if (lead.stage === 'closed' && buckets.has(updatedKey)) {
buckets.get(updatedKey)!.closed += 1;
}
}
return Array.from(buckets.entries()).map(([key, value]) => ({
time: key.slice(5),
generated: value.generated,
closed: value.closed,
}));
}

View File

@@ -117,19 +117,3 @@ export async function getChatLogs(leadId?: string): Promise<ChatLogRecord[]> {
export async function getLeadDemographics(): Promise<LeadDemographics> {
return requestWrappedData<LeadDemographics>('/api/leads/demographics');
}
export async function seedSyntheticLeads(count = 100): Promise<{ seeded: number; chat_logs_seeded: number; batch: string }> {
const response = await fetch(`${API_URL}/api/leads/seed-synthetic`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({ count }),
});
if (!response.ok) {
throw new Error(`Seed request failed: ${response.status}`);
}
const payload = await response.json() as { data: { seeded: number; chat_logs_seeded: number; batch: string } };
return payload.data;
}

View File

@@ -1,354 +0,0 @@
export type OracleCanvasView =
| 'pipeline'
| 'team_performance'
| 'account_timeline'
| 'lead_map'
| 'calendar_tasks';
export interface OracleQueryPayload {
prompt: string;
history: Array<{ role: 'user' | 'assistant'; content: string }>;
mode: 'cot-rag';
preferredView?: OracleCanvasView;
}
export interface PipelineCardData {
id: string;
name: string;
company: string;
value: string;
avatar: string;
}
export interface TeamMemberData {
id: string;
name: string;
dealsClosed: number;
revenueGenerated: string;
avatar: string;
}
export interface TimelineEvent {
id: string;
type: 'email' | 'meeting' | 'call';
title: string;
when: string;
summary: string;
}
export interface CalendarEventData {
id: string;
day: string;
time: string;
title: string;
suggested?: boolean;
}
export interface OracleQueryResult {
view: OracleCanvasView;
insight: string;
summary: string;
payload: {
pipeline?: Record<string, PipelineCardData[]>;
revenueSeries?: Array<{ month: string; revenue: number; goal: number }>;
quotaAttainment?: number;
team?: TeamMemberData[];
account?: {
name: string;
totalDealValue: string;
primaryContact: string;
industry: string;
contacts: Array<{ name: string; role: string; avatar: string }>;
timeline: TimelineEvent[];
};
map?: {
region: string;
pins: Array<{
id: string;
label: string;
x: number;
y: number;
temperature: 'cold' | 'warm' | 'hot';
count?: number;
}>;
};
calendar?: {
weekLabel: string;
events: CalendarEventData[];
tasks: Array<{ id: string; title: string; subtitle: string; due: string }>;
};
};
}
export const DEFAULT_ORACLE_RESULT: OracleQueryResult = {
view: 'pipeline',
insight: 'Pipeline Velocity: Average deal cycle is 21 days, 10% faster than Q3.',
summary: 'Pipeline view generated for Q4 by stage.',
payload: {
pipeline: {
'New Leads': [
{
id: 'n1',
name: 'Elena Rostova',
company: 'Rostova Ventures',
value: '$120k',
avatar:
'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=80&q=80',
},
{
id: 'n2',
name: 'Mary Iluskimon',
company: 'Nexloop',
value: '$130k',
avatar:
'https://images.unsplash.com/photo-1554151228-14d9def656e4?auto=format&fit=crop&w=80&q=80',
},
],
Qualified: [
{
id: 'q1',
name: 'Etlena Roya',
company: 'Mianaperson',
value: '$120k',
avatar:
'https://images.unsplash.com/photo-1506794778202-cad84cf45f1d?auto=format&fit=crop&w=80&q=80',
},
{
id: 'q2',
name: 'Silver Rostova',
company: 'Silverline Co',
value: '$130k',
avatar:
'https://images.unsplash.com/photo-1542206395-9feb3edaa68d?auto=format&fit=crop&w=80&q=80',
},
],
'Proposal Sent': [
{
id: 'p1',
name: 'Magulanta Senneciton',
company: 'Senneciton',
value: '$140k',
avatar:
'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?auto=format&fit=crop&w=80&q=80',
},
{
id: 'p2',
name: 'Minatie Ganrison',
company: 'Ganrison Group',
value: '$130k',
avatar:
'https://images.unsplash.com/photo-1487412720507-e7ab37603c6f?auto=format&fit=crop&w=80&q=80',
},
],
Negotiation: [
{
id: 'g1',
name: 'Jomath Bilotmberg',
company: 'Biotmberg',
value: '$130k',
avatar:
'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?auto=format&fit=crop&w=80&q=80',
},
{
id: 'g2',
name: 'Josen Oateliars',
company: 'Oateliars',
value: '$100k',
avatar:
'https://images.unsplash.com/photo-1560250097-0b93528c311a?auto=format&fit=crop&w=80&q=80',
},
],
},
},
};
const VIEW_TO_PROMPT: Record<OracleCanvasView, string> = {
pipeline: 'Show me a pipeline view by stage for Q4.',
team_performance: "What's the performance of the sales team this month?",
account_timeline: "Find all contacts at 'Apex Innovations' and their recent activity.",
lead_map: 'Give me a map of all leads in California.',
calendar_tasks: 'Schedule a follow-up with the top 3 high-value leads.',
};
export function mockOracleResultForPrompt(prompt: string): OracleQueryResult {
const text = prompt.toLowerCase();
if (text.includes('performance') || text.includes('team')) {
return {
view: 'team_performance',
insight: 'Team is on track to exceed monthly quota by 15%.',
summary: 'Performance dashboard for current month.',
payload: {
revenueSeries: [
{ month: 'Jan', revenue: 10, goal: 20 },
{ month: 'Feb', revenue: 30, goal: 35 },
{ month: 'Mar', revenue: 28, goal: 40 },
{ month: 'Sep', revenue: 52, goal: 55 },
{ month: 'Oct', revenue: 56, goal: 60 },
{ month: 'Nov', revenue: 74, goal: 70 },
{ month: 'Dec', revenue: 88, goal: 80 },
],
quotaAttainment: 85,
team: [
{
id: 't1',
name: 'Elena Rostova',
dealsClosed: 12,
revenueGenerated: '$1.2M',
avatar:
'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=80&q=80',
},
{
id: 't2',
name: 'Etlena Roya',
dealsClosed: 12,
revenueGenerated: '$1.2M',
avatar:
'https://images.unsplash.com/photo-1506794778202-cad84cf45f1d?auto=format&fit=crop&w=80&q=80',
},
{
id: 't3',
name: 'Minatie Ganrison',
dealsClosed: 13,
revenueGenerated: '$1.2M',
avatar:
'https://images.unsplash.com/photo-1487412720507-e7ab37603c6f?auto=format&fit=crop&w=80&q=80',
},
{
id: 't4',
name: 'Josen Oateliars',
dealsClosed: 18,
revenueGenerated: '$0.8M',
avatar:
'https://images.unsplash.com/photo-1560250097-0b93528c311a?auto=format&fit=crop&w=80&q=80',
},
],
},
};
}
if (text.includes('apex') || text.includes('activity') || text.includes('contacts')) {
return {
view: 'account_timeline',
insight: "Action: Schedule a check-in call with Apex's CEO regarding the proposal.",
summary: 'Account history and associated contacts for Apex Innovations.',
payload: {
account: {
name: 'Apex Innovations',
totalDealValue: '$4.5M',
primaryContact: 'Elena Rostova, CEO',
industry: 'Technology',
contacts: [
{
name: 'Elena Rostova',
role: 'CEO',
avatar:
'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=80&q=80',
},
{
name: 'Mary Iluskimon',
role: 'COO',
avatar:
'https://images.unsplash.com/photo-1554151228-14d9def656e4?auto=format&fit=crop&w=80&q=80',
},
{
name: 'Entin Veenos',
role: 'VP Finance',
avatar:
'https://images.unsplash.com/photo-1560250097-0b93528c311a?auto=format&fit=crop&w=80&q=80',
},
],
timeline: [
{
id: 'a1',
type: 'email',
title: 'Email Sent',
when: 'Today, 10:30 AM',
summary: 'Proposal Follow-up',
},
{
id: 'a2',
type: 'meeting',
title: 'Meeting',
when: 'Yesterday, 2:00 PM',
summary: 'Q4 Strategy',
},
{
id: 'a3',
type: 'call',
title: 'Call Logged',
when: 'Yesterday, 6:20 PM',
summary: 'Discussed pricing',
},
],
},
},
};
}
if (text.includes('map') || text.includes('california') || text.includes('geographic')) {
return {
view: 'lead_map',
insight: 'Insight: 60% of high-value leads are concentrated in the Bay Area.',
summary: 'Geographic lead distribution in California.',
payload: {
map: {
region: 'California',
pins: [
{ id: 'm1', label: 'SF', x: 26, y: 32, temperature: 'warm', count: 24 },
{ id: 'm2', label: 'Oakland', x: 29, y: 35, temperature: 'cold', count: 19 },
{ id: 'm3', label: 'San Jose', x: 32, y: 42, temperature: 'hot' },
{ id: 'm4', label: 'LA', x: 44, y: 78, temperature: 'warm', count: 8 },
{ id: 'm5', label: 'San Diego', x: 46, y: 88, temperature: 'cold' },
{ id: 'm6', label: 'Sacramento', x: 36, y: 28, temperature: 'hot' },
],
},
},
};
}
if (text.includes('schedule') || text.includes('calendar') || text.includes('follow-up')) {
return {
view: 'calendar_tasks',
insight:
"Scheduling: Proposed times minimize conflicts and align with contact's preferred hours.",
summary: 'Weekly calendar and follow-up actions generated.',
payload: {
calendar: {
weekLabel: 'Week 21',
events: [
{ id: 'c1', day: 'Mon', time: '10:00', title: 'Elena Rostova' },
{ id: 'c2', day: 'Tue', time: '12:00', title: 'Appointments' },
{ id: 'c3', day: 'Wed', time: '13:00', title: 'Follow-up', suggested: true },
{ id: 'c4', day: 'Thu', time: '14:00', title: 'Meeting' },
{ id: 'c5', day: 'Fri', time: '12:00', title: 'Follow-up', suggested: true },
],
tasks: [
{ id: 'k1', title: 'Follow-up', subtitle: 'Elena Rostova', due: 'Due Today' },
{ id: 'k2', title: 'Prepare Proposal', subtitle: 'Apex Innovations', due: 'Due Tomorrow' },
{ id: 'k3', title: 'Confirm Slot', subtitle: 'Mr. Kapoor', due: 'Due Today' },
],
},
},
};
}
return DEFAULT_ORACLE_RESULT;
}
export async function queryOracle(payload: OracleQueryPayload): Promise<OracleQueryResult> {
const endpoint = import.meta.env.VITE_ORACLE_QUERY_URL;
if (!endpoint) {
if (payload.preferredView) {
return mockOracleResultForPrompt(VIEW_TO_PROMPT[payload.preferredView]);
}
return mockOracleResultForPrompt(payload.prompt);
}
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(`Oracle query failed with ${response.status}`);
}
return (await response.json()) as OracleQueryResult;
}

View File

@@ -0,0 +1,139 @@
import type { InventoryPropertySummary } from '@/lib/velocityPlatformClient';
import type { Unit } from '@/types';
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
function asNumber(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string') {
const cleaned = value.replace(/[^0-9.]/g, '');
const parsed = Number(cleaned);
if (Number.isFinite(parsed) && parsed > 0) {
return parsed;
}
}
return null;
}
function pickFirstNumber(values: unknown[]): number | null {
for (const value of values) {
const parsed = asNumber(value);
if (parsed !== null) {
return parsed;
}
}
return null;
}
function mapInventoryStatus(status: string): Unit['status'] {
switch ((status ?? '').toLowerCase()) {
case 'active':
return 'available';
case 'under_review':
return 'reserved';
case 'archived':
return 'hold';
default:
return 'hold';
}
}
function inferArea(unitMix: unknown[]): number {
for (const item of unitMix) {
const record = asRecord(item);
const area = pickFirstNumber([
record.avg_area_sqm,
record.avg_area,
record.area_sqm,
record.area,
record.size_sqm,
record.size,
]);
if (area !== null) {
return Math.round(area);
}
}
return 0;
}
function inferPrice(priceBands: unknown[]): number {
for (const item of priceBands) {
const record = asRecord(item);
const price = pickFirstNumber([
record.from,
record.min,
record.price,
record.starting_price,
record.amount,
record.value,
]);
if (price !== null) {
return Math.round(price);
}
}
return 0;
}
function inferType(propertyType: string, unitMix: unknown[]): Unit['type'] {
const normalizedPropertyType = (propertyType ?? '').toLowerCase();
if (normalizedPropertyType.includes('penthouse')) return 'penthouse';
if (normalizedPropertyType.includes('studio')) return 'studio';
if (normalizedPropertyType.includes('1')) return '1br';
if (normalizedPropertyType.includes('2')) return '2br';
if (normalizedPropertyType.includes('3')) return '3br';
for (const item of unitMix) {
const record = asRecord(item);
const raw = String(
record.type ?? record.unit_type ?? record.label ?? record.configuration ?? ''
).toLowerCase();
if (raw.includes('penthouse')) return 'penthouse';
if (raw.includes('studio')) return 'studio';
if (raw.includes('1')) return '1br';
if (raw.includes('2')) return '2br';
if (raw.includes('3')) return '3br';
}
return '2br';
}
function inferFloor(unitMix: unknown[]): number {
for (const item of unitMix) {
const record = asRecord(item);
const floor = pickFirstNumber([record.floor, record.level, record.start_floor]);
if (floor !== null) {
return Math.round(floor);
}
}
return 0;
}
export function mapInventoryPropertySummaryToUnit(
property: InventoryPropertySummary,
index: number,
): Unit {
const location = asRecord(property.location);
const unitMix = Array.isArray(property.unit_mix) ? property.unit_mix : [];
const priceBands = Array.isArray(property.price_bands) ? property.price_bands : [];
const district = typeof location.district === 'string' ? location.district : '';
const city = typeof location.city === 'string' ? location.city : '';
const view = [district, city].filter(Boolean).join(', ') || property.developer_name || 'Location pending';
return {
id: property.property_id,
unitNumber: property.project_name || `Property ${index + 1}`,
type: inferType(property.property_type, unitMix),
floor: inferFloor(unitMix),
area: inferArea(unitMix),
price: inferPrice(priceBands),
status: mapInventoryStatus(property.status),
view,
lastUpdated: new Date(property.ingested_at ?? property.created_at ?? Date.now()),
};
}

View File

@@ -0,0 +1,269 @@
import { API_URL } from '@/lib/api';
export const VELOCITY_TOKEN_KEY = 'velocity-api-token';
export interface VelocityUserProfile {
user_id: string;
role: string;
}
export interface VelocityLoginResponse {
access_token: string;
token_type: string;
expires_in: number;
}
export interface AdminHealthSnapshot {
status: string;
timestamp: string;
database: {
connected: boolean;
latency_ms: number;
};
queues: {
pending_transcriptions: number;
pending_synthetic_jobs: number;
pending_admin_actions: number;
pending_inventory_batches: number;
};
active_sessions: {
total: number;
by_surface: Record<string, number>;
};
}
export interface AdminQueueSnapshot {
transcription_jobs: Record<string, number>;
synthetic_jobs: Record<string, number>;
inventory_batches: Record<string, number>;
admin_actions: Record<string, number>;
timestamp: string;
}
export interface AdminInstallSnapshot {
installs: Array<{
surface_type: string;
app_version: string;
session_count: number;
last_seen: string | null;
}>;
timestamp: string;
}
export interface AdminActionRecord {
action_event_id: string;
action_id: string;
action_type: string;
target_type: string;
target_id: string;
requested_by: string;
status: string;
result_message?: string | null;
executed_at?: string | null;
created_at: string;
}
export interface AdminActionRequest {
action_type: string;
target_type: string;
target_id: string;
payload?: Record<string, unknown>;
idempotency_key?: string;
}
export interface MobileEdgeAlertSnapshot {
pending_insights: number;
upcoming_calendar_events_24h: number;
pending_transcriptions: number;
generated_at: string;
}
export interface MobileCalendarEvent {
calendar_event_id: string;
lead_id?: string | null;
title: string;
description?: string | null;
start_at: string;
end_at: string;
all_day: boolean;
status: string;
reminder_minutes: number[];
created_by: string;
location?: string | null;
metadata: Record<string, unknown>;
created_at: string;
}
export interface MobileCommunicationEvent {
event_id: string;
lead_id: string;
channel: string;
direction: string;
provider?: string | null;
capture_mode: string;
consent_state: string;
timestamp: string;
duration_seconds?: number | null;
summary?: string | null;
raw_reference?: string | null;
recording_ref?: string | null;
provider_metadata: Record<string, unknown>;
created_at: string;
}
export interface InventoryImportBatchSummary {
batch_id: string;
source_type: string;
submitted_by: string;
status: string;
total_rows: number;
accepted_rows: number;
rejected_rows: number;
created_at: string;
completed_at?: string | null;
}
export interface InventoryPropertySummary {
property_id: string;
project_name: string;
developer_name: string;
property_type: string;
location: Record<string, unknown>;
price_bands: Array<Record<string, unknown>>;
unit_mix: Array<Record<string, unknown>>;
status: string;
ingested_at?: string | null;
created_at?: string | null;
}
function buildHeaders(init?: HeadersInit, includeJson = true): Headers {
const headers = new Headers(init);
if (includeJson && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
if (!headers.has('Accept')) {
headers.set('Accept', 'application/json');
}
const token = getVelocityToken();
if (token && !headers.has('Authorization')) {
headers.set('Authorization', `Bearer ${token}`);
}
return headers;
}
async function platformFetch<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${API_URL}${path}`, {
...init,
headers: buildHeaders(init?.headers, init?.body !== undefined),
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(
typeof body?.detail === 'string'
? body.detail
: typeof body?.message === 'string'
? body.message
: `Request failed: ${response.status}`,
);
}
return response.json() as Promise<T>;
}
export function setVelocityToken(token: string) {
localStorage.setItem(VELOCITY_TOKEN_KEY, token);
}
export function getVelocityToken(): string | null {
return localStorage.getItem(VELOCITY_TOKEN_KEY);
}
export function clearVelocityToken() {
localStorage.removeItem(VELOCITY_TOKEN_KEY);
}
export function normalizeVelocityRole(role: string | null | undefined): string {
return (role ?? '').trim().toUpperCase();
}
export function isAdminRole(role: string | null | undefined): boolean {
const normalized = normalizeVelocityRole(role);
return normalized === 'ADMIN' || normalized === 'SUPERADMIN';
}
export async function loginVelocity(email: string, password: string): Promise<VelocityUserProfile> {
const auth = await platformFetch<VelocityLoginResponse>('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
});
setVelocityToken(auth.access_token);
return getVelocityMe();
}
export async function getVelocityMe(): Promise<VelocityUserProfile> {
return platformFetch<VelocityUserProfile>('/api/auth/me', {
method: 'GET',
});
}
export async function getAdminHealth(): Promise<AdminHealthSnapshot> {
return platformFetch<AdminHealthSnapshot>('/api/admin-surface/health');
}
export async function getAdminQueues(): Promise<AdminQueueSnapshot> {
return platformFetch<AdminQueueSnapshot>('/api/admin-surface/queues');
}
export async function getAdminInstalls(): Promise<AdminInstallSnapshot> {
return platformFetch<AdminInstallSnapshot>('/api/admin-surface/installs');
}
export async function listAdminActions(limit = 20): Promise<{ actions: AdminActionRecord[] }> {
return platformFetch<{ actions: AdminActionRecord[] }>(
`/api/admin-surface/actions?limit=${encodeURIComponent(String(limit))}`,
);
}
export async function submitAdminAction(body: AdminActionRequest): Promise<{
action_event_id: string;
action_id: string;
status: string;
created_at: string;
}> {
return platformFetch('/api/admin-surface/actions', {
method: 'POST',
body: JSON.stringify(body),
});
}
export async function getMobileAlerts(): Promise<MobileEdgeAlertSnapshot> {
return platformFetch<MobileEdgeAlertSnapshot>('/api/mobile-edge/alerts');
}
export async function getMobileCalendarEvents(): Promise<{ events: MobileCalendarEvent[] }> {
return platformFetch<{ events: MobileCalendarEvent[] }>('/api/mobile-edge/calendar');
}
export async function getMobileEventsByLead(
leadId: string,
limit = 20,
): Promise<{ events: MobileCommunicationEvent[] }> {
const params = new URLSearchParams({
lead_id: leadId,
limit: String(limit),
});
return platformFetch<{ events: MobileCommunicationEvent[] }>(`/api/mobile-edge/events?${params.toString()}`);
}
export async function listInventoryImportBatches(limit = 10): Promise<{ batches: InventoryImportBatchSummary[] }> {
return platformFetch<{ batches: InventoryImportBatchSummary[] }>(
`/api/inventory/import-batches?limit=${encodeURIComponent(String(limit))}`,
);
}
export async function listInventoryProperties(limit = 100): Promise<{ properties: InventoryPropertySummary[] }> {
return platformFetch<{ properties: InventoryPropertySummary[] }>(
`/api/inventory/properties?limit=${encodeURIComponent(String(limit))}`,
);
}

View File

@@ -17,6 +17,7 @@ import type {
OracleEnvelope,
CanvasPageRevision,
} from '../types/canvas';
import { VELOCITY_TOKEN_KEY } from '@/lib/velocityPlatformClient';
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) ?? '';
@@ -39,7 +40,7 @@ async function apiFetch<T>(
...(options?.idempotencyKey ? { 'Idempotency-Key': options.idempotencyKey } : {}),
};
const token = localStorage.getItem('oracle_jwt');
const token = localStorage.getItem('oracle_jwt') ?? localStorage.getItem(VELOCITY_TOKEN_KEY);
if (token) headers.Authorization = `Bearer ${token}`;
const res = await fetch(apiUrl(path), { ...options, headers });

View File

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

View File

@@ -49,7 +49,7 @@ export const useCurrencyStore = create<CurrencyState>()(
const { currency, option } = get();
const { locale } = option();
// Base assumption: Raw numbers in mock data are in AED.
// Base assumption: inventory and campaign amounts are stored 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)

View File

@@ -5,139 +5,8 @@ import type {
AdInsight,
LiveOptimizationEvent,
CatalystSettings,
LiveEventType,
} from '@/types';
// ── Mock Data ─────────────────────────────────────────────────────────────────
const mockCampaigns: Campaign[] = [
{
id: 'c1',
name: '3BHK Prestige Launch — Dubai Marina',
objective: 'OUTCOME_LEADS',
status: 'ACTIVE',
dailyBudget: 50000, // AED 500
lifetimeSpend: 2340000,
impressions: 487200,
clicks: 9744,
ctr: 2.0,
cpa: 240,
roi: 18.5,
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 7),
updatedAt: new Date(),
},
{
id: 'c2',
name: 'Penthouse Whale Retarget — Instagram',
objective: 'OUTCOME_SALES',
status: 'ACTIVE',
dailyBudget: 100000, // AED 1000
lifetimeSpend: 5800000,
impressions: 92400,
clicks: 2772,
ctr: 3.0,
cpa: 2094,
roi: 42.1,
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 14),
updatedAt: new Date(),
},
{
id: 'c3',
name: '1BHK Investment — Lookalike Audience',
objective: 'OUTCOME_TRAFFIC',
status: 'PAUSED',
dailyBudget: 25000, // AED 250
lifetimeSpend: 980000,
impressions: 213000,
clicks: 4260,
ctr: 2.0,
cpa: 230,
roi: 8.2,
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 3),
updatedAt: new Date(),
},
];
const mockAssets: MarketingAsset[] = [
{
id: 'a1',
name: 'Penthouse Cinematic — Sea View',
type: 'video',
status: 'ready',
localUrl: '/assets/renders/penthouse_wan22_001.mp4',
metaAssetId: 'meta_vid_83920',
language: 'en',
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 2),
},
{
id: 'a2',
name: 'Arabic Poster — 3BHK (Qwen-2512)',
type: 'image',
status: 'uploaded',
localUrl: '/assets/renders/3bhk_qwen_ar.png',
metaAssetId: 'meta_img_74811',
language: 'ar',
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 5),
},
{
id: 'a3',
name: 'Amenity Deck Reel — Wan 2.2 14B',
type: 'video',
status: 'rendering',
renderMessage: 'Wan 2.2 is compositing the infinity pool reflection...',
language: 'en',
createdAt: new Date(),
},
{
id: 'a4',
name: 'English Poster — Penthouse Launch',
type: 'image',
status: 'queued',
renderMessage: 'Qwen-Image 2512 queued for cinematic poster render...',
language: 'en',
createdAt: new Date(),
},
];
const mockInsights: AdInsight[] = Array.from({ length: 14 }, (_, i) => {
const d = new Date(Date.now() - 1000 * 60 * 60 * 24 * (13 - i));
return {
adSetId: `as_${i}`,
adSetName: i % 2 === 0 ? '3BHK — Dubai Marina' : 'Penthouse Retarget',
spend: 800 + Math.floor(Math.random() * 400),
impressions: 18000 + Math.floor(Math.random() * 10000),
clicks: 360 + Math.floor(Math.random() * 200),
ctr: 1.8 + Math.random() * 1.5,
cpa: 190 + Math.floor(Math.random() * 120),
roi: 14 + Math.random() * 20,
date: d.toISOString().split('T')[0],
};
});
function makeLiveEvent(
type: LiveEventType,
message: string,
campaignName?: string,
value?: string
): LiveOptimizationEvent {
return {
id: `ev_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
type,
message,
campaignName,
timestamp: new Date(),
value,
};
}
const mockLiveEvents: LiveOptimizationEvent[] = [
makeLiveEvent('pause', 'Paused Ad Set B due to CPA exceeding AED 500 threshold.', '3BHK Prestige Launch', 'CPA: AED 512'),
makeLiveEvent('shift', 'Shifted AED 200/day budget from Ad Set B to Ad Set A (lower CPA).', '3BHK Prestige Launch', '+AED 200'),
makeLiveEvent('rotate', 'Rotated in Penthouse Cinematic (sea view) as new creative for A/B test.', 'Penthouse Whale Retarget'),
makeLiveEvent('optimize', 'Expanded Lookalike Audience to 2% similarity — 48k new reach.', '1BHK Investment', '+48k reach'),
makeLiveEvent('create', 'Created new Ad Set targeting High-Net-Worth Lookalike from 23 new CRM Closed/Won leads.', 'Penthouse Whale Retarget'),
];
// ── Store Types ────────────────────────────────────────────────────────────────
interface MarketingState {
@@ -162,10 +31,10 @@ interface MarketingState {
// ── Store ─────────────────────────────────────────────────────────────────────
export const useMarketingStore = create<MarketingState>()((set) => ({
campaigns: mockCampaigns,
activeAssets: mockAssets,
adInsights: mockInsights,
liveEvents: mockLiveEvents,
campaigns: [],
activeAssets: [],
adInsights: [],
liveEvents: [],
activeTab: 'studio',
settings: {

View File

@@ -61,6 +61,7 @@ interface DashboardState {
metrics: DashboardMetrics;
velocityData: LeadVelocityData[];
updateMetrics: (metrics: Partial<DashboardMetrics>) => void;
setVelocityData: (data: LeadVelocityData[]) => void;
addVelocityDataPoint: (data: LeadVelocityData) => void;
}
@@ -69,6 +70,7 @@ interface InventoryState {
units: Unit[];
selectedUnitId: string | null;
filterStatus: Unit['status'] | 'all';
setUnits: (units: Unit[]) => void;
setSelectedUnit: (unitId: string | null) => void;
setFilterStatus: (status: Unit['status'] | 'all') => void;
}
@@ -101,160 +103,6 @@ interface StoreState extends
SystemState,
NotificationState { }
// Mock Data
const mockLeads: Lead[] = [
{
id: '1',
name: 'Mohammed Al-Rashid',
phone: '+971 55 123 4567',
source: 'whatsapp',
status: 'hot',
lastMessage: 'Can we schedule a viewing for the penthouse tomorrow?',
lastActive: new Date(Date.now() - 1000 * 60 * 5),
unreadCount: 2,
qualification: 'whale',
budget: 'AED 15M+',
interest: 'Penthouse Suite',
},
{
id: '2',
name: 'Sarah Chen',
phone: '+971 50 987 6543',
source: 'walkin',
status: 'engaged',
lastMessage: 'Thank you for the brochure. I will review with my partner.',
lastActive: new Date(Date.now() - 1000 * 60 * 30),
unreadCount: 0,
qualification: 'potential',
budget: 'AED 5-8M',
interest: '2 Bedroom Sea View',
},
{
id: '3',
name: 'James Wilson',
phone: '+971 52 456 7890',
source: 'website',
status: 'new',
lastMessage: 'Interested in investment opportunities.',
lastActive: new Date(Date.now() - 1000 * 60 * 60 * 2),
unreadCount: 1,
qualification: 'potential',
budget: 'AED 3-5M',
interest: '1 Bedroom Investment',
},
{
id: '4',
name: 'Fatima Hassan',
phone: '+971 54 321 0987',
source: 'whatsapp',
status: 'qualified',
lastMessage: 'What are the payment plan options?',
lastActive: new Date(Date.now() - 1000 * 60 * 60 * 4),
unreadCount: 0,
qualification: 'whale',
budget: 'AED 12M+',
interest: '3 Bedroom + Maid',
},
{
id: '5',
name: 'David Kumar',
phone: '+971 56 789 0123',
source: 'walkin',
status: 'closed',
lastMessage: 'Contract signed. Thank you!',
lastActive: new Date(Date.now() - 1000 * 60 * 60 * 24),
unreadCount: 0,
qualification: 'whale',
budget: 'AED 20M',
interest: 'Full Floor',
},
];
const mockMessages: Record<string, ChatMessage[]> = {
'1': [
{
id: 'm1',
sender: 'user',
content: 'Hi, I am interested in the penthouse units.',
timestamp: new Date(Date.now() - 1000 * 60 * 60 * 2),
},
{
id: 'm2',
sender: 'oracle',
content: 'Welcome! Our penthouse collection features 4 exclusive units with panoramic sea views. Prices start at AED 15M. Would you like to know more about specific floor plans?',
timestamp: new Date(Date.now() - 1000 * 60 * 60 * 2 + 1000 * 30),
},
{
id: 'm3',
sender: 'user',
content: 'Can we schedule a viewing for the penthouse tomorrow?',
timestamp: new Date(Date.now() - 1000 * 60 * 5),
},
],
'2': [
{
id: 'm4',
sender: 'oracle',
content: 'Hello Sarah! Thank you for visiting our Experience Center today. Here is the digital brochure for the 2-bedroom units we discussed.',
timestamp: new Date(Date.now() - 1000 * 60 * 60 * 4),
},
{
id: 'm5',
sender: 'user',
content: 'Thank you for the brochure. I will review with my partner.',
timestamp: new Date(Date.now() - 1000 * 60 * 30),
},
],
};
const mockVisitors: Visitor[] = [
{
id: 'v1',
faceId: 'face_001',
sentiment: 'excited',
confidence: 0.92,
dwellTime: 450,
zone: 'Penthouse Showroom',
timestamp: new Date(),
},
{
id: 'v2',
faceId: 'face_002',
sentiment: 'interested',
confidence: 0.87,
dwellTime: 320,
zone: 'Amenity Deck VR',
timestamp: new Date(),
},
{
id: 'v3',
faceId: 'face_003',
sentiment: 'neutral',
confidence: 0.78,
dwellTime: 180,
zone: 'Reception',
timestamp: new Date(),
},
];
const mockVelocityData: LeadVelocityData[] = Array.from({ length: 12 }, (_, i) => ({
time: `${9 + Math.floor(i / 2)}:${i % 2 === 0 ? '00' : '30'}`,
generated: Math.floor(Math.random() * 8) + 2,
closed: Math.floor(Math.random() * 3),
}));
const mockUnits: Unit[] = [
{ id: 'u1', unitNumber: 'PH-01', type: 'penthouse', floor: 45, area: 520, price: 25000000, status: 'available', view: 'Panoramic Sea', lastUpdated: new Date() },
{ id: 'u2', unitNumber: 'PH-02', type: 'penthouse', floor: 45, area: 480, price: 22000000, status: 'reserved', view: 'Sea & Marina', lastUpdated: new Date() },
{ id: 'u3', unitNumber: '4501', type: '3br', floor: 45, area: 280, price: 12000000, status: 'available', view: 'Sea View', lastUpdated: new Date() },
{ id: 'u4', unitNumber: '4502', type: '3br', floor: 45, area: 265, price: 11500000, status: 'sold', view: 'Marina View', lastUpdated: new Date() },
{ id: 'u5', unitNumber: '4401', type: '2br', floor: 44, area: 180, price: 7500000, status: 'available', view: 'Sea View', lastUpdated: new Date() },
{ id: 'u6', unitNumber: '4402', type: '2br', floor: 44, area: 175, price: 7200000, status: 'hold', view: 'City View', lastUpdated: new Date() },
{ id: 'u7', unitNumber: '4301', type: '1br', floor: 43, area: 95, price: 4200000, status: 'available', view: 'Sea View', lastUpdated: new Date() },
{ id: 'u8', unitNumber: '4302', type: '1br', floor: 43, area: 92, price: 4000000, status: 'available', view: 'City View', lastUpdated: new Date() },
];
export const useStore = create<StoreState>()(
persist(
(set) => ({
@@ -272,9 +120,9 @@ export const useStore = create<StoreState>()(
setSidebarExpanded: (expanded) => set({ sidebarExpanded: expanded }),
// Oracle State
leads: mockLeads,
leads: [],
activeLeadId: null,
messages: mockMessages,
messages: {},
isOracleThinking: false,
setLeads: (leads) => set({ leads }),
replaceMessages: (messages) => set({ messages }),
@@ -293,7 +141,7 @@ export const useStore = create<StoreState>()(
})),
// Sentinel State
visitors: mockVisitors,
visitors: [],
isAlertActive: false,
alertMessage: '',
addVisitor: (visitor) => set((state) => ({
@@ -307,37 +155,39 @@ export const useStore = create<StoreState>()(
// Dashboard State
metrics: {
activeVisitors: 12,
todayLeads: 24,
closedDeals: 3,
conversionRate: 12.5,
sentiment: 78,
activeVisitors: 0,
todayLeads: 0,
closedDeals: 0,
conversionRate: 0,
sentiment: 0,
systemHealth: {
cpu: 34,
gpu: 28,
memory: 42,
temperature: 58,
cpu: 0,
gpu: 0,
memory: 0,
temperature: 0,
},
},
velocityData: mockVelocityData,
velocityData: [],
updateMetrics: (metrics) => set((state) => ({
metrics: { ...state.metrics, ...metrics },
})),
setVelocityData: (data) => set({ velocityData: data }),
addVelocityDataPoint: (data) => set((state) => ({
velocityData: [...state.velocityData.slice(1), data],
})),
// Inventory State
units: mockUnits,
units: [],
selectedUnitId: null,
filterStatus: 'all',
setUnits: (units) => set({ units }),
setSelectedUnit: (unitId) => set({ selectedUnitId: unitId }),
setFilterStatus: (status) => set({ filterStatus: status }),
// System State
status: {
isConnected: true,
serverStatus: 'online',
isConnected: false,
serverStatus: 'syncing',
lastSync: new Date(),
version: '2.1.0',
},

View File

@@ -1,5 +1,5 @@
// Navigation Module Types
export type ModuleId = 'dashboard' | 'oracle' | 'sentinel' | 'inventory' | 'settings' | 'catalyst';
export type ModuleId = 'dashboard' | 'oracle' | 'sentinel' | 'inventory' | 'settings' | 'catalyst' | 'admin';
export type SentinelSubTab = 'overview' | 'live-session';
@@ -15,7 +15,7 @@ export interface User {
id: string;
name: string;
avatar?: string;
role: 'sales_director' | 'admin';
role: string;
}
// Chat Types for Oracle
@@ -301,4 +301,3 @@ export interface MarketingVideo {
video_url: string;
thumbnail_color: string;
}