Built the Oracle Tab (#14)

This commit is contained in:
2026-04-11 19:35:45 +05:30
committed by Sagnik
parent 8e1ffe0e43
commit fb656d1443
54 changed files with 10651 additions and 818 deletions

View File

@@ -3,7 +3,7 @@ import { motion, AnimatePresence } from 'framer-motion';
import {
Megaphone, Clapperboard, BarChart3, Globe, Settings2,
Zap, TrendingUp, Eye, MousePointerClick, DollarSign,
Upload, Play, Image, Film, RefreshCw, ArrowRight, Plus,
Upload, Play, Image, Film, RefreshCw, ArrowRight, Plus, X,
AlertTriangle, ArrowRightLeft, PlusCircle, SlidersHorizontal,
Activity, Check, Link2,
type LucideIcon,
@@ -13,7 +13,10 @@ import {
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
} from 'recharts';
import { useMarketingStore } from '@/store/useMarketingStore';
import { useCurrency } from '@/store/useCurrencyStore';
import type { Campaign, MarketingAsset, LiveOptimizationEvent, LiveEventType } from '@/types';
import { GroundTruthPicker } from './GroundTruthPicker';
import type { GroundTruthSelection } from './GroundTruthPicker';
// ── Design tokens ─────────────────────────────────────────────────────────────
const GLASS = {
@@ -243,22 +246,151 @@ function AssetCard({ asset }: { asset: MarketingAsset }) {
);
}
// ── Reference Slot ───────────────────────────────────────────────────────────
interface RefSelection { name: string; preview: string; }
function ReferenceSlot({ value, onSelect, onClear, onRemove }: {
value: RefSelection | null;
onSelect: (sel: RefSelection) => void;
onClear: () => void;
onRemove?: () => void;
}) {
const inputRef = useRef<HTMLInputElement>(null);
function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
onSelect({ name: file.name, preview: URL.createObjectURL(file) });
e.target.value = '';
}
// Show X when: slot has content (clear it), or slot is removable (remove it)
const showX = !!value || !!onRemove;
const handleX = (e: React.MouseEvent) => {
e.stopPropagation();
if (onRemove) onRemove();
else onClear();
};
return (
<div className="relative w-[60px] h-[60px]">
<motion.button
className="w-[60px] h-[60px] rounded-2xl flex flex-col items-center justify-center gap-1 overflow-hidden transition-colors"
style={{
background: value?.preview ? 'transparent' : 'rgba(255,255,255,0.03)',
border: '1px solid rgba(255,255,255,0.08)',
}}
onClick={() => inputRef.current?.click()}
whileHover={{ scale: 1.04 }}
whileTap={{ scale: 0.96 }}
title="Add reference image"
>
{value?.preview ? (
<img src={value.preview} alt={value.name} className="w-full h-full object-cover" />
) : value?.name ? (
<>
<Check className="w-4 h-4 text-blue-400" />
<span className="text-[8px] text-blue-400 font-medium text-center px-1 leading-tight line-clamp-2">{value.name}</span>
</>
) : (
<span className="text-xs font-medium" style={{ color: 'rgba(255,255,255,0.2)' }}>+</span>
)}
</motion.button>
<AnimatePresence>
{showX && (
<motion.button
className="absolute -top-2 -right-3 w-4 h-4 rounded-full flex items-center justify-center z-10"
style={{ background: 'rgba(20,20,30,0.95)', border: '1px solid rgba(255,255,255,0.22)' }}
onClick={handleX}
initial={{ opacity: 0, scale: 0.6 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.6 }}
transition={{ duration: 0.12 }}
whileHover={{ scale: 1.2, backgroundColor: 'rgba(239,68,68,0.8)' }}
title={onRemove ? 'Remove slot' : 'Clear selection'}
>
<X className="w-2 h-2 text-white" />
</motion.button>
)}
</AnimatePresence>
<input ref={inputRef} type="file" accept="image/*,video/*" className="hidden" onChange={handleFile} />
</div>
);
}
function WorkflowInput() {
const [mode, setMode] = useState<'image' | 'video'>('image');
const [prompt, setPrompt] = useState('');
const [keywords, setKeywords] = useState('');
const [textCopy, setTextCopy] = useState('');
const [groundTruth, setGroundTruth] = useState<GroundTruthSelection | null>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const [refs, setRefs] = useState<(RefSelection | null)[]>([null, null]);
const anchorRef = useRef<HTMLButtonElement>(null);
return (
<Widget delay={0.04} colSpan={1} className="!p-6" style={{ background: '#111216', borderRadius: '28px' }}>
<Widget delay={0.04} colSpan={1} className="!p-6 !overflow-visible" style={{ background: '#111216', borderRadius: '28px' }}>
<div className="flex flex-col gap-6">
{/* Top Section: Ground Truth & References */}
<div className="flex items-end gap-6">
{/* Ground Truth slot */}
<div className="flex flex-col gap-2">
<div className="flex items-center gap-3">
<button title="Select Image from Gallery or Dream Weaver / Click Image from iPad" className="w-[60px] h-[60px] rounded-2xl flex items-center justify-center transition-colors hover:bg-white/5" style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.08)' }}>
<span className="text-xs font-medium" style={{ color: 'rgba(255,255,255,0.2)' }}>Start</span>
</button>
<div className="relative">
<motion.button
ref={anchorRef}
onClick={() => setPickerOpen(v => !v)}
className="w-[60px] h-[60px] rounded-2xl flex flex-col items-center justify-center gap-1 overflow-hidden transition-colors"
style={{
background: groundTruth?.preview ? 'transparent' : pickerOpen ? 'rgba(59,130,246,0.12)' : 'rgba(255,255,255,0.03)',
border: pickerOpen ? '1px solid rgba(59,130,246,0.4)' : '1px solid rgba(255,255,255,0.08)',
}}
whileHover={{ scale: 1.04 }}
whileTap={{ scale: 0.96 }}
title="Select Ground Truth image"
>
{groundTruth?.preview ? (
<img src={groundTruth.preview} alt="ground truth" className="w-full h-full object-cover" />
) : groundTruth?.name ? (
<>
<Check className="w-4 h-4 text-green-400" />
<span className="text-[8px] text-green-400 font-medium text-center px-1 leading-tight line-clamp-2">{groundTruth.name}</span>
</>
) : (
<span className="text-xs font-medium" style={{ color: 'rgba(255,255,255,0.2)' }}>{pickerOpen ? '✕' : '+'}</span>
)}
</motion.button>
{/* Clear selection button */}
<AnimatePresence>
{groundTruth && (
<motion.button
className="absolute -top-2 right-2 w-4 h-4 rounded-full flex items-center justify-center z-10"
style={{ background: 'rgba(20,20,30,0.95)', border: '1px solid rgba(255,255,255,0.22)' }}
onClick={(e) => { e.stopPropagation(); setGroundTruth(null); setPickerOpen(false); }}
initial={{ opacity: 0, scale: 0.6 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.6 }}
transition={{ duration: 0.12 }}
whileHover={{ scale: 1.2, backgroundColor: 'rgba(239,68,68,0.8)' }}
title="Remove selection"
>
<X className="w-2 h-2 text-white" />
</motion.button>
)}
</AnimatePresence>
<AnimatePresence>
{pickerOpen && (
<GroundTruthPicker
anchorRef={anchorRef}
onSelect={sel => { setGroundTruth(sel); setPickerOpen(false); }}
onClose={() => setPickerOpen(false)}
/>
)}
</AnimatePresence>
</div>
<span className="text-xs font-semibold text-white tracking-wide">Ground Truth</span>
</div>
@@ -269,11 +401,25 @@ function WorkflowInput() {
<div className="flex flex-col gap-2">
<div className="flex items-center gap-3">
<button title="Add reference image" className="w-[60px] h-[60px] rounded-2xl flex items-center justify-center transition-colors hover:bg-white/5" style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.08)' }} />
<button title="Add reference image" className="w-[60px] h-[60px] rounded-2xl flex items-center justify-center transition-colors hover:bg-white/5" style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.08)' }} />
<button title="Add more references" className="w-[60px] h-[60px] rounded-2xl flex items-center justify-center transition-colors hover:bg-white/5" style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.08)' }}>
{refs.map((ref, i) => (
<ReferenceSlot
key={i}
value={ref}
onSelect={(sel) => setRefs(prev => prev.map((r, idx) => idx === i ? sel : r))}
onClear={() => setRefs(prev => prev.map((r, idx) => idx === i ? null : r))}
onRemove={i >= 2 ? () => setRefs(prev => prev.filter((_, idx) => idx !== i)) : undefined}
/>
))}
<motion.button
title="Add more references"
className="w-[60px] h-[60px] rounded-2xl flex items-center justify-center transition-colors"
style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.08)' }}
onClick={() => setRefs(prev => prev.length < 6 ? [...prev, null] : prev)}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<Plus className="w-5 h-5 text-white/40" />
</button>
</motion.button>
</div>
<span className="text-xs font-semibold text-white tracking-wide">References</span>
</div>
@@ -389,6 +535,7 @@ function TheStudio() {
function CampaignCommand() {
const { campaigns, adInsights } = useMarketingStore();
const { formatAmount, rate } = useCurrency();
const totalSpend = campaigns.reduce((s, c) => s + c.lifetimeSpend / 100, 0);
const totalImpr = campaigns.reduce((s, c) => s + c.impressions, 0);
@@ -398,7 +545,7 @@ function CampaignCommand() {
// Build spend-over-time from insights (last 14 days)
const spendData = adInsights.slice(0, 14).map((d) => ({
date: d.date.slice(5), // MM-DD
spend: d.spend,
spend: Math.round(d.spend * rate),
impressions: Math.round(d.impressions / 1000),
})).reverse();
@@ -407,9 +554,9 @@ function CampaignCommand() {
{/* KPI row */}
<div className="grid grid-cols-2 xl:grid-cols-4 gap-4">
<StatCard icon={Megaphone} label="Active Campaigns" value={String(campaigns.filter(c => c.status === 'ACTIVE').length)} sub={`${campaigns.length} total campaigns`} glowColor="rgba(59,130,246,0.2)" delay={0} />
<StatCard icon={DollarSign} label="Total Spend" value={`AED ${(totalSpend / 1000).toFixed(1)}K`} sub="Lifetime across all campaigns" glowColor="rgba(34,211,238,0.2)" delay={0.06} />
<StatCard icon={DollarSign} label="Total Spend" value={formatAmount(totalSpend, { compact: true })} sub="Lifetime across all campaigns" glowColor="rgba(34,211,238,0.2)" delay={0.06} />
<StatCard icon={Eye} label="Impressions" value={`${(totalImpr / 1000).toFixed(0)}K`} sub="Total ad impressions" glowColor="rgba(251,191,36,0.2)" delay={0.12} />
<StatCard icon={MousePointerClick} label="Avg CTR" value={`${avgCtr.toFixed(2)}%`} sub={`Avg CPA: AED ${avgCpa.toFixed(0)}`} glowColor="rgba(167,139,250,0.2)" delay={0.18} />
<StatCard icon={MousePointerClick} label="Avg CTR" value={`${avgCtr.toFixed(2)}%`} sub={`Avg CPA: ${formatAmount(avgCpa)}`} glowColor="rgba(167,139,250,0.2)" delay={0.18} />
</div>
{/* Spend chart + campaign list */}
@@ -463,7 +610,7 @@ function CampaignCommand() {
<div className="flex-1 min-w-0 mr-3">
<p className="text-sm font-medium text-white truncate">{c.name}</p>
<p className="text-xs mt-0.5" style={{ color: 'rgba(148,163,184,0.5)' }}>
AED {(c.lifetimeSpend / 100).toLocaleString()} · {c.impressions.toLocaleString()} impr.
{formatAmount(c.lifetimeSpend / 100)} · {c.impressions.toLocaleString()} impr.
</p>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
@@ -487,24 +634,25 @@ function CampaignCommand() {
function IntelligenceROI() {
const { campaigns, adInsights } = useMarketingStore();
const { formatAmount, symbol, rate } = useCurrency();
const cpaData = adInsights.slice(0, 7).map((d) => ({
date: d.date.slice(5),
cpa: d.cpa,
cpa: Math.round(d.cpa * rate),
roi: d.roi,
})).reverse();
const adSetPerf = [
{ name: '3BHK Dubai', ctr: 2.1, cpa: 210, spend: 3400 },
{ name: 'PH Retarget', ctr: 3.2, cpa: 2100, spend: 5800 },
{ name: '1BHK Lookalike', ctr: 1.8, cpa: 270, spend: 980 },
{ name: '3BHK Dubai', ctr: 2.1, cpa: Math.round(210 * rate), spend: Math.round(3400 * rate) },
{ name: 'PH Retarget', ctr: 3.2, cpa: Math.round(2100 * rate), spend: Math.round(5800 * rate) },
{ name: '1BHK Lookalike', ctr: 1.8, cpa: Math.round(270 * rate), spend: Math.round(980 * rate) },
];
return (
<div className="space-y-5">
{/* CPA / ROI KPIs */}
<div className="grid grid-cols-2 xl:grid-cols-4 gap-4">
<StatCard icon={DollarSign} label="Avg CPA" value={`AED ${(campaigns.reduce((s,c)=>s+c.cpa,0)/campaigns.length).toFixed(0)}`} sub="Cost per acquisition" glowColor="rgba(34,211,238,0.2)" delay={0} />
<StatCard icon={DollarSign} label="Avg CPA" value={formatAmount(campaigns.reduce((s,c)=>s+c.cpa,0)/campaigns.length)} sub="Cost per acquisition" glowColor="rgba(34,211,238,0.2)" delay={0} />
<StatCard icon={TrendingUp} label="Portfolio ROI" value={`${(campaigns.reduce((s,c)=>s+c.roi,0)/campaigns.length).toFixed(1)}%`} sub="Blended across all ad sets" glowColor="rgba(74,222,128,0.2)" delay={0.06} />
<StatCard icon={Activity} label="Avg CTR" value={`${(campaigns.reduce((s,c)=>s+c.ctr,0)/campaigns.length).toFixed(2)}%`} sub="Click-through rate" glowColor="rgba(167,139,250,0.2)" delay={0.12} />
<StatCard icon={Zap} label="Optimization Runs" value="47" sub="Agent actions today" glowColor="rgba(251,191,36,0.2)" delay={0.18} />
@@ -529,7 +677,7 @@ function IntelligenceROI() {
<XAxis dataKey="date" stroke="rgba(148,163,184,0.3)" fontSize={10} tickLine={false} axisLine={false} />
<YAxis stroke="rgba(148,163,184,0.3)" fontSize={10} tickLine={false} axisLine={false} />
<Tooltip contentStyle={{ backgroundColor: 'rgba(8,10,18,0.92)', border: '1px solid rgba(34,211,238,0.2)', borderRadius: 10 }} labelStyle={{ color: 'rgba(148,163,184,0.8)', fontSize: 11 }} itemStyle={{ color: '#67e8f9', fontSize: 12 }} />
<Area type="monotone" dataKey="cpa" stroke="#22d3ee" strokeWidth={2} fillOpacity={1} fill="url(#gCpa)" name="CPA (AED)" />
<Area type="monotone" dataKey="cpa" stroke="#22d3ee" strokeWidth={2} fillOpacity={1} fill="url(#gCpa)" name={`CPA (${symbol})`} />
</AreaChart>
</ResponsiveContainer>
</div>
@@ -547,7 +695,7 @@ function IntelligenceROI() {
<XAxis dataKey="name" stroke="rgba(148,163,184,0.3)" fontSize={10} tickLine={false} axisLine={false} />
<YAxis stroke="rgba(148,163,184,0.3)" fontSize={10} tickLine={false} axisLine={false} />
<Tooltip contentStyle={{ backgroundColor: 'rgba(8,10,18,0.92)', border: '1px solid rgba(59,130,246,0.2)', borderRadius: 10 }} labelStyle={{ color: 'rgba(148,163,184,0.8)', fontSize: 11 }} itemStyle={{ color: '#93c5fd', fontSize: 12 }} />
<Bar dataKey="spend" fill="#3b82f6" radius={[4, 4, 0, 0]} name="Spend (AED)" opacity={0.8} />
<Bar dataKey="spend" fill="#3b82f6" radius={[4, 4, 0, 0]} name={`Spend (${symbol})`} opacity={0.8} />
</BarChart>
</ResponsiveContainer>
</div>
@@ -572,6 +720,7 @@ const EVENT_ICON: Record<LiveEventType, { icon: LucideIcon; color: string; bg: s
function LiveEventItem({ event }: { event: LiveOptimizationEvent }) {
const cfg = EVENT_ICON[event.type];
const { formatText } = useCurrency();
const Icon = cfg.icon;
return (
<motion.div
@@ -596,10 +745,10 @@ function LiveEventItem({ event }: { event: LiveOptimizationEvent }) {
</span>
)}
{event.value && (
<span className="text-[10px] font-medium ml-auto" style={{ color: cfg.color }}>{event.value}</span>
<span className="text-[10px] font-medium ml-auto" style={{ color: cfg.color }}>{formatText(event.value)}</span>
)}
</div>
<p className="text-xs leading-relaxed" style={{ color: 'rgba(148,163,184,0.75)' }}>{event.message}</p>
<p className="text-xs leading-relaxed" style={{ color: 'rgba(148,163,184,0.75)' }}>{formatText(event.message)}</p>
<p className="text-[10px] mt-1" style={{ color: 'rgba(148,163,184,0.35)' }}>
{event.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
</p>

View File

@@ -0,0 +1,219 @@
import { useState, useEffect, useLayoutEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { Plus, Film, Check, X } from 'lucide-react';
// ─── Recent designs mock data ─────────────────────────────────────────────────
const RECENT_DESIGNS = [
{ id: 'rd1', name: 'Penthouse Sea View', type: 'video' as const, gradient: 'linear-gradient(135deg,#1a2a4a,#0f1928)', accent: '#60a5fa', date: '2h ago' },
{ id: 'rd2', name: 'Arabic 3BHK Poster', type: 'image' as const, gradient: 'linear-gradient(135deg,#2a1a3a,#180f28)', accent: '#a78bfa', date: '5h ago' },
{ id: 'rd3', name: 'Amenity Deck Reel', type: 'video' as const, gradient: 'linear-gradient(135deg,#1a3a2a,#0f2818)', accent: '#4ade80', date: '8h ago' },
{ id: 'rd4', name: 'Penthouse En Poster', type: 'image' as const, gradient: 'linear-gradient(135deg,#3a2a1a,#281808)', accent: '#fbbf24', date: '1d ago' },
{ id: 'rd5', name: 'Dubai Marina Aerial', type: 'video' as const, gradient: 'linear-gradient(135deg,#1a3a3a,#0f2828)', accent: '#22d3ee', date: '2d ago' },
{ id: 'rd6', name: 'Investment Lifestyle', type: 'image' as const, gradient: 'linear-gradient(135deg,#3a1a1a,#280f0f)', accent: '#f87171', date: '3d ago' },
];
// ─── Types ────────────────────────────────────────────────────────────────────
export interface GroundTruthSelection {
name: string;
preview: string;
}
interface GroundTruthPickerProps {
anchorRef: React.RefObject<HTMLButtonElement | null>;
onSelect: (sel: GroundTruthSelection) => void;
onClose: () => void;
}
// ─── Portal popup component ───────────────────────────────────────────────────
function PickerPopup({ anchorRef, onSelect, onClose }: GroundTruthPickerProps) {
const popupRef = useRef<HTMLDivElement>(null);
const galleryRef = useRef<HTMLInputElement>(null);
const cameraRef = useRef<HTMLInputElement>(null);
// ── Synchronously measure anchor and position popup ──────────────────────
const [style, setStyle] = useState<React.CSSProperties>({ visibility: 'hidden' });
useLayoutEffect(() => {
if (!anchorRef.current) return;
const r = anchorRef.current.getBoundingClientRect();
const W = 440;
const vw = window.innerWidth;
// Prefer aligning left edge with button; clamp so popup stays on screen
let left = r.left;
if (left + W > vw - 12) left = Math.max(12, vw - W - 12);
setStyle({
position: 'fixed',
top: r.bottom + 8,
left,
width: W,
visibility: 'visible',
});
}, [anchorRef]);
// ── Dismiss on outside click ──────────────────────────────────────────────
useEffect(() => {
function handleDown(e: MouseEvent) {
const target = e.target as Node;
const inPopup = popupRef.current?.contains(target);
const inAnchor = anchorRef.current?.contains(target);
if (!inPopup && !inAnchor) onClose();
}
document.addEventListener('mousedown', handleDown);
return () => document.removeEventListener('mousedown', handleDown);
}, [anchorRef, onClose]);
// ── File handler ──────────────────────────────────────────────────────────
function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
onSelect({ name: file.name, preview: URL.createObjectURL(file) });
e.target.value = '';
}
return (
<motion.div
ref={popupRef}
className="z-[99999] rounded-2xl p-4"
style={{
...style,
background: 'rgba(12,14,22,0.98)',
border: '1px solid rgba(255,255,255,0.12)',
backdropFilter: 'blur(32px)',
WebkitBackdropFilter: 'blur(32px)',
boxShadow: '0 24px 80px rgba(0,0,0,0.85), 0 0 0 1px rgba(255,255,255,0.05)',
}}
initial={{ opacity: 0, y: -6, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -6, scale: 0.97 }}
transition={{ duration: 0.16, ease: [0.4, 0, 0.2, 1] }}
>
{/* Header */}
<div className="flex items-center justify-between mb-3">
<p className="text-[10px] font-semibold uppercase tracking-widest"
style={{ color: 'rgba(148,163,184,0.5)' }}>
Recent Designs
</p>
<motion.button
onClick={onClose}
className="w-7 h-7 flex items-center justify-center rounded-xl"
style={{
background: 'rgba(255,255,255,0.06)',
border: '1px solid rgba(255,255,255,0.1)',
color: 'rgba(148,163,184,0.7)',
}}
whileHover={{ scale: 1.1, backgroundColor: 'rgba(255,255,255,0.14)' }}
whileTap={{ scale: 0.9 }}
title="Close"
>
<X className="w-3.5 h-3.5" />
</motion.button>
</div>
{/* 3×2 recent designs grid */}
<div className="grid grid-cols-3 gap-2 mb-2">
{RECENT_DESIGNS.map((d, i) => (
<motion.button
key={d.id}
className="relative rounded-xl overflow-hidden flex flex-col items-start p-2 group text-left"
style={{
background: d.gradient,
border: '1px solid rgba(255,255,255,0.07)',
aspectRatio: '1',
}}
onClick={() => onSelect({ name: d.name, preview: '' })}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.15, delay: i * 0.04 }}
whileHover={{ scale: 1.03 }}
whileTap={{ scale: 0.97 }}
>
<span
className="text-[9px] font-semibold uppercase px-1.5 py-0.5 rounded-full mb-auto z-10"
style={{ background: `${d.accent}22`, color: d.accent }}
>
{d.type === 'video' ? '▶ Video' : '■ Image'}
</span>
{/* Glow */}
<div className="absolute bottom-0 right-0 w-12 h-12 pointer-events-none"
style={{ background: `radial-gradient(circle,${d.accent}44 0%,transparent 70%)`, filter: 'blur(8px)' }} />
<div className="w-full mt-1 z-10">
<p className="text-[10px] font-medium text-white leading-tight line-clamp-1">{d.name}</p>
<p className="text-[9px] mt-0.5" style={{ color: 'rgba(148,163,184,0.45)' }}>{d.date}</p>
</div>
{/* Hover overlay */}
<div className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center rounded-xl"
style={{ background: 'rgba(255,255,255,0.08)' }}>
<Check className="w-5 h-5 text-white" />
</div>
</motion.button>
))}
</div>
{/* Bottom: gallery + camera */}
<div className="grid grid-cols-2 gap-2 pt-3"
style={{ borderTop: '1px solid rgba(255,255,255,0.05)' }}>
{/* Gallery */}
<div className="relative">
<motion.button
className="w-full flex items-center justify-center gap-2 rounded-xl py-3 text-xs font-medium"
style={{
background: 'rgba(255,255,255,0.04)',
border: '1px solid rgba(255,255,255,0.08)',
color: 'rgba(148,163,184,0.75)',
}}
onClick={() => galleryRef.current?.click()}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.97 }}
>
<Plus className="w-3.5 h-3.5" />
Add from Gallery
</motion.button>
<input ref={galleryRef} type="file" accept="image/*,video/*" className="hidden" onChange={handleFile} />
</div>
{/* Camera */}
<div className="relative">
<motion.button
className="w-full flex items-center justify-center gap-2 rounded-xl py-3 text-xs font-medium"
style={{
background: 'rgba(255,255,255,0.04)',
border: '1px solid rgba(255,255,255,0.08)',
color: 'rgba(148,163,184,0.75)',
}}
onClick={() => cameraRef.current?.click()}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.97 }}
>
<Film className="w-3.5 h-3.5" />
Take Photo
</motion.button>
<input ref={cameraRef} type="file" accept="image/*" capture="environment" className="hidden" onChange={handleFile} />
</div>
</div>
</motion.div>
);
}
// ─── Public export — renders via portal ──────────────────────────────────────
export function GroundTruthPicker(props: GroundTruthPickerProps) {
return createPortal(
<AnimatePresence>
<PickerPopup {...props} />
</AnimatePresence>,
document.body,
);
}

View File

@@ -20,7 +20,7 @@ import { Canvas } from '@react-three/fiber';
import { Bounds, Html, OrbitControls, useGLTF } from '@react-three/drei';
import * as THREE from 'three';
import { useStore } from '@/store/useStore';
import { formatCurrency } from '@/lib/utils';
import { useCurrency } from '@/store/useCurrencyStore';
import type { Unit } from '@/types';
// Penthouse preview images — one per unit (u1u8) for card thumbnails
@@ -227,6 +227,7 @@ function PropertyDetailModal({
}) {
const details = UNIT_DETAILS[unit.id] ?? DEFAULT_DETAILS;
const preview = UNIT_PREVIEWS[unit.id] ?? UNIT_PREVIEWS['u1'];
const { formatAmount } = useCurrency();
const statusColors: Record<string, string> = {
available: 'text-emerald-300 border-emerald-400/30 bg-emerald-500/10',
@@ -282,8 +283,8 @@ function PropertyDetailModal({
{/* Overlay price */}
<div className="absolute bottom-4 left-5">
<p className="text-xs text-zinc-400">Starting from</p>
<p className="text-3xl font-bold tracking-tight text-white">{formatCurrency(unit.price)}</p>
<p className="text-xs text-zinc-400">{formatCurrency(pricePerSqm)} / m²</p>
<p className="text-3xl font-bold tracking-tight text-white">{formatAmount(unit.price)}</p>
<p className="text-xs text-zinc-400">{formatAmount(pricePerSqm)} / m²</p>
</div>
</div>
@@ -371,8 +372,8 @@ function PropertyDetailModal({
{/* Pricing card */}
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
<p className="mb-3 text-xs uppercase tracking-widest text-zinc-500">Pricing</p>
<p className="text-2xl font-bold text-zinc-100">{formatCurrency(unit.price)}</p>
<p className="mt-0.5 text-xs text-zinc-500">{formatCurrency(pricePerSqm)} per m²</p>
<p className="text-2xl font-bold text-zinc-100">{formatAmount(unit.price)}</p>
<p className="mt-0.5 text-xs text-zinc-500">{formatAmount(pricePerSqm)} per m²</p>
<div className="my-3 border-t border-white/10" />
<div className="space-y-1.5 text-sm">
<div className="flex justify-between">
@@ -441,6 +442,7 @@ function UnitCard({
}) {
const preview = UNIT_PREVIEWS[unit.id] ?? UNIT_PREVIEWS['u1'];
const [hovered, setHovered] = useState(false);
const { formatAmount } = useCurrency();
// Status accent color for glow
const statusGlow =
@@ -524,7 +526,7 @@ function UnitCard({
{/* Price */}
<div className="mb-3">
<p className="stat-label mb-0.5">Starting from</p>
<p className="text-xl font-bold leading-none tracking-tight text-white">{formatCurrency(unit.price)}</p>
<p className="text-xl font-bold leading-none tracking-tight text-white">{formatAmount(unit.price)}</p>
</div>
{/* Divider */}
@@ -731,6 +733,7 @@ function StudioWindow({
}
function RightMapPane({ units }: { units: Unit[] }) {
const { formatAmount } = useCurrency();
return (
<div className="relative h-full min-h-[36rem] overflow-hidden rounded-2xl border border-white/10 bg-zinc-900/70">
<iframe title="Dubai Map" src={MAP_EMBED_URL} className="h-full w-full border-0" loading="lazy" referrerPolicy="no-referrer-when-downgrade" />
@@ -741,7 +744,7 @@ function RightMapPane({ units }: { units: Unit[] }) {
<div className="absolute bottom-3 left-3 right-3 grid grid-cols-2 gap-2 rounded-xl border border-white/15 bg-zinc-900/75 p-2 backdrop-blur-xl">
{units.slice(0, 4).map((unit) => (
<div key={unit.id} className="rounded-lg border border-white/10 bg-white/5 px-2 py-1 text-xs text-zinc-200">
{unit.unitNumber} - {formatCurrency(unit.price)}
{unit.unitNumber} - {formatAmount(unit.price)}
</div>
))}
</div>
@@ -762,6 +765,7 @@ function UnitRow({
onOpen3D: (u: Unit) => void;
onOpenBlueprint: (u: Unit) => void;
}) {
const { formatAmount } = useCurrency();
return (
<motion.div
className="flex items-center gap-4 px-4 py-3 rounded-xl cursor-pointer transition-colors"
@@ -795,7 +799,7 @@ function UnitRow({
<p className="text-xs" style={{ color: 'hsl(var(--muted-fg))' }}>{unit.view}</p>
</div>
<div className="col-span-1">
<p className="text-sm font-bold text-white">{formatCurrency(unit.price)}</p>
<p className="text-sm font-bold text-white">{formatAmount(unit.price)}</p>
</div>
</div>

View File

@@ -17,6 +17,8 @@ import {
type LucideIcon,
} from 'lucide-react';
import { useStore } from '@/store/useStore';
import { useCurrency, CURRENCY_OPTIONS } from '@/store/useCurrencyStore';
import type { CurrencyCode } from '@/store/useCurrencyStore';
// ── Design tokens (matching inventory glassmorphism) ─────────────────────────
const GLASS = {
@@ -44,7 +46,7 @@ function GlassCard({
}) {
return (
<motion.div
className={`rounded-2xl overflow-hidden ${className}`}
className={`relative rounded-2xl ${className}`}
style={GLASS}
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
@@ -508,6 +510,7 @@ function DisplaySettings() {
const [compactMode, setCompactMode] = useState(false);
const [language, setLanguage] = useState('en');
const [timezone, setTimezone] = useState('dxb');
const { currency, setCurrency } = useCurrency();
return (
<GlassCard delay={0.25}>
@@ -540,6 +543,21 @@ function DisplaySettings() {
]}
/>
</SettingsRow>
{/* ── Currency ── */}
<SettingsRow
label="Currency"
description="Default currency shown across the entire app"
>
<DarkSelect
value={currency}
onChange={(v) => setCurrency(v as CurrencyCode)}
options={CURRENCY_OPTIONS.map((o) => ({
value: o.code,
label: `${o.flag} ${o.symbol}${o.label}`,
}))}
/>
</SettingsRow>
</div>
</GlassCard>
);
@@ -615,25 +633,25 @@ export function Settings() {
return (
<div className="space-y-4">
{/* Row 1: System + iOS */}
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-2 gap-4 relative z-40">
<SystemStatusCard />
<IOSConnectionCard />
</div>
{/* Row 2: Profile + Notifications */}
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-2 gap-4 relative z-30">
<ProfileSettings />
<NotificationSettings />
</div>
{/* Row 3: Security + Display */}
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-2 gap-4 relative z-20">
<SecuritySettings />
<DisplaySettings />
</div>
{/* Row 4: Data + About */}
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-2 gap-4 relative z-10">
<DataSettings />
<AboutSection />
</div>