Built the Oracle Tab (#14)
This commit is contained in:
@@ -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>
|
||||
|
||||
219
app/src/components/modules/GroundTruthPicker.tsx
Normal file
219
app/src/components/modules/GroundTruthPicker.tsx
Normal 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,
|
||||
);
|
||||
}
|
||||
@@ -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 (u1–u8) 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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user