Initial commit: Velocity-OS migration
This commit is contained in:
227
webos/src/pillars/studio/PropertyEntity.tsx
Normal file
227
webos/src/pillars/studio/PropertyEntity.tsx
Normal file
@@ -0,0 +1,227 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user