Some checks failed
Velocity-OS Deployment Pipeline / lint (push) Has been cancelled
Velocity-OS Deployment Pipeline / build-and-push (agents) (push) Has been cancelled
Velocity-OS Deployment Pipeline / build-and-push (core) (push) Has been cancelled
Velocity-OS Deployment Pipeline / build-and-push (media-engine) (push) Has been cancelled
Velocity-OS Deployment Pipeline / build-and-push (webos) (push) Has been cancelled
Velocity-OS Deployment Pipeline / sign-images (agents) (push) Has been cancelled
Velocity-OS Deployment Pipeline / sign-images (core) (push) Has been cancelled
Velocity-OS Deployment Pipeline / sign-images (media-engine) (push) Has been cancelled
Velocity-OS Deployment Pipeline / sign-images (webos) (push) Has been cancelled
Velocity-OS Deployment Pipeline / notify-ingress (push) Has been cancelled
228 lines
8.1 KiB
TypeScript
228 lines
8.1 KiB
TypeScript
import { Suspense, useState, useRef } from 'react';
|
|
import { useParams, useNavigate } from 'react-router-dom';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
import { Canvas, useFrame } from '@react-three/fiber';
|
|
import { OrbitControls, Environment, PerspectiveCamera, Html, useGLTF } from '@react-three/drei';
|
|
import { ReimaginePanel } from './ReimaginePanel';
|
|
import { useProperty } from '../../shared/hooks/useStudio';
|
|
import styles from './PropertyEntity.module.css';
|
|
|
|
/**
|
|
* PropertyEntity — The unified property page.
|
|
* Route: /studio/:propertyId
|
|
*
|
|
* Layout:
|
|
* - 3D hero viewport (React Three Fiber) — always prominent
|
|
* - Property header overlay (name, config, price) — glassmorphic
|
|
* - Action bar: [View Floorplan] [Reimagine ✨] [Share Brochure]
|
|
* - ReimaginePanel slides in below hero on "Reimagine" tap
|
|
* - Media grid below
|
|
*
|
|
* Transition into this page: scale(0.94)→scale(1) from Studio
|
|
* (handled by AuthenticatedShell depth choreography)
|
|
*/
|
|
|
|
type Tab = 'overview' | 'media';
|
|
|
|
export default function PropertyEntity() {
|
|
const { propertyId } = useParams<{ propertyId: string }>();
|
|
const navigate = useNavigate();
|
|
const [showReimagine, setShowReimagine] = useState(false);
|
|
const [activeTab, setActiveTab] = useState<Tab>('overview');
|
|
|
|
const { property, isLoading } = useProperty(propertyId!);
|
|
|
|
if (isLoading || !property) return <PropertySkeleton />;
|
|
|
|
return (
|
|
<div className={styles.root}>
|
|
{/* Back */}
|
|
<button className={styles.backBtn} onClick={() => navigate('/studio')}>
|
|
← Studio
|
|
</button>
|
|
|
|
{/* ── 3D Hero Viewport ────────────────────────────── */}
|
|
<div className={styles.hero3d}>
|
|
<Canvas
|
|
className={styles.canvas}
|
|
gl={{ antialias: true, alpha: true }}
|
|
dpr={[1, 2]}
|
|
>
|
|
<PerspectiveCamera makeDefault position={[0, 1.5, 5]} fov={55} />
|
|
<ambientLight intensity={0.6} />
|
|
<directionalLight position={[5, 8, 5]} intensity={1.2} castShadow />
|
|
|
|
<Suspense fallback={<Html center><span style={{ color: 'white' }}>Loading model…</span></Html>}>
|
|
{property.modelUrl
|
|
? <PropertyModel url={property.modelUrl} />
|
|
: <PlaceholderBuilding />
|
|
}
|
|
<Environment preset="city" />
|
|
</Suspense>
|
|
|
|
<OrbitControls
|
|
enablePan={false}
|
|
enableZoom={true}
|
|
minDistance={3}
|
|
maxDistance={12}
|
|
maxPolarAngle={Math.PI / 2}
|
|
/>
|
|
</Canvas>
|
|
|
|
{/* Glassmorphic overlay on hero */}
|
|
<div className={`${styles.heroOverlay} glass`}>
|
|
<h1 className={styles.propName}>{property.name}</h1>
|
|
<p className={styles.propMeta}>
|
|
{property.config} · {property.area} · {property.price}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Action bar ──────────────────────────────────── */}
|
|
<div className={styles.actionBar}>
|
|
<button className="btn-ghost">📐 Floorplan</button>
|
|
<button
|
|
className={`btn-primary ${showReimagine ? styles.reimagineActive : ''}`}
|
|
onClick={() => setShowReimagine(v => !v)}
|
|
>
|
|
✨ Reimagine
|
|
</button>
|
|
<button className="btn-ghost"
|
|
onClick={() => {/* vault link generation */}}>
|
|
🔗 Share
|
|
</button>
|
|
</div>
|
|
|
|
{/* ── ReimaginePanel — slides in below hero (no nav) ─ */}
|
|
<AnimatePresence>
|
|
{showReimagine && (
|
|
<motion.div
|
|
className={styles.reimagineSlot}
|
|
initial={{ height: 0, opacity: 0 }}
|
|
animate={{ height: 'auto', opacity: 1 }}
|
|
exit={{ height: 0, opacity: 0 }}
|
|
transition={{ duration: 0.3, ease: [0.4, 0, 0.2, 1] }}
|
|
style={{ overflow: 'hidden' }}
|
|
>
|
|
<div className={`${styles.reimaginePad} glass-card`}>
|
|
<ReimaginePanel
|
|
propertyId={propertyId!}
|
|
roomImageUrl={property.interiorImageUrl}
|
|
onResultSaved={() => setShowReimagine(false)}
|
|
/>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* ── Tab nav ─────────────────────────────────────── */}
|
|
<div className={styles.tabs}>
|
|
{(['overview', 'media'] as Tab[]).map(tab => (
|
|
<button
|
|
key={tab}
|
|
className={`${styles.tab} ${activeTab === tab ? styles.tabActive : ''}`}
|
|
onClick={() => setActiveTab(tab)}
|
|
>
|
|
{tab === 'overview' ? 'Overview' : 'Media Gallery'}
|
|
{activeTab === tab && (
|
|
<motion.div layoutId="prop-tab-underline" className={styles.tabUnderline}
|
|
transition={{ type: 'spring', stiffness: 500, damping: 40 }} />
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* ── Tab content ─────────────────────────────────── */}
|
|
<AnimatePresence mode="wait">
|
|
<motion.div
|
|
key={activeTab}
|
|
initial={{ opacity: 0, y: 8 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0 }}
|
|
transition={{ duration: 0.2 }}
|
|
>
|
|
{activeTab === 'overview' && (
|
|
<div className={styles.overview}>
|
|
{property.description && (
|
|
<p className={styles.description}>{property.description}</p>
|
|
)}
|
|
{property.amenities && (
|
|
<div className={styles.amenities}>
|
|
{(property.amenities || []).map((a, i) => (
|
|
<span key={i} className={`${styles.amenityChip} glass`}>{a}</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
{activeTab === 'media' && (
|
|
<div className={styles.mediaGrid}>
|
|
{(property.images ?? []).map((img, i) => (
|
|
<motion.img
|
|
key={i}
|
|
src={img}
|
|
className={styles.mediaImg}
|
|
initial={{ opacity: 0, scale: 0.96 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
transition={{ delay: i * 0.06 }}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</motion.div>
|
|
</AnimatePresence>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── 3D Model component (R3F) ──────────────────────────────────
|
|
function PropertyModel({ url }: { url: string }) {
|
|
const { scene } = useGLTF(url);
|
|
const meshRef = useRef<any>();
|
|
|
|
useFrame((_, delta) => {
|
|
if (meshRef.current) {
|
|
meshRef.current.rotation.y += delta * 0.05;
|
|
}
|
|
});
|
|
|
|
return <primitive ref={meshRef} object={scene} scale={1} />;
|
|
}
|
|
|
|
// Placeholder building geometry when no GLB model available
|
|
function PlaceholderBuilding() {
|
|
const ref = useRef<any>();
|
|
useFrame((_, delta) => {
|
|
if (ref.current) ref.current.rotation.y += delta * 0.1;
|
|
});
|
|
return (
|
|
<group ref={ref}>
|
|
<mesh position={[0, 0.75, 0]}>
|
|
<boxGeometry args={[1.5, 1.5, 1.5]} />
|
|
<meshStandardMaterial color="#7C3AED" roughness={0.3} metalness={0.4} />
|
|
</mesh>
|
|
<mesh position={[0, 2.5, 0]}>
|
|
<boxGeometry args={[0.9, 1.5, 0.9]} />
|
|
<meshStandardMaterial color="#A78BFA" roughness={0.2} metalness={0.5} />
|
|
</mesh>
|
|
<mesh position={[0, 3.8, 0]}>
|
|
<boxGeometry args={[0.5, 0.6, 0.5]} />
|
|
<meshStandardMaterial color="#C4B5FD" roughness={0.1} metalness={0.6} />
|
|
</mesh>
|
|
</group>
|
|
);
|
|
}
|
|
|
|
function PropertySkeleton() {
|
|
return (
|
|
<div className={styles.root}>
|
|
<div className={`${styles.hero3d} shimmer`} style={{ height: 360 }} />
|
|
<div style={{ padding: 'var(--space-6)', display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
|
|
{[0,1].map(i => (
|
|
<div key={i} className="shimmer" style={{ height: 64, borderRadius: 'var(--radius-lg)', background: 'var(--glass-bg)' }} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|