901 lines
36 KiB
TypeScript
901 lines
36 KiB
TypeScript
import { useEffect, useRef, useState, type ChangeEvent } from 'react';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
import {
|
|
User,
|
|
Bell,
|
|
Shield,
|
|
Database,
|
|
Monitor,
|
|
RefreshCw,
|
|
Power,
|
|
Server,
|
|
Smartphone,
|
|
Wifi,
|
|
Copy,
|
|
Check,
|
|
ChevronDown,
|
|
LogOut,
|
|
Pencil,
|
|
MessageCircle,
|
|
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 { fetchCommsSettings, testCommsProviderConnection, updateCommsSettings } from '@/lib/commsApi';
|
|
import type { CommsProvider, CommsSettings } from '@/types/commsTypes';
|
|
import {
|
|
clearVelocityToken,
|
|
getVelocityToken,
|
|
normalizeVelocityRole,
|
|
resolveVelocityFirstName,
|
|
uploadVelocityAvatar,
|
|
} from '@/lib/velocityPlatformClient';
|
|
|
|
// ── Design tokens (matching inventory glassmorphism) ─────────────────────────
|
|
const GLASS = {
|
|
background: 'rgba(14, 16, 21, 0.72)',
|
|
border: '1px solid rgba(255,255,255,0.08)',
|
|
backdropFilter: 'blur(18px)',
|
|
WebkitBackdropFilter: 'blur(18px)',
|
|
} as const;
|
|
|
|
const INNER_SURFACE = {
|
|
background: 'rgba(255,255,255,0.04)',
|
|
border: '1px solid rgba(255,255,255,0.07)',
|
|
} as const;
|
|
|
|
// ── Shared primitives ────────────────────────────────────────────────────────
|
|
|
|
function GlassCard({
|
|
children,
|
|
delay = 0,
|
|
className = '',
|
|
}: {
|
|
children: React.ReactNode;
|
|
delay?: number;
|
|
className?: string;
|
|
}) {
|
|
return (
|
|
<motion.div
|
|
className={`relative rounded-2xl ${className}`}
|
|
style={GLASS}
|
|
initial={{ opacity: 0, y: 16 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.35, delay }}
|
|
>
|
|
{children}
|
|
</motion.div>
|
|
);
|
|
}
|
|
|
|
function SectionHeader({ icon: Icon, title, accent = 'hsl(var(--accent))' }: { icon: LucideIcon; title: string; accent?: string }) {
|
|
return (
|
|
<div className="flex items-center gap-3 px-6 pt-6 pb-4 mb-2" style={{ borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
|
|
<div
|
|
className="w-9 h-9 rounded-xl flex items-center justify-center flex-shrink-0"
|
|
style={{ background: `${accent}22`, border: `1px solid ${accent}33` }}
|
|
>
|
|
<Icon className="w-4 h-4" style={{ color: accent }} />
|
|
</div>
|
|
<h3 className="text-white font-semibold text-base tracking-tight">{title}</h3>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SettingsRow({
|
|
label,
|
|
description,
|
|
children,
|
|
danger = false,
|
|
}: {
|
|
label: string;
|
|
description?: string;
|
|
children: React.ReactNode;
|
|
danger?: boolean;
|
|
}) {
|
|
return (
|
|
<div
|
|
className="flex items-center justify-between px-6 py-3.5"
|
|
style={{ borderBottom: '1px solid rgba(255,255,255,0.04)' }}
|
|
>
|
|
<div className="flex-1 min-w-0 pr-4">
|
|
<p className={`text-sm font-medium ${danger ? 'text-red-400' : 'text-white'}`}>{label}</p>
|
|
{description && (
|
|
<p className="text-xs mt-0.5" style={{ color: 'hsl(var(--muted-fg))' }}>{description}</p>
|
|
)}
|
|
</div>
|
|
<div className="flex-shrink-0">{children}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Toggle ───────────────────────────────────────────────────────────────────
|
|
function Toggle({ enabled, onChange }: { enabled: boolean; onChange: (v: boolean) => void }) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={() => onChange(!enabled)}
|
|
className="relative w-11 h-6 rounded-full transition-colors duration-200 flex-shrink-0"
|
|
style={{ background: enabled ? 'hsl(var(--accent))' : 'rgba(255,255,255,0.12)' }}
|
|
>
|
|
<motion.div
|
|
className="absolute top-1 w-4 h-4 rounded-full bg-white shadow-sm"
|
|
animate={{ left: enabled ? '24px' : '4px' }}
|
|
transition={{ type: 'spring', stiffness: 500, damping: 32 }}
|
|
/>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
// ── Dark Select ──────────────────────────────────────────────────────────────
|
|
function DarkSelect({
|
|
value,
|
|
onChange,
|
|
options,
|
|
}: {
|
|
value: string;
|
|
onChange: (v: string) => void;
|
|
options: { value: string; label: string }[];
|
|
}) {
|
|
const [open, setOpen] = useState(false);
|
|
const current = options.find((o) => o.value === value) ?? options[0];
|
|
|
|
return (
|
|
<div className="relative">
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpen((p) => !p)}
|
|
className="flex items-center gap-2 rounded-xl px-3 py-2 text-sm text-white transition-colors min-w-[140px] justify-between"
|
|
style={INNER_SURFACE}
|
|
>
|
|
<span>{current.label}</span>
|
|
<ChevronDown
|
|
className="w-3.5 h-3.5 transition-transform flex-shrink-0"
|
|
style={{ color: 'hsl(var(--muted-fg))', transform: open ? 'rotate(180deg)' : 'rotate(0deg)' }}
|
|
/>
|
|
</button>
|
|
<AnimatePresence>
|
|
{open && (
|
|
<motion.div
|
|
className="absolute right-0 top-full mt-1.5 z-50 rounded-xl overflow-hidden min-w-[160px]"
|
|
style={{
|
|
background: 'rgba(18,20,26,0.96)',
|
|
border: '1px solid rgba(255,255,255,0.1)',
|
|
backdropFilter: 'blur(24px)',
|
|
WebkitBackdropFilter: 'blur(24px)',
|
|
boxShadow: '0 16px 48px rgba(0,0,0,0.5)',
|
|
}}
|
|
initial={{ opacity: 0, y: -6, scale: 0.97 }}
|
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
|
exit={{ opacity: 0, y: -4, scale: 0.97 }}
|
|
transition={{ duration: 0.15 }}
|
|
>
|
|
{options.map((opt) => (
|
|
<button
|
|
key={opt.value}
|
|
type="button"
|
|
onClick={() => { onChange(opt.value); setOpen(false); }}
|
|
className="w-full text-left px-4 py-2.5 text-sm transition-colors"
|
|
style={{
|
|
color: opt.value === value ? 'hsl(var(--accent))' : 'rgba(255,255,255,0.85)',
|
|
background: opt.value === value ? 'hsl(var(--accent) / 0.1)' : 'transparent',
|
|
}}
|
|
onMouseEnter={(e) => { if (opt.value !== value) (e.currentTarget as HTMLElement).style.background = 'rgba(255,255,255,0.06)'; }}
|
|
onMouseLeave={(e) => { if (opt.value !== value) (e.currentTarget as HTMLElement).style.background = 'transparent'; }}
|
|
>
|
|
{opt.label}
|
|
</button>
|
|
))}
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
{open && <div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Ghost button ─────────────────────────────────────────────────────────────
|
|
function GhostButton({ children, onClick, danger = false }: { children: React.ReactNode; onClick?: () => void; danger?: boolean }) {
|
|
return (
|
|
<motion.button
|
|
type="button"
|
|
onClick={onClick}
|
|
className="px-4 py-2 rounded-xl text-sm font-medium transition-colors"
|
|
style={danger
|
|
? { background: 'rgba(239,68,68,0.12)', color: '#f87171', border: '1px solid rgba(239,68,68,0.2)' }
|
|
: { ...INNER_SURFACE, color: 'rgba(255,255,255,0.8)' }
|
|
}
|
|
whileHover={{ scale: 1.02 }}
|
|
whileTap={{ scale: 0.97 }}
|
|
>
|
|
{children}
|
|
</motion.button>
|
|
);
|
|
}
|
|
|
|
// ── Text input ───────────────────────────────────────────────────────────────
|
|
|
|
// ── System Status ────────────────────────────────────────────────────────────
|
|
function SystemStatusCard() {
|
|
const { status, updateStatus } = useStore();
|
|
const lastSync = status.lastSync instanceof Date ? status.lastSync : new Date(status.lastSync);
|
|
|
|
return (
|
|
<GlassCard delay={0}>
|
|
<SectionHeader icon={Server} title="System Status" />
|
|
<div className="px-6 pb-6 space-y-3">
|
|
{/* Connection pill */}
|
|
<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 ${status.isConnected ? 'bg-green-500' : 'bg-red-500'}`} />
|
|
{status.isConnected && <div className="absolute inset-0 rounded-full bg-green-500 status-pulse" />}
|
|
</div>
|
|
<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 live Velocity backend' : 'Connection unavailable'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<span
|
|
className="px-2.5 py-1 rounded-full text-[11px] font-medium"
|
|
style={status.isConnected
|
|
? { background: 'rgba(34,197,94,0.15)', color: '#86efac' }
|
|
: { background: 'rgba(239,68,68,0.15)', color: '#fca5a5' }
|
|
}
|
|
>
|
|
{status.serverStatus.toUpperCase()}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Version / sync grid */}
|
|
<div className="grid grid-cols-2 gap-3">
|
|
{[
|
|
{ label: 'Version', value: status.version },
|
|
{ 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>
|
|
<p className="text-white text-sm font-medium">{value}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="flex gap-3">
|
|
<motion.button
|
|
type="button"
|
|
className="flex-1 py-2.5 rounded-xl text-sm font-medium flex items-center justify-center gap-2 transition-colors"
|
|
style={INNER_SURFACE}
|
|
whileHover={{ scale: 1.02 }}
|
|
whileTap={{ scale: 0.97 }}
|
|
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>
|
|
</motion.button>
|
|
<motion.button
|
|
type="button"
|
|
className="flex-1 py-2.5 rounded-xl text-sm font-medium flex items-center justify-center gap-2 transition-colors"
|
|
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>
|
|
</motion.button>
|
|
</div>
|
|
</div>
|
|
</GlassCard>
|
|
);
|
|
}
|
|
|
|
// ── iOS Device Connection ────────────────────────────────────────────────────
|
|
function CompanionSurfacesCard() {
|
|
const [copied, setCopied] = useState(false);
|
|
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);
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 1800);
|
|
};
|
|
|
|
return (
|
|
<GlassCard delay={0.05}>
|
|
<SectionHeader icon={Smartphone} title="Companion Surfaces" accent="#a78bfa" />
|
|
<div className="px-6 pb-6 space-y-3">
|
|
<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 ${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">WebOS Session Token</p>
|
|
<p className="text-xs mt-0.5" style={{ color: 'hsl(var(--muted-fg))' }}>
|
|
{token ? 'Reusable by Oracle and protected WebOS routes.' : 'No authenticated backend session is currently present.'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
{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>
|
|
|
|
<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))' }}>Runtime Access</p>
|
|
<div className="flex items-center justify-between">
|
|
<p className="text-sm font-bold tracking-[0.06em] text-white font-mono">{maskedToken}</p>
|
|
<button
|
|
type="button"
|
|
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>
|
|
<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>
|
|
|
|
<div className="flex gap-3">
|
|
<motion.button
|
|
type="button"
|
|
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={{ background: 'hsl(var(--accent))', color: 'hsl(var(--accent-fg))' }}
|
|
whileHover={{ scale: 1.02 }}
|
|
whileTap={{ scale: 0.97 }}
|
|
>
|
|
<><RefreshCw className="w-4 h-4" /> Refresh WebOS</>
|
|
</motion.button>
|
|
<GhostButton onClick={() => handleCopy(API_URL)}>Copy API URL</GhostButton>
|
|
</div>
|
|
</div>
|
|
</GlassCard>
|
|
);
|
|
}
|
|
|
|
// ── Profile ──────────────────────────────────────────────────────────────────
|
|
function ProfileSettings() {
|
|
const { user, login } = useStore();
|
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
|
const [avatarError, setAvatarError] = useState<string | null>(null);
|
|
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false);
|
|
const displayName = user?.fullName?.trim() || user?.name?.trim() || user?.email?.trim() || user?.id || 'Authenticated User';
|
|
const firstName = resolveVelocityFirstName({
|
|
user_id: user?.id ?? '',
|
|
full_name: user?.fullName ?? user?.name ?? null,
|
|
email: user?.email ?? null,
|
|
});
|
|
const initials = (user?.fullName || user?.name || user?.email || 'AU')
|
|
.split(/[\s@._-]+/)
|
|
.filter(Boolean)
|
|
.slice(0, 2)
|
|
.map((n) => n[0]?.toUpperCase() ?? '')
|
|
.join('') || 'AU';
|
|
const roleLabel = normalizeVelocityRole(user?.role)
|
|
.toLowerCase()
|
|
.split('_')
|
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
.join(' ') || 'Authenticated User';
|
|
|
|
const handleAvatarUpload = async (event: ChangeEvent<HTMLInputElement>) => {
|
|
const file = event.target.files?.[0];
|
|
if (!file || !user) {
|
|
return;
|
|
}
|
|
|
|
setAvatarError(null);
|
|
setIsUploadingAvatar(true);
|
|
try {
|
|
const { avatar_url } = await uploadVelocityAvatar(file);
|
|
login({
|
|
...user,
|
|
avatar: avatar_url,
|
|
});
|
|
} catch (error) {
|
|
setAvatarError(error instanceof Error ? error.message : 'Failed to upload profile picture.');
|
|
} finally {
|
|
setIsUploadingAvatar(false);
|
|
if (event.target) {
|
|
event.target.value = '';
|
|
}
|
|
}
|
|
};
|
|
|
|
return (
|
|
<GlassCard delay={0.1}>
|
|
<SectionHeader icon={User} title="Profile" />
|
|
<div className="px-6 pb-6">
|
|
<div className="flex items-center gap-4 mb-5 p-4 rounded-xl" style={INNER_SURFACE}>
|
|
<div className="relative flex-shrink-0">
|
|
{user?.avatar ? (
|
|
<img
|
|
src={user.avatar}
|
|
alt={displayName}
|
|
className="w-14 h-14 rounded-full object-cover border border-white/10 shadow-[0_0_20px_rgba(59,130,246,0.2)]"
|
|
/>
|
|
) : (
|
|
<div
|
|
className="w-14 h-14 rounded-full flex items-center justify-center text-white text-lg font-bold"
|
|
style={{ background: 'hsl(var(--accent))', boxShadow: '0 0 20px hsl(var(--accent) / 0.3)' }}
|
|
>
|
|
{initials}
|
|
</div>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={() => fileInputRef.current?.click()}
|
|
className="absolute -right-1 -top-1 w-6 h-6 rounded-full flex items-center justify-center border border-white/10 bg-zinc-900/90 text-zinc-200 hover:text-white hover:bg-zinc-800 transition-colors"
|
|
title="Update profile picture"
|
|
>
|
|
<Pencil className="w-3 h-3" />
|
|
</button>
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-white font-semibold truncate">{displayName}</p>
|
|
<p className="text-xs mt-0.5" style={{ color: 'hsl(var(--muted-fg))' }}>
|
|
{roleLabel}
|
|
</p>
|
|
{isUploadingAvatar && (
|
|
<p className="mt-3 text-xs text-zinc-400">Uploading profile picture...</p>
|
|
)}
|
|
</div>
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept="image/png,image/jpeg,image/webp,image/jpg"
|
|
className="hidden"
|
|
onChange={handleAvatarUpload}
|
|
/>
|
|
</div>
|
|
{avatarError && (
|
|
<p className="mb-4 text-xs text-red-400">{avatarError}</p>
|
|
)}
|
|
<div className="space-y-0 -mx-6">
|
|
<SettingsRow label="Authenticated Name" description="Resolved from the active Velocity session">
|
|
<span className="text-sm text-white">{firstName || 'Unavailable'}</span>
|
|
</SettingsRow>
|
|
<SettingsRow label="User ID" description="Backend principal identifier">
|
|
<span className="text-sm font-mono text-white">{user?.id ?? 'Unavailable'}</span>
|
|
</SettingsRow>
|
|
<SettingsRow label="Role" description="Normalized access role from JWT claims">
|
|
<span className="text-sm text-white">{roleLabel}</span>
|
|
</SettingsRow>
|
|
</div>
|
|
</div>
|
|
</GlassCard>
|
|
);
|
|
}
|
|
|
|
// ── Notifications ────────────────────────────────────────────────────────────
|
|
function NotificationSettings() {
|
|
const [s, setS] = useState({ newLeads: true, sentimentAlerts: true, viewings: true, systemUpdates: false, emailDigest: true });
|
|
const rows: { key: keyof typeof s; label: string; desc: string }[] = [
|
|
{ key: 'newLeads', label: 'New Lead Alerts', desc: 'Get notified when a new lead is captured' },
|
|
{ key: 'sentimentAlerts', label: 'Sentiment Alerts', desc: 'Alert when visitor sentiment drops' },
|
|
{ key: 'viewings', label: 'Viewing Reminders', desc: 'Reminders for scheduled viewings' },
|
|
{ key: 'systemUpdates', label: 'System Updates', desc: 'Notifications about system maintenance' },
|
|
{ key: 'emailDigest', label: 'Daily Email Digest', desc: 'Summary of daily activity' },
|
|
];
|
|
|
|
return (
|
|
<GlassCard delay={0.15}>
|
|
<SectionHeader icon={Bell} title="Notifications" />
|
|
<div className="-mx-0">
|
|
{rows.map(({ key, label, desc }) => (
|
|
<SettingsRow key={key} label={label} description={desc}>
|
|
<Toggle enabled={s[key]} onChange={(v) => setS({ ...s, [key]: v })} />
|
|
</SettingsRow>
|
|
))}
|
|
</div>
|
|
</GlassCard>
|
|
);
|
|
}
|
|
|
|
// ── Security ─────────────────────────────────────────────────────────────────
|
|
function SecuritySettings() {
|
|
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="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="Password Management" description="Handled by the backend identity service">
|
|
<span className="text-sm text-zinc-400">Managed outside WebOS</span>
|
|
</SettingsRow>
|
|
<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
|
|
value={timeout}
|
|
onChange={setTimeout_}
|
|
options={[
|
|
{ value: '15', label: '15 minutes' },
|
|
{ value: '30', label: '30 minutes' },
|
|
{ value: '60', label: '1 hour' },
|
|
{ value: 'never', label: 'Never' },
|
|
]}
|
|
/>
|
|
</SettingsRow>
|
|
</div>
|
|
</GlassCard>
|
|
);
|
|
}
|
|
|
|
// ── Display ──────────────────────────────────────────────────────────────────
|
|
function DisplaySettings() {
|
|
const [reducedMotion, setReducedMotion] = useState(false);
|
|
const [compactMode, setCompactMode] = useState(false);
|
|
const [language, setLanguage] = useState('en');
|
|
const [timezone, setTimezone] = useState('dxb');
|
|
const { currency, setCurrency } = useCurrency();
|
|
|
|
return (
|
|
<GlassCard delay={0.25}>
|
|
<SectionHeader icon={Monitor} title="Display" />
|
|
<div>
|
|
<SettingsRow label="Reduced Motion" description="Minimize animations">
|
|
<Toggle enabled={reducedMotion} onChange={setReducedMotion} />
|
|
</SettingsRow>
|
|
<SettingsRow label="Compact Mode" description="Show more content per screen">
|
|
<Toggle enabled={compactMode} onChange={setCompactMode} />
|
|
</SettingsRow>
|
|
<SettingsRow label="Language" description="Interface language">
|
|
<DarkSelect
|
|
value={language}
|
|
onChange={setLanguage}
|
|
options={[
|
|
{ value: 'en', label: 'English' },
|
|
{ value: 'ar', label: 'العربية' },
|
|
]}
|
|
/>
|
|
</SettingsRow>
|
|
<SettingsRow label="Timezone" description="Local time display">
|
|
<DarkSelect
|
|
value={timezone}
|
|
onChange={setTimezone}
|
|
options={[
|
|
{ value: 'dxb', label: 'Dubai (GMT+4)' },
|
|
{ value: 'ruh', label: 'Riyadh (GMT+3)' },
|
|
{ value: 'lon', label: 'London (GMT+0)' },
|
|
]}
|
|
/>
|
|
</SettingsRow>
|
|
|
|
{/* ── Currency ── */}
|
|
<SettingsRow
|
|
label="Currency"
|
|
description="Default currency shown across the entire app"
|
|
>
|
|
<DarkSelect
|
|
value={currency}
|
|
onChange={(v) => setCurrency(v as CurrencyCode)}
|
|
options={CURRENCY_OPTIONS.map((o) => ({
|
|
value: o.code,
|
|
label: `${o.flag} ${o.symbol} — ${o.label}`,
|
|
}))}
|
|
/>
|
|
</SettingsRow>
|
|
</div>
|
|
</GlassCard>
|
|
);
|
|
}
|
|
|
|
// ── Data & Privacy ───────────────────────────────────────────────────────────
|
|
function CommunicationsSettings() {
|
|
const [settings, setSettings] = useState<CommsSettings | null>(null);
|
|
const [draft, setDraft] = useState<Partial<CommsSettings>>({});
|
|
const [statusText, setStatusText] = useState('Loading provider settings...');
|
|
const [saving, setSaving] = useState(false);
|
|
const current: CommsSettings = {
|
|
provider: 'mock',
|
|
providerBaseUrl: '',
|
|
providerApiKey: '',
|
|
instanceId: '',
|
|
phoneNumberId: '',
|
|
webhookCallbackUrl: '/api/comms/webhooks/{provider}',
|
|
webhookSecretSet: false,
|
|
autoLinkByPhone: true,
|
|
createCrmInteractionOnInbound: true,
|
|
defaultCountryCode: '91',
|
|
transcriptionProvider: 'none',
|
|
...(settings ?? {}),
|
|
...draft,
|
|
};
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
void fetchCommsSettings()
|
|
.then((value) => {
|
|
if (cancelled) return;
|
|
setSettings(value);
|
|
setStatusText('Settings loaded from backend.');
|
|
})
|
|
.catch((error) => {
|
|
if (cancelled) return;
|
|
setStatusText(error instanceof Error ? error.message : 'Unable to load comms settings.');
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, []);
|
|
|
|
const update = <K extends keyof CommsSettings>(key: K, value: CommsSettings[K]) => {
|
|
setDraft((prev) => ({ ...prev, [key]: value }));
|
|
};
|
|
|
|
const save = async () => {
|
|
setSaving(true);
|
|
try {
|
|
await updateCommsSettings(draft);
|
|
const latest = await fetchCommsSettings();
|
|
setSettings(latest);
|
|
setDraft({});
|
|
setStatusText('Communications settings saved.');
|
|
} catch (error) {
|
|
setStatusText(error instanceof Error ? error.message : 'Failed to save communications settings.');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const test = async () => {
|
|
try {
|
|
const result = await testCommsProviderConnection();
|
|
setStatusText(result.message || (result.success ? 'Provider connection succeeded.' : 'Provider connection failed.'));
|
|
} catch (error) {
|
|
setStatusText(error instanceof Error ? error.message : 'Provider test failed.');
|
|
}
|
|
};
|
|
|
|
const fieldClass = "w-64 rounded-xl px-3 py-2 text-sm text-white placeholder-zinc-500 outline-none";
|
|
|
|
return (
|
|
<GlassCard delay={0.3}>
|
|
<SectionHeader icon={MessageCircle} title="Communications" accent="#22d3ee" />
|
|
<div>
|
|
<SettingsRow label="Provider" description="Mock is local preview. WAHA and Evolution require a running provider service.">
|
|
<DarkSelect
|
|
value={current.provider}
|
|
onChange={(v) => update('provider', v as CommsProvider)}
|
|
options={[
|
|
{ value: 'mock', label: 'Mock' },
|
|
{ value: 'waha', label: 'WAHA' },
|
|
{ value: 'evolution', label: 'Evolution API' },
|
|
{ value: 'meta_cloud', label: 'Meta Cloud API' },
|
|
]}
|
|
/>
|
|
</SettingsRow>
|
|
<SettingsRow label="Provider Base URL" description="Internal or public base URL for WAHA/Evolution.">
|
|
<input
|
|
className={fieldClass}
|
|
style={INNER_SURFACE}
|
|
value={current.providerBaseUrl ?? ''}
|
|
onChange={(event) => update('providerBaseUrl', event.target.value)}
|
|
placeholder="http://127.0.0.1:3000"
|
|
/>
|
|
</SettingsRow>
|
|
<SettingsRow label="API Key" description="Stored in backend comms settings. Masked when read back.">
|
|
<input
|
|
className={fieldClass}
|
|
style={INNER_SURFACE}
|
|
type="password"
|
|
value={current.providerApiKey ?? ''}
|
|
onChange={(event) => update('providerApiKey', event.target.value)}
|
|
placeholder="Provider API key"
|
|
/>
|
|
</SettingsRow>
|
|
<SettingsRow label="Instance / Session" description="WAHA session name or Evolution instance name.">
|
|
<input
|
|
className={fieldClass}
|
|
style={INNER_SURFACE}
|
|
value={current.instanceId ?? ''}
|
|
onChange={(event) => update('instanceId', event.target.value)}
|
|
placeholder="default"
|
|
/>
|
|
</SettingsRow>
|
|
<SettingsRow label="Webhook URL" description="Point provider inbound webhooks here.">
|
|
<span className="text-xs font-mono text-zinc-300">{current.webhookCallbackUrl || '/api/comms/webhooks/{provider}'}</span>
|
|
</SettingsRow>
|
|
<SettingsRow label="Auto-link by Phone" description="Match inbound numbers to crm_people.primary_phone.">
|
|
<Toggle enabled={Boolean(current.autoLinkByPhone)} onChange={(v) => update('autoLinkByPhone', v)} />
|
|
</SettingsRow>
|
|
<SettingsRow label="Create CRM Interaction" description="Mirror inbound messages into canonical intelligence tables.">
|
|
<Toggle enabled={Boolean(current.createCrmInteractionOnInbound)} onChange={(v) => update('createCrmInteractionOnInbound', v)} />
|
|
</SettingsRow>
|
|
<SettingsRow label="Transcription Provider" description="Recording intake is stored now; transcription worker can be added later.">
|
|
<DarkSelect
|
|
value={current.transcriptionProvider ?? 'none'}
|
|
onChange={(v) => update('transcriptionProvider', v as CommsSettings['transcriptionProvider'])}
|
|
options={[
|
|
{ value: 'none', label: 'None' },
|
|
{ value: 'local', label: 'Local Whisper' },
|
|
{ value: 'openai', label: 'OpenAI' },
|
|
]}
|
|
/>
|
|
</SettingsRow>
|
|
<div className="px-6 py-4 flex items-center justify-between gap-3">
|
|
<p className="text-xs text-zinc-400">{statusText}</p>
|
|
<div className="flex items-center gap-2">
|
|
<GhostButton onClick={test}>Test</GhostButton>
|
|
<motion.button
|
|
type="button"
|
|
onClick={save}
|
|
disabled={saving || Object.keys(draft).length === 0}
|
|
className="px-4 py-2 rounded-xl text-sm font-semibold disabled:opacity-50 disabled:cursor-not-allowed"
|
|
style={{ background: 'hsl(var(--accent))', color: 'hsl(var(--accent-fg))' }}
|
|
whileHover={{ scale: saving ? 1 : 1.02 }}
|
|
whileTap={{ scale: saving ? 1 : 0.97 }}
|
|
>
|
|
{saving ? 'Saving...' : 'Save'}
|
|
</motion.button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</GlassCard>
|
|
);
|
|
}
|
|
|
|
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="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
|
|
value={retention}
|
|
onChange={setRetention}
|
|
options={[
|
|
{ value: '30', label: '30 days' },
|
|
{ value: '90', label: '90 days' },
|
|
{ value: '180', label: '6 months' },
|
|
{ value: '365', label: '1 year' },
|
|
]}
|
|
/>
|
|
</SettingsRow>
|
|
<SettingsRow label="Export Data" description="Download all your data">
|
|
<GhostButton onClick={exportSnapshot}>Export</GhostButton>
|
|
</SettingsRow>
|
|
<SettingsRow label="Clear Cache" description="Remove temporary files" danger>
|
|
<GhostButton danger onClick={clearUiCache}>Clear</GhostButton>
|
|
</SettingsRow>
|
|
</div>
|
|
</GlassCard>
|
|
);
|
|
}
|
|
|
|
// ── About ────────────────────────────────────────────────────────────────────
|
|
function AboutSection() {
|
|
const token = getVelocityToken();
|
|
return (
|
|
<GlassCard delay={0.35}>
|
|
<SectionHeader icon={Wifi} title="About" />
|
|
<div className="px-6 pb-6 text-center">
|
|
<div
|
|
className="w-16 h-16 rounded-2xl flex items-center justify-center mx-auto mb-4 text-white text-2xl font-bold"
|
|
style={{ background: 'hsl(var(--accent))', boxShadow: '0 0 28px hsl(var(--accent) / 0.35)' }}
|
|
>
|
|
V
|
|
</div>
|
|
<h4 className="text-white font-semibold text-lg mb-1">Velocity WebOS</h4>
|
|
<p className="text-sm mb-4" style={{ color: 'hsl(var(--muted-fg))' }}>Real Estate Operating System</p>
|
|
<div className="flex items-center justify-center gap-2 text-xs mb-6" style={{ color: 'hsl(var(--subtle-fg))' }}>
|
|
<span>Version 2.2.0 CRM</span>
|
|
<span>•</span>
|
|
<span>{token ? 'Authenticated session active' : 'No active session'}</span>
|
|
</div>
|
|
<div className="text-xs text-zinc-500 mb-4">
|
|
Backend origin: <span className="font-mono text-zinc-300">{API_URL}</span>
|
|
</div>
|
|
</div>
|
|
</GlassCard>
|
|
);
|
|
}
|
|
|
|
// ── Main ─────────────────────────────────────────────────────────────────────
|
|
export function Settings() {
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Row 1: System + iOS */}
|
|
<div className="grid grid-cols-2 gap-4 relative z-40">
|
|
<SystemStatusCard />
|
|
<CompanionSurfacesCard />
|
|
</div>
|
|
|
|
{/* Row 2: Profile + Notifications */}
|
|
<div className="grid grid-cols-2 gap-4 relative z-30">
|
|
<ProfileSettings />
|
|
<NotificationSettings />
|
|
</div>
|
|
|
|
{/* Row 3: Security + Display */}
|
|
<div className="grid grid-cols-2 gap-4 relative z-20">
|
|
<SecuritySettings />
|
|
<DisplaySettings />
|
|
</div>
|
|
|
|
{/* Row 4: Communications + Data */}
|
|
<div className="grid grid-cols-2 gap-4 relative z-10">
|
|
<CommunicationsSettings />
|
|
<DataSettings />
|
|
</div>
|
|
|
|
{/* Row 5: About */}
|
|
<div className="grid grid-cols-1 gap-4 relative z-0">
|
|
<AboutSection />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|