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:
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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 */}
|
||||
|
||||
@@ -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 ? "Ahmed’s 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 */}
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
];
|
||||
Reference in New Issue
Block a user