|
|
|
|
@@ -0,0 +1,241 @@
|
|
|
|
|
import { useState } from "react";
|
|
|
|
|
import { motion, AnimatePresence } from "motion/react";
|
|
|
|
|
import { routes } from "@/routes";
|
|
|
|
|
import { Info, MapPin, ChevronLeft } from "lucide-react";
|
|
|
|
|
import { cls } from "@/lib/utils";
|
|
|
|
|
|
|
|
|
|
type ViewKey = 'exterior' | 'cab' | 'machinery' | 'electrical' | 'boom' | 'maintenance';
|
|
|
|
|
|
|
|
|
|
type Hotspot =
|
|
|
|
|
| { id: string; x: number; y: number; label: string; type: 'nav'; target: ViewKey }
|
|
|
|
|
| { id: string; x: number; y: number; label: string; type: 'info'; info: string };
|
|
|
|
|
|
|
|
|
|
type View = {
|
|
|
|
|
title: string;
|
|
|
|
|
imageSrc: string;
|
|
|
|
|
hotspots: Hotspot[];
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const VIEWS: Record<ViewKey, View> = {
|
|
|
|
|
exterior: {
|
|
|
|
|
title: "P&H 4200 Exterior",
|
|
|
|
|
imageSrc: "https://picsum.photos/seed/1329000375/1200/800",
|
|
|
|
|
hotspots: [
|
|
|
|
|
{ id: 'enter', x: 50, y: 60, label: "Enter the Machine", target: 'cab', type: 'nav' }
|
|
|
|
|
]
|
|
|
|
|
},
|
|
|
|
|
cab: {
|
|
|
|
|
title: "Operator's Cab",
|
|
|
|
|
imageSrc: "https://picsum.photos/seed/1496099219/1200/800",
|
|
|
|
|
hotspots: [
|
|
|
|
|
{ id: 'controls', x: 40, y: 50, label: "Dual Joystick Controls", info: "Ergonomic control center with panoramic visibility.", type: 'info' },
|
|
|
|
|
{ id: 'display', x: 60, y: 45, label: "Diagnostic Displays", info: "Real-time operational data and payload monitoring.", type: 'info' }
|
|
|
|
|
]
|
|
|
|
|
},
|
|
|
|
|
machinery: {
|
|
|
|
|
title: "Machinery House",
|
|
|
|
|
imageSrc: "https://picsum.photos/seed/1110967395/1200/800",
|
|
|
|
|
hotspots: [
|
|
|
|
|
{ id: 'hoist', x: 30, y: 60, label: "Hoist Motors", info: "Massive hoist motors designed for continuous high-torque output.", type: 'info' },
|
|
|
|
|
{ id: 'swing', x: 70, y: 55, label: "Swing Transmissions", info: "Planetary gearboxes for smooth, rapid swing cycles.", type: 'info' }
|
|
|
|
|
]
|
|
|
|
|
},
|
|
|
|
|
electrical: {
|
|
|
|
|
title: "Electrical Room",
|
|
|
|
|
imageSrc: "https://picsum.photos/seed/1776604305/1200/800",
|
|
|
|
|
hotspots: [
|
|
|
|
|
{ id: 'ac', x: 50, y: 40, label: "AC Drive Systems", info: "Advanced IGBT AC drives for precise motor control.", type: 'info' },
|
|
|
|
|
{ id: 'panels', x: 20, y: 50, label: "Power Distribution", info: "Climate-controlled electronics bays.", type: 'info' }
|
|
|
|
|
]
|
|
|
|
|
},
|
|
|
|
|
boom: {
|
|
|
|
|
title: "Boom & Dipper",
|
|
|
|
|
imageSrc: "https://picsum.photos/seed/691466050/1200/800",
|
|
|
|
|
hotspots: [
|
|
|
|
|
{ id: 'dipper', x: 50, y: 70, label: "High-Capacity Dipper", info: "Engineered for maximum payload and optimal dig geometry.", type: 'info' },
|
|
|
|
|
{ id: 'crowd', x: 50, y: 30, label: "Crowd Machinery", info: "Rack and pinion crowd for positive digging force.", type: 'info' }
|
|
|
|
|
]
|
|
|
|
|
},
|
|
|
|
|
maintenance: {
|
|
|
|
|
title: "Maintenance Deck",
|
|
|
|
|
imageSrc: "https://picsum.photos/seed/429735176/1200/800",
|
|
|
|
|
hotspots: [
|
|
|
|
|
{ id: 'lube', x: 40, y: 60, label: "Auto-Lube System", info: "Centralized lubrication for all major pivot points.", type: 'info' },
|
|
|
|
|
{ id: 'access', x: 80, y: 50, label: "Safe Access", info: "Wide walkways and tie-off points for maintenance personnel.", type: 'info' }
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export default function MachineWalkthroughPage() {
|
|
|
|
|
const [currentView, setCurrentView] = useState<ViewKey>('exterior');
|
|
|
|
|
const [activeInfo, setActiveInfo] = useState<string | null>(null);
|
|
|
|
|
const [isTransitioning, setIsTransitioning] = useState(false);
|
|
|
|
|
|
|
|
|
|
const handleNavigate = (target: ViewKey) => {
|
|
|
|
|
setIsTransitioning(true);
|
|
|
|
|
setActiveInfo(null);
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
setCurrentView(target);
|
|
|
|
|
setIsTransitioning(false);
|
|
|
|
|
}, 800);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const view = VIEWS[currentView];
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
className="min-h-svh flex flex-col font-sans bg-background text-foreground"
|
|
|
|
|
style={{
|
|
|
|
|
'--background': '#09090b',
|
|
|
|
|
'--foreground': '#f4f4f5',
|
|
|
|
|
'--card': '#18181b',
|
|
|
|
|
'--primary-cta': '#18181b',
|
|
|
|
|
'--primary-cta-text': '#f97316',
|
|
|
|
|
'--secondary-cta': '#27272a',
|
|
|
|
|
'--secondary-cta-text': '#f4f4f5',
|
|
|
|
|
'--accent': '#f97316',
|
|
|
|
|
'--background-accent': '#c2410c',
|
|
|
|
|
} as React.CSSProperties}
|
|
|
|
|
>
|
|
|
|
|
<main className="relative flex-grow h-[calc(100vh-5rem)] mt-20 overflow-hidden bg-background">
|
|
|
|
|
<AnimatePresence mode="wait">
|
|
|
|
|
<motion.div
|
|
|
|
|
key={currentView}
|
|
|
|
|
initial={{ opacity: 0, scale: 1.1 }}
|
|
|
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
|
|
|
exit={{ opacity: 0, scale: 1.2 }}
|
|
|
|
|
transition={{ duration: 0.8, ease: "easeInOut" }}
|
|
|
|
|
className="absolute inset-0"
|
|
|
|
|
>
|
|
|
|
|
<img
|
|
|
|
|
src={view.imageSrc}
|
|
|
|
|
alt={view.title}
|
|
|
|
|
className="w-full h-full object-cover opacity-60"
|
|
|
|
|
/>
|
|
|
|
|
{currentView === 'exterior' && (
|
|
|
|
|
<motion.img
|
|
|
|
|
src={view.imageSrc}
|
|
|
|
|
alt={view.title}
|
|
|
|
|
initial={{ scale: 1 }}
|
|
|
|
|
animate={{ scale: 1.1 }}
|
|
|
|
|
transition={{ duration: 20, repeat: Infinity, repeatType: "reverse", ease: "linear" }}
|
|
|
|
|
className="absolute inset-0 w-full h-full object-cover opacity-50 mix-blend-overlay"
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</motion.div>
|
|
|
|
|
</AnimatePresence>
|
|
|
|
|
|
|
|
|
|
<div className="absolute inset-0 bg-gradient-to-t from-background via-background/40 to-transparent pointer-events-none" />
|
|
|
|
|
|
|
|
|
|
<div className="absolute inset-0 z-10">
|
|
|
|
|
<AnimatePresence>
|
|
|
|
|
{!isTransitioning && view.hotspots.map((hotspot) => (
|
|
|
|
|
<motion.div
|
|
|
|
|
key={hotspot.id}
|
|
|
|
|
initial={{ opacity: 0, y: 10 }}
|
|
|
|
|
animate={{ opacity: 1, y: 0 }}
|
|
|
|
|
exit={{ opacity: 0, scale: 0.9 }}
|
|
|
|
|
transition={{ delay: 0.5 }}
|
|
|
|
|
className="absolute -translate-x-1/2 -translate-y-1/2 flex flex-col items-center gap-2"
|
|
|
|
|
style={{ left: `${hotspot.x}%`, top: `${hotspot.y}%` }}
|
|
|
|
|
>
|
|
|
|
|
{hotspot.type === 'nav' ? (
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => handleNavigate(hotspot.target)}
|
|
|
|
|
className="group relative flex items-center justify-center w-16 h-16 rounded-full bg-accent/20 border-2 border-accent text-accent hover:bg-accent hover:text-background transition-all duration-300 shadow-lg shadow-accent/40 hover:shadow-xl hover:shadow-accent/60"
|
|
|
|
|
>
|
|
|
|
|
<MapPin className="w-6 h-6" />
|
|
|
|
|
<span className="absolute top-full mt-3 whitespace-nowrap text-sm font-bold tracking-wider uppercase text-accent group-hover:text-accent/80 drop-shadow-md">
|
|
|
|
|
{hotspot.label}
|
|
|
|
|
</span>
|
|
|
|
|
</button>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="relative">
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setActiveInfo(activeInfo === hotspot.id ? null : hotspot.id)}
|
|
|
|
|
className={cls(
|
|
|
|
|
"group relative flex items-center justify-center w-10 h-10 rounded-full border-2 transition-all duration-300",
|
|
|
|
|
activeInfo === hotspot.id
|
|
|
|
|
? "bg-accent border-accent text-background shadow-lg shadow-accent/60"
|
|
|
|
|
: "bg-card/80 border-accent/50 text-accent hover:border-accent hover:bg-accent/20"
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<Info className="w-5 h-5" />
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
<AnimatePresence>
|
|
|
|
|
{activeInfo === hotspot.id && (
|
|
|
|
|
<motion.div
|
|
|
|
|
initial={{ opacity: 0, y: 10, scale: 0.95 }}
|
|
|
|
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
|
|
|
|
exit={{ opacity: 0, y: 10, scale: 0.95 }}
|
|
|
|
|
className="absolute top-full left-1/2 -translate-x-1/2 mt-4 w-64 p-4 rounded-lg bg-card/95 border border-foreground/10 shadow-2xl backdrop-blur-sm"
|
|
|
|
|
>
|
|
|
|
|
<h4 className="text-accent font-bold mb-2">{hotspot.label}</h4>
|
|
|
|
|
<p className="text-sm text-foreground/80 leading-relaxed">{hotspot.info}</p>
|
|
|
|
|
</motion.div>
|
|
|
|
|
)}
|
|
|
|
|
</AnimatePresence>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</motion.div>
|
|
|
|
|
))}
|
|
|
|
|
</AnimatePresence>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<AnimatePresence>
|
|
|
|
|
{currentView !== 'exterior' && (
|
|
|
|
|
<motion.div
|
|
|
|
|
initial={{ opacity: 0, y: 20 }}
|
|
|
|
|
animate={{ opacity: 1, y: 0 }}
|
|
|
|
|
exit={{ opacity: 0, y: 20 }}
|
|
|
|
|
className="absolute bottom-8 left-1/2 -translate-x-1/2 z-20 flex flex-col items-center gap-6 w-full max-w-content-width px-4"
|
|
|
|
|
>
|
|
|
|
|
<div className="flex flex-wrap justify-center gap-2 p-2 rounded-theme bg-card/80 backdrop-blur-md border border-foreground/10 shadow-2xl">
|
|
|
|
|
{(Object.keys(VIEWS) as ViewKey[]).filter(k => k !== 'exterior').map((key) => (
|
|
|
|
|
<button
|
|
|
|
|
key={key}
|
|
|
|
|
onClick={() => handleNavigate(key)}
|
|
|
|
|
className={cls(
|
|
|
|
|
"px-4 py-2 rounded-xl text-sm font-medium transition-all duration-300",
|
|
|
|
|
currentView === key
|
|
|
|
|
? "bg-accent text-background shadow-md shadow-accent/40"
|
|
|
|
|
: "text-foreground/60 hover:text-accent hover:bg-background/50"
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
{VIEWS[key].title}
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => handleNavigate('exterior')}
|
|
|
|
|
className="flex items-center gap-2 px-6 py-3 rounded-full bg-card/80 border border-foreground/20 text-foreground/80 hover:text-foreground hover:border-foreground/50 transition-all backdrop-blur-md"
|
|
|
|
|
>
|
|
|
|
|
<ChevronLeft className="w-4 h-4" />
|
|
|
|
|
Back to Exterior
|
|
|
|
|
</button>
|
|
|
|
|
</motion.div>
|
|
|
|
|
)}
|
|
|
|
|
</AnimatePresence>
|
|
|
|
|
|
|
|
|
|
<div className="absolute top-8 left-8 z-20">
|
|
|
|
|
<motion.div
|
|
|
|
|
key={currentView}
|
|
|
|
|
initial={{ opacity: 0, x: -20 }}
|
|
|
|
|
animate={{ opacity: 1, x: 0 }}
|
|
|
|
|
className="flex flex-col gap-1"
|
|
|
|
|
>
|
|
|
|
|
<span className="text-accent text-sm font-bold tracking-widest uppercase">
|
|
|
|
|
{currentView === 'exterior' ? 'Virtual Tour' : 'Interior Compartment'}
|
|
|
|
|
</span>
|
|
|
|
|
<h1 className="text-4xl md:text-5xl font-bold text-white drop-shadow-lg">
|
|
|
|
|
{view.title}
|
|
|
|
|
</h1>
|
|
|
|
|
</motion.div>
|
|
|
|
|
</div>
|
|
|
|
|
</main>
|
|
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|