#13 Built the complete Oracle Tab with all the functionalities. Co-authored-by: Sayan Datta <sayan@Sayans-MacBook-Air.local> Reviewed-on: #14
1081 lines
45 KiB
TypeScript
1081 lines
45 KiB
TypeScript
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 (u1–u8) 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} m²` },
|
||
{ 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>
|
||
);
|
||
}
|