Files
Project_Velocity/app/src/components/modules/Inventory.tsx
sayan f78655debc feat: Built the Oracle Tab1 (#14)
#13 Built the complete Oracle Tab with all the functionalities.

Co-authored-by: Sayan Datta <sayan@Sayans-MacBook-Air.local>
Reviewed-on: #14
2026-04-11 19:35:45 +05:30

1081 lines
45 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Suspense, useMemo, useRef, useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import {
ArrowRight,
Bath,
Bed,
CheckCircle2,
Clock,
Compass,
Folder,
Layers,
MapPin,
MapPinned,
Maximize2,
Search,
Tag,
XCircle,
} from 'lucide-react';
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 { useCurrency } from '@/store/useCurrencyStore';
import type { Unit } from '@/types';
// Penthouse preview images — one per unit (u1u8) for card thumbnails
const UNIT_PREVIEWS: Record<string, string> = {
u1: '/penthouse-images/1.jpg',
u2: '/penthouse-images/2.jpg',
u3: '/penthouse-images/3.jpg',
u4: '/penthouse-images/4.jpg',
u5: '/penthouse-images/5.jpg',
u6: '/penthouse-images/6.jpg',
u7: '/penthouse-images/7.jpg',
u8: '/penthouse-images/8.jpg',
};
// Blueprint floor plan images for the Blueprint Studio viewer
const HOUSE_1_BLUEPRINT = new URL(
'../../../assets/House Floor Plans/House 1/f6b441fc43a460a957df992433ee39ca.jpg',
import.meta.url
).href;
const HOUSE_2_BLUEPRINT = new URL(
'../../../assets/House Floor Plans/House 2/ee2057aab582951894fea5b1f56ea27e.jpg',
import.meta.url
).href;
const HOUSE_1_GLB = '/models/house1/house1.glb';
const HOUSE_2_GLB = '/models/house2/house2.glb';
const MAP_EMBED_URL = 'https://www.google.com/maps?q=Dubai+Marina&output=embed';
// Preload GLB files for faster loading
useGLTF.preload(HOUSE_1_GLB);
useGLTF.preload(HOUSE_2_GLB);
function StatusBadge({ status }: { status: Unit['status'] }) {
const config = {
available: { icon: CheckCircle2, label: 'Available', color: 'text-green-300 bg-green-500/20 border-green-500/30' },
reserved: { icon: Clock, label: 'Reserved', color: 'text-amber-300 bg-amber-500/20 border-amber-500/30' },
sold: { icon: XCircle, label: 'Sold', color: 'text-zinc-300 bg-zinc-500/20 border-zinc-500/30' },
hold: { icon: Clock, label: 'On Hold', color: 'text-red-300 bg-red-500/20 border-red-500/30' },
} as const;
const { icon: Icon, label, color } = config[status];
return (
<span className={`inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-[11px] ${color}`}>
<Icon className="h-3 w-3" />
{label}
</span>
);
}
function MacClose({ onClose }: { onClose: () => void }) {
return (
<div className="flex items-center gap-2">
<button type="button" onClick={onClose} className="h-3.5 w-3.5 rounded-full bg-[#ff5f57] hover:brightness-110" />
<span className="h-3.5 w-3.5 rounded-full bg-[#febc2e]" />
<span className="h-3.5 w-3.5 rounded-full bg-[#28c840]" />
</div>
);
}
function GlbModel({ url }: { url: string }) {
const { scene } = useGLTF(url);
const model = useMemo(() => {
const cloned = scene.clone();
// Auto-fit: normalize to a standard size regardless of original scale
const box = new THREE.Box3().setFromObject(cloned);
const size = new THREE.Vector3();
box.getSize(size);
const maxDim = Math.max(size.x, size.y, size.z) || 1;
const scale = 5 / maxDim;
cloned.scale.setScalar(scale);
// Re-center after scaling
const box2 = new THREE.Box3().setFromObject(cloned);
const center = new THREE.Vector3();
box2.getCenter(center);
cloned.position.sub(center);
return cloned;
}, [scene]);
return <primitive object={model} />;
}
function Viewer3D({ unit }: { unit: Unit }) {
const glbUrl = unit.id === 'u1' || unit.id === 'u2' ? HOUSE_2_GLB : HOUSE_1_GLB;
return (
<Canvas camera={{ position: [6, 5, 8], fov: 55 }} gl={{ antialias: true }} dpr={[1, 2]}>
<color attach="background" args={['#060d1f']} />
<ambientLight intensity={1.2} />
<directionalLight intensity={1.5} position={[10, 12, 8]} castShadow />
<directionalLight intensity={0.6} position={[-8, 4, -6]} />
<pointLight position={[0, 8, 0]} intensity={0.4} />
<Suspense
fallback={
<Html center>
<div className="rounded-lg border border-white/20 bg-zinc-900/90 px-4 py-3 text-sm text-zinc-200 backdrop-blur-xl">
<div className="flex items-center gap-2">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-cyan-400 border-t-transparent" />
<span>Loading 3D model</span>
</div>
</div>
</Html>
}
>
<Bounds fit clip observe margin={1.15}>
<GlbModel url={glbUrl} />
</Bounds>
</Suspense>
<OrbitControls
enableDamping
dampingFactor={0.06}
minDistance={1}
maxDistance={30}
enablePan
mouseButtons={{
LEFT: THREE.MOUSE.ROTATE,
MIDDLE: THREE.MOUSE.DOLLY,
RIGHT: THREE.MOUSE.PAN,
}}
/>
</Canvas>
);
}
// ─── Per-unit enriched data ────────────────────────────────────────────────
const UNIT_DETAILS: Record<string, {
bedrooms: number;
bathrooms: number;
parking: number;
features: string[];
paymentPlan: { label: string; value: string }[];
description: string;
}> = {
u1: {
bedrooms: 4, bathrooms: 4, parking: 2,
description: 'Sky-high penthouse with unobstructed panoramic sea views, private terrace, and bespoke interiors across two levels.',
features: ['Private Rooftop Terrace', 'Smart Home Automation', 'Floor-to-Ceiling Glazing', 'Private Elevator Lobby', 'Chef\'s Kitchen', 'Maid\'s Room'],
paymentPlan: [{ label: 'On Booking', value: '10%' }, { label: 'During Construction', value: '40%' }, { label: 'On Handover', value: '50%' }],
},
u2: {
bedrooms: 3, bathrooms: 3, parking: 2,
description: 'Expansive penthouse overlooking the sea and marina, featuring a wraparound terrace and premium finishes throughout.',
features: ['Wraparound Terrace', 'Marina & Sea Views', 'Smart Home System', 'Private Pool', 'Walk-in Wardrobes', 'Maid\'s Room'],
paymentPlan: [{ label: 'On Booking', value: '10%' }, { label: 'During Construction', value: '40%' }, { label: 'On Handover', value: '50%' }],
},
u3: {
bedrooms: 3, bathrooms: 3, parking: 2,
description: 'Generous 3-bedroom residence with sweeping sea views, open-plan living, and premium finishes on the 45th floor.',
features: ['Sea View', 'Open-Plan Living', 'Balcony', 'Built-in Wardrobes', 'Laundry Room', 'Storage Room'],
paymentPlan: [{ label: 'On Booking', value: '10%' }, { label: 'During Construction', value: '50%' }, { label: 'On Handover', value: '40%' }],
},
u4: {
bedrooms: 3, bathrooms: 3, parking: 2,
description: 'Elegant 3-bedroom apartment with marina views, contemporary design, and a spacious open-plan layout.',
features: ['Marina View', 'Open-Plan Living', 'Balcony', 'Built-in Wardrobes', 'Laundry Room', 'Storage Room'],
paymentPlan: [{ label: 'On Booking', value: '10%' }, { label: 'During Construction', value: '50%' }, { label: 'On Handover', value: '40%' }],
},
u5: {
bedrooms: 2, bathrooms: 2, parking: 1,
description: 'Bright 2-bedroom apartment with sea views and a modern open-plan kitchen and living area on the 44th floor.',
features: ['Sea View', 'Open-Plan Kitchen', 'Balcony', 'Built-in Wardrobes', 'Laundry Closet'],
paymentPlan: [{ label: 'On Booking', value: '10%' }, { label: 'During Construction', value: '50%' }, { label: 'On Handover', value: '40%' }],
},
u6: {
bedrooms: 2, bathrooms: 2, parking: 1,
description: 'Contemporary 2-bedroom apartment with city views, modern interiors, and a private balcony on the 44th floor.',
features: ['City View', 'Open-Plan Kitchen', 'Balcony', 'Built-in Wardrobes', 'Laundry Closet'],
paymentPlan: [{ label: 'On Booking', value: '10%' }, { label: 'During Construction', value: '50%' }, { label: 'On Handover', value: '40%' }],
},
u7: {
bedrooms: 1, bathrooms: 1, parking: 1,
description: 'Stylish 1-bedroom apartment with sea views and a well-appointed open-plan layout on the 43rd floor.',
features: ['Sea View', 'Open-Plan Layout', 'Balcony', 'Built-in Wardrobe'],
paymentPlan: [{ label: 'On Booking', value: '10%' }, { label: 'During Construction', value: '60%' }, { label: 'On Handover', value: '30%' }],
},
u8: {
bedrooms: 1, bathrooms: 1, parking: 1,
description: 'Modern 1-bedroom apartment with city views and a compact, efficient layout on the 43rd floor.',
features: ['City View', 'Open-Plan Layout', 'Balcony', 'Built-in Wardrobe'],
paymentPlan: [{ label: 'On Booking', value: '10%' }, { label: 'During Construction', value: '60%' }, { label: 'On Handover', value: '30%' }],
},
};
const DEFAULT_DETAILS = UNIT_DETAILS['u1'];
// ─── Property Detail Modal ───────────────────────────────────────────────────
function PropertyDetailModal({
unit,
onClose,
onOpen3D,
onOpenBlueprint,
}: {
unit: Unit;
onClose: () => void;
onOpen3D: (unit: Unit) => void;
onOpenBlueprint: (unit: Unit) => void;
}) {
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',
reserved: 'text-amber-300 border-amber-400/30 bg-amber-500/10',
sold: 'text-red-300 border-red-400/30 bg-red-500/10',
hold: 'text-zinc-300 border-zinc-400/30 bg-zinc-500/10',
};
const pricePerSqm = Math.round(unit.price / unit.area);
return (
<motion.div
className="fixed inset-0 z-[95] flex items-center justify-center p-4"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
{/* Backdrop */}
<div className="absolute inset-0 bg-black/75 backdrop-blur-md" onClick={onClose} />
{/* Modal */}
<motion.div
initial={{ y: 24, scale: 0.96 }}
animate={{ y: 0, scale: 1 }}
exit={{ y: 24, scale: 0.96 }}
transition={{ type: 'spring', stiffness: 320, damping: 28 }}
className="relative z-10 flex h-[88vh] w-full max-w-5xl flex-col overflow-hidden rounded-3xl border border-white/15 bg-gradient-to-br from-zinc-900/95 via-zinc-900/90 to-zinc-950/95 shadow-2xl backdrop-blur-2xl"
>
{/* Header bar */}
<div className="flex shrink-0 items-center justify-between border-b border-white/10 px-5 py-3.5">
<MacClose onClose={onClose} />
<div className="flex items-center gap-2">
<span className="text-xs text-zinc-500">Unit</span>
<span className="text-sm font-semibold text-zinc-100">{unit.unitNumber}</span>
<span className="mx-1 text-zinc-600">·</span>
<span className="text-xs text-zinc-400">Floor {unit.floor}</span>
</div>
<span className={`rounded-full border px-3 py-1 text-xs font-medium capitalize ${statusColors[unit.status]}`}>
{unit.status}
</span>
</div>
{/* Body — scrollable */}
<div className="flex-1 overflow-y-auto custom-scrollbar">
{/* Hero image */}
<div className="relative h-64 w-full shrink-0 overflow-hidden">
<img
src={preview}
alt={unit.unitNumber}
className="h-full w-full object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-zinc-950/90 via-zinc-950/20 to-transparent" />
{/* 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">{formatAmount(unit.price)}</p>
<p className="text-xs text-zinc-400">{formatAmount(pricePerSqm)} / m²</p>
</div>
</div>
{/* Content grid */}
<div className="grid gap-5 p-5 md:grid-cols-[1fr_280px]">
{/* Left column */}
<div className="space-y-5">
{/* Description */}
<div>
<h2 className="mb-1 text-xl font-semibold text-zinc-100">
{unit.type === 'penthouse' ? 'Penthouse' : unit.type.toUpperCase()} · {unit.unitNumber}
</h2>
<p className="text-sm leading-relaxed text-zinc-400">{details.description}</p>
</div>
{/* Key stats */}
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{[
{ icon: <Bed className="h-4 w-4" />, label: 'Bedrooms', value: details.bedrooms },
{ icon: <Bath className="h-4 w-4" />, label: 'Bathrooms', value: details.bathrooms },
{ icon: <Maximize2 className="h-4 w-4" />, label: 'Area', value: `${unit.area}` },
{ icon: <Layers className="h-4 w-4" />, label: 'Floor', value: unit.floor },
].map((stat) => (
<div key={stat.label} className="flex flex-col items-center gap-1.5 rounded-xl border border-white/10 bg-white/5 p-3">
<span className="text-cyan-300">{stat.icon}</span>
<span className="text-lg font-semibold text-zinc-100">{stat.value}</span>
<span className="text-[11px] text-zinc-500">{stat.label}</span>
</div>
))}
</div>
{/* Additional info row */}
<div className="grid grid-cols-3 gap-2">
{[
{ icon: <Compass className="h-3.5 w-3.5" />, label: 'View', value: unit.view },
{ icon: <MapPin className="h-3.5 w-3.5" />, label: 'Parking', value: `${details.parking} Bay${details.parking > 1 ? 's' : ''}` },
{ icon: <Tag className="h-3.5 w-3.5" />, label: 'Type', value: unit.type.toUpperCase() },
].map((item) => (
<div key={item.label} className="rounded-xl border border-white/10 bg-white/5 px-3 py-2.5">
<div className="mb-1 flex items-center gap-1.5 text-zinc-500">
{item.icon}
<span className="text-[10px] uppercase tracking-wide">{item.label}</span>
</div>
<p className="text-sm font-medium text-zinc-200">{item.value}</p>
</div>
))}
</div>
{/* Features */}
<div>
<p className="mb-2.5 text-xs uppercase tracking-widest text-zinc-500">Features & Amenities</p>
<div className="flex flex-wrap gap-2">
{details.features.map((f) => (
<span
key={f}
className="rounded-full border border-cyan-300/20 bg-cyan-500/10 px-3 py-1 text-xs text-cyan-200"
>
{f}
</span>
))}
</div>
</div>
{/* Quick-launch buttons */}
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => { onClose(); onOpen3D(unit); }}
className="flex items-center justify-center gap-2 rounded-xl border border-cyan-300/30 bg-cyan-500/10 py-3 text-sm font-medium text-cyan-100 transition-colors hover:bg-cyan-500/20"
>
3D Viewer <ArrowRight className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => { onClose(); onOpenBlueprint(unit); }}
className="flex items-center justify-center gap-2 rounded-xl border border-blue-300/30 bg-blue-500/10 py-3 text-sm font-medium text-blue-100 transition-colors hover:bg-blue-500/20"
>
Blueprint Studio <ArrowRight className="h-4 w-4" />
</button>
</div>
</div>
{/* Right column */}
<div className="space-y-4">
{/* 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">{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">
<span className="text-zinc-400">Unit Area</span>
<span className="text-zinc-200">{unit.area} m²</span>
</div>
<div className="flex justify-between">
<span className="text-zinc-400">Floor</span>
<span className="text-zinc-200">{unit.floor}</span>
</div>
<div className="flex justify-between">
<span className="text-zinc-400">Parking</span>
<span className="text-zinc-200">{details.parking} Bay{details.parking > 1 ? 's' : ''}</span>
</div>
</div>
</div>
{/* Payment plan */}
<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">Payment Plan</p>
<div className="space-y-2">
{details.paymentPlan.map((step, i) => (
<div key={step.label} className="flex items-center gap-3">
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-cyan-300/30 bg-cyan-500/10 text-[10px] font-bold text-cyan-300">
{i + 1}
</div>
<div className="flex flex-1 items-center justify-between">
<span className="text-xs text-zinc-400">{step.label}</span>
<span className="text-sm font-semibold text-zinc-100">{step.value}</span>
</div>
</div>
))}
</div>
</div>
{/* Status card */}
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
<p className="mb-2 text-xs uppercase tracking-widest text-zinc-500">Availability</p>
<span className={`inline-block rounded-full border px-3 py-1 text-sm font-medium capitalize ${statusColors[unit.status]}`}>
{unit.status === 'available' ? '✓ Available Now' :
unit.status === 'reserved' ? '⏳ Reserved' :
unit.status === 'sold' ? '✗ Sold' : '⏸ On Hold'}
</span>
<p className="mt-2 text-[11px] text-zinc-600">
Last updated: {unit.lastUpdated.toLocaleDateString('en-AE', { day: 'numeric', month: 'short', year: 'numeric' })}
</p>
</div>
</div>
</div>
</div>
</motion.div>
</motion.div>
);
}
function UnitCard({
unit,
onOpen3D,
onOpenBlueprint,
onViewDetails,
}: {
unit: Unit;
onOpen3D: (unit: Unit) => void;
onOpenBlueprint: (unit: Unit) => void;
onViewDetails: (unit: Unit) => void;
}) {
const preview = UNIT_PREVIEWS[unit.id] ?? UNIT_PREVIEWS['u1'];
const [hovered, setHovered] = useState(false);
const { formatAmount } = useCurrency();
// Status accent color for glow
const statusGlow =
unit.status === 'available' ? 'rgba(34,197,94,0.15)' :
unit.status === 'reserved' ? 'rgba(245,158,11,0.15)' :
unit.status === 'sold' ? 'rgba(139,92,246,0.12)' :
'rgba(239,68,68,0.12)';
return (
<motion.div
className="group relative cursor-pointer overflow-hidden rounded-2xl"
style={{
background: 'rgba(14, 16, 21, 0.72)',
border: '1px solid rgba(255,255,255,0.08)',
backdropFilter: 'blur(18px)',
WebkitBackdropFilter: 'blur(18px)',
}}
onClick={() => onViewDetails(unit)}
whileHover={{ y: -3, scale: 1.008 }}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
transition={{ type: 'spring', stiffness: 400, damping: 28 }}
>
{/* Ambient status glow — top-right corner */}
<motion.div
className="absolute top-0 right-0 w-32 h-32 rounded-full blur-3xl pointer-events-none"
style={{ background: statusGlow }}
animate={{ opacity: hovered ? 1 : 0.5 }}
transition={{ duration: 0.3 }}
/>
{/* Inner padding wrapper */}
<div className="relative z-10 p-4">
{/* Header row */}
<div className="mb-2.5 flex items-start justify-between">
<div>
<div
className="mb-1.5 inline-flex items-center gap-1.5 rounded-lg px-2 py-0.5 text-[10px] font-medium tracking-wide"
style={{ background: 'rgba(255,255,255,0.06)', color: 'hsl(var(--muted-fg))', border: '1px solid rgba(255,255,255,0.08)' }}
>
<Folder className="h-3 w-3" style={{ color: 'hsl(var(--accent))' }} />
Unit Folder
</div>
<p className="text-2xl font-bold leading-none tracking-tight text-white">{unit.unitNumber}</p>
<p className="text-xs mt-1" style={{ color: 'hsl(var(--muted-fg))' }}>Floor {unit.floor}</p>
</div>
<StatusBadge status={unit.status} />
</div>
{/* Image / 3D preview */}
<div
className="mb-3 overflow-hidden rounded-xl"
style={{ border: '1px solid rgba(255,255,255,0.07)' }}
>
{!hovered ? (
<img src={preview} alt={`${unit.unitNumber} preview`} className="h-36 w-full object-cover" />
) : (
<div className="h-36 w-full" style={{ background: 'hsl(var(--background))' }}>
<Canvas camera={{ position: [6, 5, 8], fov: 50 }}>
<ambientLight intensity={1.2} />
<directionalLight intensity={1.4} position={[8, 10, 6]} />
<Suspense fallback={null}>
<Bounds fit clip observe margin={1.1}>
<GlbModel url={unit.id === 'u1' || unit.id === 'u2' ? HOUSE_2_GLB : HOUSE_1_GLB} />
</Bounds>
</Suspense>
<OrbitControls autoRotate autoRotateSpeed={1.2} enablePan={false} enableZoom={false} />
</Canvas>
</div>
)}
</div>
{/* Tags */}
<div className="mb-3 grid grid-cols-3 gap-1.5">
<span className="tag text-center uppercase">{unit.type}</span>
<span className="tag text-center">{unit.area} m²</span>
<span className="tag text-center truncate">{unit.view}</span>
</div>
{/* 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">{formatAmount(unit.price)}</p>
</div>
{/* Divider */}
<div className="mb-3 h-px" style={{ background: 'rgba(255,255,255,0.06)' }} />
{/* Actions */}
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={(e) => { e.stopPropagation(); onOpen3D(unit); }}
className="rounded-xl py-2 text-xs font-semibold transition-all hover:brightness-110 active:scale-95"
style={{ background: 'hsl(var(--accent))', color: 'hsl(var(--accent-fg))' }}
>
3D Viewer
</button>
<button
type="button"
onClick={(e) => { e.stopPropagation(); onOpenBlueprint(unit); }}
className="rounded-xl py-2 text-xs font-medium transition-all hover:brightness-110 active:scale-95"
style={{
background: 'rgba(255,255,255,0.06)',
color: 'hsl(var(--muted-fg))',
border: '1px solid rgba(255,255,255,0.1)',
}}
>
Blueprint
</button>
</div>
</div>
</motion.div>
);
}
function BlueprintViewer({ blueprintImage, unitNumber }: { blueprintImage: string; unitNumber: string }) {
const containerRef = useRef<HTMLDivElement>(null);
const imgRef = useRef<HTMLImageElement>(null);
// scale: 1 = image natural size. We compute fitScale once image loads.
const [scale, setScale] = useState(1);
const [fitScale, setFitScale] = useState(1);
const [offset, setOffset] = useState({ x: 0, y: 0 });
const [dragging, setDragging] = useState(false);
const dragStart = useRef({ mx: 0, my: 0, ox: 0, oy: 0 });
// Compute fit-to-height scale when image loads
const handleImageLoad = () => {
const img = imgRef.current;
const container = containerRef.current;
if (!img || !container) return;
const naturalW = img.naturalWidth;
const naturalH = img.naturalHeight;
const containerH = container.clientHeight;
const containerW = container.clientWidth;
// Fit to height, allow empty space on sides
const byHeight = containerH / naturalH;
// But don't exceed container width either
const byWidth = containerW / naturalW;
const fit = Math.min(byHeight, byWidth) * 0.92; // 92% so there's a small margin
setFitScale(fit);
setScale(fit);
setOffset({ x: 0, y: 0 });
};
const clampScale = (s: number) => Math.max(fitScale * 0.5, Math.min(fitScale * 8, s));
const handleWheel = (e: React.WheelEvent) => {
e.preventDefault();
const factor = e.deltaY < 0 ? 1.12 : 1 / 1.12;
setScale((prev) => clampScale(prev * factor));
};
const handleMouseDown = (e: React.MouseEvent) => {
e.preventDefault();
setDragging(true);
dragStart.current = { mx: e.clientX, my: e.clientY, ox: offset.x, oy: offset.y };
};
const handleMouseMove = (e: React.MouseEvent) => {
if (!dragging) return;
setOffset({
x: dragStart.current.ox + (e.clientX - dragStart.current.mx),
y: dragStart.current.oy + (e.clientY - dragStart.current.my),
});
};
const handleMouseUp = () => setDragging(false);
const zoomIn = () => setScale((prev) => clampScale(prev * 1.25));
const zoomOut = () => setScale((prev) => clampScale(prev / 1.25));
const resetView = () => { setScale(fitScale); setOffset({ x: 0, y: 0 }); };
const zoomPercent = Math.round((scale / fitScale) * 100);
return (
<div
ref={containerRef}
className="relative h-full w-full overflow-hidden bg-[#060d1f]"
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
style={{ cursor: dragging ? 'grabbing' : 'grab', userSelect: 'none' }}
>
{/* Blueprint image — centered, transformed */}
<div
className="absolute inset-0 flex items-center justify-center"
style={{ pointerEvents: 'none' }}
>
<img
ref={imgRef}
src={blueprintImage}
alt={`${unitNumber} blueprint`}
onLoad={handleImageLoad}
draggable={false}
className="brightness-110 contrast-125 saturate-0"
style={{
transform: `translate(${offset.x}px, ${offset.y}px) scale(${scale})`,
transformOrigin: 'center center',
maxWidth: 'none',
maxHeight: 'none',
}}
/>
</div>
{/* Zoom controls */}
<div className="absolute right-4 top-4 flex flex-col gap-1.5 z-10">
<button
type="button"
onClick={zoomIn}
className="flex h-8 w-8 items-center justify-center rounded-lg border border-white/20 bg-zinc-900/90 text-lg font-light text-zinc-200 backdrop-blur-xl transition-colors hover:bg-zinc-800/90"
title="Zoom In"
>+</button>
<button
type="button"
onClick={zoomOut}
className="flex h-8 w-8 items-center justify-center rounded-lg border border-white/20 bg-zinc-900/90 text-lg font-light text-zinc-200 backdrop-blur-xl transition-colors hover:bg-zinc-800/90"
title="Zoom Out"
></button>
<button
type="button"
onClick={resetView}
className="flex h-8 w-8 items-center justify-center rounded-lg border border-white/20 bg-zinc-900/90 text-[10px] text-zinc-300 backdrop-blur-xl transition-colors hover:bg-zinc-800/90"
title="Reset View"
></button>
</div>
{/* Zoom level indicator */}
<div className="absolute bottom-4 left-4 z-10 rounded-lg border border-white/20 bg-zinc-900/90 px-3 py-1.5 text-xs text-zinc-300 backdrop-blur-xl">
{zoomPercent}%
</div>
</div>
);
}
function StudioWindow({
unit,
mode,
onClose,
}: {
unit: Unit | null;
mode: '3d' | 'blueprint' | null;
onClose: () => void;
}) {
if (!unit || !mode) return null;
const blueprintImage = unit.id === 'u1' || unit.id === 'u2' ? HOUSE_2_BLUEPRINT : HOUSE_1_BLUEPRINT;
return (
<motion.div className="fixed inset-0 z-[90] grid place-items-center p-6" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}>
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={onClose} />
<motion.div
initial={{ y: 18, scale: 0.97 }}
animate={{ y: 0, scale: 1 }}
className="relative z-10 h-[80vh] w-[92vw] overflow-hidden rounded-3xl border border-white/15 bg-zinc-900/90 backdrop-blur-2xl"
>
<div className="flex items-center justify-between border-b border-white/10 px-4 py-3">
<MacClose onClose={onClose} />
<p className="text-sm font-medium text-zinc-200">{mode === '3d' ? '3D Unit Studio' : 'Blueprint Studio'} - {unit.unitNumber}</p>
<div />
</div>
<div className={['h-[calc(80vh-56px)]', mode === '3d' ? 'grid grid-rows-[1fr_auto]' : 'grid grid-rows-[1fr]'].join(' ')}>
<div className="relative bg-[#060d1f]">
{mode === '3d' ? <Viewer3D unit={unit} /> : <BlueprintViewer blueprintImage={blueprintImage} unitNumber={unit.unitNumber} />}
</div>
{mode === '3d' && (
<div className="grid grid-cols-[1fr_280px] gap-3 border-t border-white/10 bg-zinc-950/75 p-3">
<div className="rounded-xl border border-white/10 bg-white/5 p-3">
<p className="text-xs uppercase tracking-wide text-zinc-400">Model Viewport</p>
<p className="mt-1 text-sm text-zinc-200">Interactive 3D model loaded from OBJ + MTL + textures.</p>
</div>
<div className="rounded-xl border border-white/10 bg-white/5 p-3">
<p className="mb-2 text-xs uppercase tracking-wide text-zinc-400">Controls</p>
<p className="text-xs text-zinc-300">Mouse: rotate, pan, zoom</p>
<p className="text-xs text-zinc-300">Model source: House {unit.id === 'u1' || unit.id === 'u2' ? '2' : '1'}</p>
</div>
</div>
)}
</div>
</motion.div>
</motion.div>
);
}
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" />
<div className="absolute left-3 top-3 rounded-xl border border-white/20 bg-zinc-900/80 px-3 py-2 text-xs text-zinc-200 backdrop-blur-xl">
<MapPinned className="mr-1 inline h-3.5 w-3.5 text-cyan-300" />
Google Maps - Inventory Region
</div>
<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} - {formatAmount(unit.price)}
</div>
))}
</div>
</div>
);
}
// ── List-view row ────────────────────────────────────────────────────────────
function UnitRow({
unit,
onViewDetails,
onOpen3D,
onOpenBlueprint,
}: {
unit: Unit;
onViewDetails: (u: Unit) => void;
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"
style={{ background: 'hsl(var(--surface-2))' }}
whileHover={{ background: 'hsl(var(--surface-3))' } as never}
onClick={() => onViewDetails(unit)}
layout
>
{/* Thumbnail */}
<img
src={UNIT_PREVIEWS[unit.id] ?? UNIT_PREVIEWS['u1']}
alt={unit.unitNumber}
className="w-14 h-10 rounded-lg object-cover flex-shrink-0"
style={{ border: '1px solid hsl(var(--border-subtle))' }}
/>
{/* Unit info */}
<div className="flex-1 min-w-0 grid grid-cols-5 items-center gap-3">
<div className="col-span-1">
<p className="font-bold text-white text-sm leading-none">{unit.unitNumber}</p>
<p className="text-xs mt-0.5" style={{ color: 'hsl(var(--muted-fg))' }}>Floor {unit.floor}</p>
</div>
<div className="col-span-1">
<StatusBadge status={unit.status} />
</div>
<div className="col-span-1 flex gap-1.5">
<span className="tag">{unit.type}</span>
<span className="tag">{unit.area} m²</span>
</div>
<div className="col-span-1">
<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">{formatAmount(unit.price)}</p>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2 flex-shrink-0" onClick={(e) => e.stopPropagation()}>
<button
type="button"
onClick={() => onOpen3D(unit)}
className="px-3 py-1.5 rounded-lg text-xs font-semibold transition-opacity hover:opacity-90"
style={{ background: 'hsl(var(--accent))', color: 'hsl(var(--accent-fg))' }}
>
3D
</button>
<button
type="button"
onClick={() => onOpenBlueprint(unit)}
className="px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
style={{ background: 'hsl(var(--surface-3))', color: 'hsl(var(--muted-fg))', border: '1px solid hsl(var(--border-subtle))' }}
>
Blueprint
</button>
</div>
</motion.div>
);
}
export function Inventory() {
const { units, filterStatus, setFilterStatus } = useStore();
const [searchQuery, setSearchQuery] = useState('');
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
const [studioUnit, setStudioUnit] = useState<Unit | null>(null);
const [studioMode, setStudioMode] = useState<'3d' | 'blueprint' | null>(null);
const [detailUnit, setDetailUnit] = useState<Unit | null>(null);
const stats = useMemo(
() => ({
total: units.length,
available: units.filter((u) => u.status === 'available').length,
reserved: units.filter((u) => u.status === 'reserved').length,
sold: units.filter((u) => u.status === 'sold').length,
}),
[units]
);
const filteredUnits = useMemo(() => {
return units.filter((unit) => {
const queryMatch = unit.unitNumber.toLowerCase().includes(searchQuery.toLowerCase()) || unit.view.toLowerCase().includes(searchQuery.toLowerCase());
const statusMatch = filterStatus === 'all' || unit.status === filterStatus;
return queryMatch && statusMatch;
});
}, [units, searchQuery, filterStatus]);
const statusFilters: { value: Unit['status'] | 'all'; label: string }[] = [
{ value: 'all', label: 'All Units' },
{ value: 'available', label: 'Available' },
{ value: 'reserved', label: 'Reserved' },
{ value: 'sold', label: 'Sold' },
{ value: 'hold', label: 'On Hold' },
];
// Stat card definitions
const statCards = [
{
label: 'Total Units',
value: stats.total,
sub: 'In portfolio',
color: 'hsl(var(--accent))',
fill: 100,
},
{
label: 'Available',
value: stats.available,
sub: `${Math.round((stats.available / stats.total) * 100) || 0}% of total`,
color: 'hsl(var(--success))',
fill: (stats.available / stats.total) * 100,
},
{
label: 'Reserved',
value: stats.reserved,
sub: `${Math.round((stats.reserved / stats.total) * 100) || 0}% of total`,
color: 'hsl(var(--warning))',
fill: (stats.reserved / stats.total) * 100,
},
{
label: 'Sold',
value: stats.sold,
sub: `${Math.round((stats.sold / stats.total) * 100) || 0}% of total`,
color: '#a78bfa',
fill: (stats.sold / stats.total) * 100,
},
];
return (
<div className="space-y-4">
{/* ── Stat Cards ─────────────────────────────────────────────────────── */}
<div className="grid grid-cols-4 gap-3">
{statCards.map((card) => (
<motion.div
key={card.label}
className="rounded-2xl p-4 relative overflow-hidden"
style={{
background: 'rgba(18,20,26,0.6)',
border: '1px solid rgba(255,255,255,0.08)',
backdropFilter: 'blur(20px)',
WebkitBackdropFilter: 'blur(20px)',
}}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
{/* Ambient glow */}
<div
className="absolute top-0 right-0 w-20 h-20 rounded-full blur-2xl opacity-20 -translate-y-1/2 translate-x-1/2 pointer-events-none"
style={{ background: card.color }}
/>
{/* Header */}
<div className="flex items-center justify-between mb-3">
<p className="text-xs font-medium uppercase tracking-widest" style={{ color: 'hsl(var(--muted-fg))' }}>
{card.label}
</p>
<div className="w-2 h-2 rounded-full" style={{ background: card.color, boxShadow: `0 0 8px ${card.color}` }} />
</div>
{/* Big number */}
<p className="text-4xl font-bold text-white leading-none mb-1">{card.value}</p>
<p className="text-xs mb-3" style={{ color: 'hsl(var(--subtle-fg))' }}>{card.sub}</p>
{/* Fill bar */}
<div className="h-1 rounded-full overflow-hidden" style={{ background: 'rgba(255,255,255,0.07)' }}>
<motion.div
className="h-full rounded-full"
style={{ background: card.color }}
initial={{ width: 0 }}
animate={{ width: `${card.fill}%` }}
transition={{ duration: 0.8, ease: [0.4, 0, 0.2, 1] }}
/>
</div>
</motion.div>
))}
</div>
{/* ── Toolbar ────────────────────────────────────────────────────────── */}
<div
className="flex flex-wrap items-center gap-3 p-3 rounded-2xl"
style={{ background: 'rgba(18,20,26,0.6)', border: '1px solid rgba(255,255,255,0.08)', backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)' }}
>
{/* Search */}
<div className="relative min-w-[240px] flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" style={{ color: 'hsl(var(--muted-fg))' }} />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search units..."
className="h-9 w-full rounded-xl pl-9 pr-3 text-sm text-white placeholder:text-zinc-500 focus:outline-none transition-all"
style={{ background: 'hsl(var(--surface-2))', border: '1px solid hsl(var(--border-subtle))' }}
/>
</div>
{/* Status filters */}
<div className="flex items-center gap-1.5">
{statusFilters.map((filter) => (
<button
key={filter.value}
type="button"
onClick={() => setFilterStatus(filter.value)}
className="rounded-xl px-3 py-1.5 text-xs font-medium transition-all"
style={filterStatus === filter.value
? { background: 'hsl(var(--accent))', color: 'hsl(var(--accent-fg))' }
: { background: 'hsl(var(--surface-2))', color: 'hsl(var(--muted-fg))', border: '1px solid hsl(var(--border-subtle))' }
}
>
{filter.label}
</button>
))}
</div>
{/* View toggle */}
<div
className="flex items-center rounded-xl p-1 gap-1"
style={{ background: 'hsl(var(--surface-2))', border: '1px solid hsl(var(--border-subtle))' }}
>
<button
type="button"
onClick={() => setViewMode('grid')}
className="p-1.5 rounded-lg transition-all"
style={viewMode === 'grid'
? { background: 'hsl(var(--accent))', color: 'hsl(var(--accent-fg))' }
: { color: 'hsl(var(--muted-fg))' }
}
title="Grid view"
>
<Layers className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => setViewMode('list')}
className="p-1.5 rounded-lg transition-all"
style={viewMode === 'list'
? { background: 'hsl(var(--accent))', color: 'hsl(var(--accent-fg))' }
: { color: 'hsl(var(--muted-fg))' }
}
title="List view"
>
<MapPin className="w-4 h-4" />
</button>
</div>
</div>
{/* ── Unit List / Grid ───────────────────────────────────────────────── */}
<div className="grid gap-4 xl:grid-cols-2">
<div
className="h-[44rem] overflow-y-auto rounded-2xl p-3 custom-scrollbar"
style={{ background: 'hsl(var(--surface))', border: '1px solid hsl(var(--border-subtle))' }}
>
{viewMode === 'grid' ? (
<div className="grid grid-cols-2 gap-3">
{filteredUnits.map((unit) => (
<UnitCard
key={unit.id}
unit={unit}
onViewDetails={(current) => setDetailUnit(current)}
onOpen3D={(current) => { setStudioUnit(current); setStudioMode('3d'); }}
onOpenBlueprint={(current) => { setStudioUnit(current); setStudioMode('blueprint'); }}
/>
))}
</div>
) : (
<div className="space-y-2">
{/* List header */}
<div className="grid grid-cols-5 gap-3 px-4 py-2 text-xs font-medium uppercase tracking-widest" style={{ color: 'hsl(var(--subtle-fg))' }}>
<span className="col-span-1">Unit</span>
<span className="col-span-1">Status</span>
<span className="col-span-1">Type / Area</span>
<span className="col-span-1">View</span>
<span className="col-span-1">Price</span>
</div>
{filteredUnits.map((unit) => (
<UnitRow
key={unit.id}
unit={unit}
onViewDetails={(current) => setDetailUnit(current)}
onOpen3D={(current) => { setStudioUnit(current); setStudioMode('3d'); }}
onOpenBlueprint={(current) => { setStudioUnit(current); setStudioMode('blueprint'); }}
/>
))}
</div>
)}
</div>
<RightMapPane units={filteredUnits} />
</div>
<AnimatePresence>
{detailUnit && (
<PropertyDetailModal
unit={detailUnit}
onClose={() => setDetailUnit(null)}
onOpen3D={(current) => { setDetailUnit(null); setStudioUnit(current); setStudioMode('3d'); }}
onOpenBlueprint={(current) => { setDetailUnit(null); setStudioUnit(current); setStudioMode('blueprint'); }}
/>
)}
</AnimatePresence>
<AnimatePresence>
{studioMode && (
<StudioWindow
unit={studioUnit}
mode={studioMode}
onClose={() => { setStudioMode(null); setStudioUnit(null); }}
/>
)}
</AnimatePresence>
</div>
);
}