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

@@ -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' },
],
},
];