Files
Velocity-OS/webos/src/pillars/studio/PropertyEntity.tsx
Sagnik Ghosh effd19531a
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
Fix Studio Pillar crash and fetch user profile on login
2026-05-01 13:09:09 +05:30

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>
);
}