cb7458cded
- Create Breadcrumbs.tsx server component — semantic <nav> + <ol>/<li> with aria-current, ChevronRight separators, Apple-clean styling - Add breadcrumbs to news article hero overlay (reuses JSON-LD crumbs) - Add breadcrumbs to application detail hero (passed as prop to client component) - Refactor breadcrumb data into shared array for JSON-LD + visual nav Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1194 lines
63 KiB
TypeScript
1194 lines
63 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useEffect, useRef } from "react";
|
||
import ReactDOM from "react-dom";
|
||
import { motion, AnimatePresence } from "framer-motion";
|
||
import Link from "next/link";
|
||
import Image from "next/image";
|
||
import Script from "next/script";
|
||
import { ArrowLeft, CheckCircle2, Zap, LayoutDashboard, Cpu, PencilRuler, Factory, MapPin, ChevronDown, Play, FileText, Box, Loader2, Maximize, X, ChevronLeft, ChevronRight } from "lucide-react";
|
||
import BreathingField from "@/components/visuals/BreathingField";
|
||
import AutoPlayVideo from "@/components/AutoPlayVideo";
|
||
import Breadcrumbs from "@/components/seo/Breadcrumbs";
|
||
import type { BreadcrumbItem } from "@/components/seo/Breadcrumbs";
|
||
|
||
// 🔥 EL TRUCO DEFINITIVO PARA TYPESCRIPT Y WEB COMPONENTS 🔥
|
||
// Al asignar el string a una variable con 'as any', TypeScript deja de
|
||
// intentar validar las propiedades intrínsecas de JSX y compila sin chistar.
|
||
const ModelViewer = 'model-viewer' as any;
|
||
|
||
// ── LIGHTBOX CORREGIDO ──────────────────────────────────────────────────────
|
||
function ImageLightbox({ images, initialIndex, onClose }: {
|
||
images: string[];
|
||
initialIndex: number;
|
||
onClose: () => void;
|
||
}) {
|
||
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
||
const touchStartX = useRef<number | null>(null);
|
||
|
||
useEffect(() => {
|
||
const prev = document.body.style.overflow;
|
||
document.body.style.overflow = 'hidden';
|
||
return () => { document.body.style.overflow = prev; };
|
||
}, []);
|
||
|
||
const handleNext = (e?: React.MouseEvent | React.TouchEvent) => {
|
||
e?.stopPropagation();
|
||
setCurrentIndex((p) => (p + 1) % images.length);
|
||
};
|
||
const handlePrev = (e?: React.MouseEvent | React.TouchEvent) => {
|
||
e?.stopPropagation();
|
||
setCurrentIndex((p) => (p === 0 ? images.length - 1 : p - 1));
|
||
};
|
||
|
||
useEffect(() => {
|
||
const onKey = (e: KeyboardEvent) => {
|
||
if (e.key === 'Escape') onClose();
|
||
if (e.key === 'ArrowRight') setCurrentIndex((p) => (p + 1) % images.length);
|
||
if (e.key === 'ArrowLeft') setCurrentIndex((p) => (p === 0 ? images.length - 1 : p - 1));
|
||
};
|
||
window.addEventListener('keydown', onKey);
|
||
return () => window.removeEventListener('keydown', onKey);
|
||
}, [images.length, onClose]);
|
||
|
||
const onTouchStart = (e: React.TouchEvent) => {
|
||
touchStartX.current = e.touches[0].clientX;
|
||
};
|
||
const onTouchEnd = (e: React.TouchEvent) => {
|
||
if (touchStartX.current === null) return;
|
||
const delta = e.changedTouches[0].clientX - touchStartX.current;
|
||
if (Math.abs(delta) > 50) {
|
||
delta < 0 ? handleNext() : handlePrev();
|
||
}
|
||
touchStartX.current = null;
|
||
};
|
||
|
||
const [mounted, setMounted] = useState(false);
|
||
const portalElRef = useRef<HTMLDivElement | null>(null);
|
||
|
||
useEffect(() => {
|
||
const el = document.createElement('div');
|
||
el.id = 'lightbox-portal-' + Date.now();
|
||
el.style.cssText = [
|
||
'position:fixed',
|
||
'inset:0',
|
||
'z-index:99999',
|
||
'pointer-events:auto',
|
||
'touch-action:none',
|
||
'isolation:isolate',
|
||
].join(';');
|
||
document.documentElement.appendChild(el);
|
||
portalElRef.current = el;
|
||
setMounted(true);
|
||
|
||
return () => {
|
||
if (portalElRef.current && document.documentElement.contains(portalElRef.current)) {
|
||
document.documentElement.removeChild(portalElRef.current);
|
||
portalElRef.current = null;
|
||
}
|
||
};
|
||
}, []);
|
||
|
||
if (!mounted || !portalElRef.current) return null;
|
||
|
||
return ReactDOM.createPortal(
|
||
<motion.div
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
exit={{ opacity: 0 }}
|
||
transition={{ duration: 0.25 }}
|
||
style={{
|
||
position: 'fixed',
|
||
inset: 0,
|
||
zIndex: 99999,
|
||
touchAction: 'none',
|
||
WebkitOverflowScrolling: 'auto',
|
||
overscrollBehavior: 'contain',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
background: 'rgba(0,0,0,0.95)',
|
||
backdropFilter: 'blur(20px)',
|
||
WebkitBackdropFilter: 'blur(20px)',
|
||
}}
|
||
onClick={onClose}
|
||
onTouchStart={onTouchStart}
|
||
onTouchEnd={onTouchEnd}
|
||
>
|
||
<div className="flex items-center justify-between px-5 py-4 shrink-0" onClick={(e) => e.stopPropagation()}>
|
||
<span className="text-white/50 font-mono text-sm tracking-widest">
|
||
{images.length > 1 ? `${currentIndex + 1} / ${images.length}` : ''}
|
||
</span>
|
||
<button onClick={onClose} className="p-3 bg-white/10 hover:bg-white/20 active:bg-white/30 rounded-full text-white transition-colors touch-manipulation" style={{ minWidth: 44, minHeight: 44 }}>
|
||
<X size={22} />
|
||
</button>
|
||
</div>
|
||
|
||
<div className="flex-1 flex items-center justify-center px-4 md:px-20 relative min-h-0" onClick={(e) => e.stopPropagation()} style={{ touchAction: 'none' }}>
|
||
<AnimatePresence mode="wait">
|
||
<motion.img
|
||
key={currentIndex}
|
||
initial={{ opacity: 0, scale: 0.96 }}
|
||
animate={{ opacity: 1, scale: 1 }}
|
||
exit={{ opacity: 0, scale: 1.02 }}
|
||
transition={{ duration: 0.3, ease: 'easeOut' }}
|
||
src={images[currentIndex]}
|
||
alt={`Imagen ${currentIndex + 1}`}
|
||
style={{
|
||
maxWidth: '100%',
|
||
maxHeight: '100%',
|
||
width: 'auto',
|
||
height: 'auto',
|
||
objectFit: 'contain',
|
||
borderRadius: 12,
|
||
boxShadow: '0 0 80px rgba(0,0,0,0.6)',
|
||
display: 'block',
|
||
background: 'rgba(255,255,255,0.03)',
|
||
}}
|
||
/>
|
||
</AnimatePresence>
|
||
|
||
{images.length > 1 && (
|
||
<>
|
||
<button onClick={handlePrev} className="hidden md:flex absolute left-4 top-1/2 -translate-y-1/2 p-4 bg-white/10 hover:bg-white/20 rounded-full text-white transition-all hover:scale-110 backdrop-blur-md touch-manipulation"><ChevronLeft size={32} /></button>
|
||
<button onClick={handleNext} className="hidden md:flex absolute right-4 top-1/2 -translate-y-1/2 p-4 bg-white/10 hover:bg-white/20 rounded-full text-white transition-all hover:scale-110 backdrop-blur-md touch-manipulation"><ChevronRight size={32} /></button>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{images.length > 1 && (
|
||
<div className="flex md:hidden items-center justify-center gap-6 py-5 shrink-0" onClick={(e) => e.stopPropagation()}>
|
||
<button onClick={handlePrev} className="p-4 bg-white/10 hover:bg-white/20 rounded-full text-white backdrop-blur-md touch-manipulation" style={{ minWidth: 52, minHeight: 52 }}><ChevronLeft size={26} /></button>
|
||
<div className="flex gap-2">
|
||
{images.map((_, i) => (
|
||
<button key={i} onClick={(e) => { e.stopPropagation(); setCurrentIndex(i); }} className={`rounded-full transition-all touch-manipulation ${i === currentIndex ? 'w-5 h-2 bg-white' : 'w-2 h-2 bg-white/30'}`} />
|
||
))}
|
||
</div>
|
||
<button onClick={handleNext} className="p-4 bg-white/10 hover:bg-white/20 rounded-full text-white backdrop-blur-md touch-manipulation" style={{ minWidth: 52, minHeight: 52 }}><ChevronRight size={26} /></button>
|
||
</div>
|
||
)}
|
||
</motion.div>,
|
||
portalElRef.current
|
||
);
|
||
}
|
||
|
||
// ── INLINE LIGHTWEIGHT 3D VIEWER ────────────────────────────────────────────
|
||
function InlineModelViewer({ glbPath, alt }: { glbPath: string; alt?: string }) {
|
||
const [isActive, setIsActive] = useState(false);
|
||
const [isLoaded, setIsLoaded] = useState(false);
|
||
const viewerId = useRef(`imv-${Math.random().toString(36).slice(2)}`);
|
||
|
||
const usdzPath = glbPath.replace(/\.glb$/i, '.usdz');
|
||
|
||
useEffect(() => {
|
||
if (!isActive) return;
|
||
let attempts = 0;
|
||
const tryRead = () => {
|
||
const el = document.getElementById(viewerId.current) as any;
|
||
if (!el) { if (++attempts < 20) setTimeout(tryRead, 300); return; }
|
||
if ((el as any).modelIsVisible) { setIsLoaded(true); return; }
|
||
el.addEventListener('load', () => setIsLoaded(true), { once: true });
|
||
};
|
||
const t = setTimeout(tryRead, 300);
|
||
return () => clearTimeout(t);
|
||
}, [isActive]);
|
||
|
||
return (
|
||
<div className="w-full my-8">
|
||
<Script
|
||
type="module"
|
||
src="https://ajax.googleapis.com/ajax/libs/model-viewer/3.5.0/model-viewer.min.js"
|
||
strategy="afterInteractive"
|
||
/>
|
||
|
||
{!isActive ? (
|
||
<div
|
||
className="relative rounded-2xl border border-[#0066CC]/20 dark:border-[#00F0FF]/20 overflow-hidden cursor-pointer group"
|
||
onClick={() => setIsActive(true)}
|
||
style={{ background: 'linear-gradient(135deg, #f0f4f8 0%, #e8edf4 100%)' }}
|
||
>
|
||
<div className="absolute inset-0 hidden dark:block" style={{ background: 'linear-gradient(135deg, #0a0a0c 0%, #0d1a2e 50%, #0a0a0c 100%)' }} />
|
||
<div className="absolute inset-0 opacity-[0.04] dark:opacity-[0.08] pointer-events-none" style={{
|
||
backgroundImage: 'linear-gradient(rgba(0,102,204,0.5) 1px,transparent 1px),linear-gradient(90deg,rgba(0,102,204,0.5) 1px,transparent 1px)',
|
||
backgroundSize: '40px 40px',
|
||
}} />
|
||
|
||
<div className="relative z-10 p-6 md:p-8 flex flex-col sm:flex-row items-start sm:items-center gap-5">
|
||
<div className="flex-1 min-w-0">
|
||
<div className="inline-flex items-center gap-2 bg-[#0066CC]/10 dark:bg-[#0066CC]/20 border border-[#0066CC]/20 dark:border-[#0066CC]/30 rounded-full px-3 py-1 mb-3">
|
||
<span className="w-1.5 h-1.5 rounded-full bg-[#0066CC] dark:bg-[#4DA6FF] animate-pulse" />
|
||
<span className="text-[9px] font-bold uppercase tracking-[0.15em] text-[#0066CC] dark:text-[#4DA6FF]">3D Preview — AR Ready</span>
|
||
</div>
|
||
<h4 className="text-base md:text-lg font-medium text-[#1D1D1F] dark:text-white leading-tight">
|
||
{alt || "Interactive 3D Model"}
|
||
</h4>
|
||
<div className="flex flex-wrap gap-1.5 mt-3">
|
||
{['Drag to rotate', 'Pinch to zoom', 'AR on mobile'].map(cap => (
|
||
<span key={cap} className="text-[9px] text-[#86868B] dark:text-[#A1A1A6] bg-black/5 dark:bg-white/5 border border-black/5 dark:border-white/10 px-2 py-0.5 rounded-full">{cap}</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<button className="shrink-0 bg-[#0066CC] hover:bg-[#0055BB] text-white px-5 py-2.5 rounded-xl font-semibold text-sm transition-all shadow-md hover:shadow-lg flex items-center gap-2 group-hover:scale-[1.02] touch-manipulation">
|
||
<Box size={16} /> Launch 3D
|
||
</button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<motion.div
|
||
initial={{ opacity: 0, scale: 0.98 }}
|
||
animate={{ opacity: 1, scale: 1 }}
|
||
style={{
|
||
width: '100%',
|
||
background: '#080c12',
|
||
borderRadius: 16,
|
||
border: '1px solid rgba(0,102,204,0.2)',
|
||
boxShadow: '0 0 0 1px rgba(0,102,204,0.06), 0 12px 32px rgba(0,0,0,0.4)',
|
||
position: 'relative',
|
||
isolation: 'isolate',
|
||
}}
|
||
>
|
||
<div style={{
|
||
position: 'absolute', top: 10, left: 10, zIndex: 10, pointerEvents: 'none',
|
||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||
background: 'rgba(0,0,0,0.85)', border: `1px solid ${isLoaded ? 'rgba(74,222,128,0.3)' : 'rgba(59,130,246,0.3)'}`,
|
||
borderRadius: 8, padding: '5px 10px',
|
||
}}>
|
||
<span style={{ width: 6, height: 6, borderRadius: '50%', background: isLoaded ? '#4ade80' : '#3b82f6', boxShadow: isLoaded ? '0 0 6px rgba(74,222,128,0.8)' : '0 0 6px rgba(59,130,246,0.8)' }} />
|
||
<span style={{ fontSize: 9, fontWeight: 700, letterSpacing: '0.1em', textTransform: 'uppercase', fontFamily: 'monospace', color: isLoaded ? '#86efac' : 'rgba(255,255,255,0.5)' }}>
|
||
{isLoaded ? 'Ready' : 'Loading...'}
|
||
</span>
|
||
</div>
|
||
|
||
<button
|
||
onClick={() => { setIsActive(false); setIsLoaded(false); }}
|
||
style={{
|
||
position: 'absolute', top: 10, right: 10, zIndex: 10,
|
||
width: 32, height: 32, borderRadius: 8, border: '1px solid rgba(255,60,60,0.3)',
|
||
background: 'rgba(0,0,0,0.85)', color: 'rgba(255,120,120,0.7)',
|
||
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
WebkitTapHighlightColor: 'transparent', touchAction: 'manipulation',
|
||
}}
|
||
>
|
||
<X size={14} />
|
||
</button>
|
||
|
||
<ModelViewer
|
||
id={viewerId.current}
|
||
src={glbPath}
|
||
ios-src={usdzPath}
|
||
alt={alt || "3D Model"}
|
||
ar
|
||
ar-modes="quick-look scene-viewer webxr"
|
||
ar-scale="auto"
|
||
camera-controls
|
||
auto-rotate
|
||
auto-rotate-delay="800"
|
||
rotation-per-second="14deg"
|
||
shadow-intensity="1"
|
||
exposure="1.1"
|
||
environment-image="neutral"
|
||
style={{
|
||
display: 'block',
|
||
width: '100%',
|
||
height: '360px',
|
||
background: 'transparent',
|
||
borderRadius: 16,
|
||
['--poster-color' as any]: 'transparent',
|
||
['--progress-bar-color' as any]: '#3b82f6',
|
||
}}
|
||
>
|
||
<button slot="ar-button" style={{
|
||
position: 'absolute', bottom: 14, left: '50%', transform: 'translateX(-50%)',
|
||
display: 'flex', alignItems: 'center', gap: 8,
|
||
background: 'linear-gradient(135deg,#1d4ed8,#2563eb)', color: 'white',
|
||
border: 'none', borderRadius: 32, padding: '10px 20px',
|
||
fontWeight: 700, fontSize: 13, letterSpacing: '0.03em', cursor: 'pointer',
|
||
boxShadow: '0 0 20px rgba(37,99,235,0.5),0 4px 16px rgba(0,0,0,0.4)',
|
||
WebkitTapHighlightColor: 'transparent', touchAction: 'manipulation', zIndex: 5,
|
||
}}>
|
||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
|
||
</svg>
|
||
View in AR
|
||
</button>
|
||
|
||
<div slot="poster" style={{
|
||
position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column',
|
||
alignItems: 'center', justifyContent: 'center', gap: 12, background: 'transparent',
|
||
}}>
|
||
<div style={{
|
||
width: 36, height: 36, borderRadius: '50%', border: '3px solid rgba(59,130,246,0.15)',
|
||
borderTop: '3px solid #3b82f6', animation: 'mv-spin 1s linear infinite',
|
||
}} />
|
||
<span style={{ color: '#6b7280', fontSize: 10, fontFamily: 'monospace', letterSpacing: '0.15em', textTransform: 'uppercase' }}>
|
||
Loading...
|
||
</span>
|
||
<style>{`@keyframes mv-spin { to { transform: rotate(360deg); } }`}</style>
|
||
</div>
|
||
</ModelViewer>
|
||
</motion.div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── SUPER PARSER MARKDOWN MEJORADO v2 ───────────────────────────────────────
|
||
const renderMarkdown = (text: string, onImageClick: (url: string) => void) => {
|
||
if (!text) return null;
|
||
const lines = text.split('\n');
|
||
|
||
// 🔥 CORRECCIÓN TYPESCRIPT: Usamos React.ReactNode en lugar de JSX.Element
|
||
const elements: React.ReactNode[] = [];
|
||
let listItems: React.ReactNode[] = [];
|
||
let isOrderedList = false;
|
||
|
||
let inTable = false;
|
||
let tableHeaders: string[] = [];
|
||
let tableRows: string[][] = [];
|
||
|
||
const pushTable = () => {
|
||
if (inTable) {
|
||
elements.push(
|
||
<div key={`table-${elements.length}`} className="my-8 w-full overflow-x-auto pb-4 [scrollbar-width:none]">
|
||
<table className="w-full text-left border-collapse min-w-[600px] shadow-2xl rounded-2xl overflow-hidden border border-black/5 dark:border-white/5">
|
||
<thead>
|
||
<tr className="bg-[#1D1D1F] dark:bg-[#111]">
|
||
{tableHeaders.map((th, i) => (
|
||
<th key={i} className={`p-4 border-b border-black/10 dark:border-white/10 text-[10px] md:text-xs uppercase tracking-widest font-semibold ${i === tableHeaders.length - 1 ? 'text-[#0066CC] dark:text-[#00F0FF] bg-[#0066CC]/5 dark:bg-[#00F0FF]/5' : 'text-white'}`}>
|
||
{parseInline(th)}
|
||
</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody className="bg-white/50 dark:bg-black/40 backdrop-blur-md">
|
||
{tableRows.map((row, rIdx) => (
|
||
<tr key={rIdx} className="hover:bg-black/5 dark:hover:bg-white/[0.02] transition-colors group">
|
||
{row.map((cell, cIdx) => (
|
||
<td key={cIdx} className={`p-4 border-b border-black/5 dark:border-white/5 text-xs md:text-sm ${cIdx === 0 ? 'text-[#86868B] font-medium' : cIdx === row.length - 1 ? 'text-[#1D1D1F] dark:text-white font-semibold bg-[#0066CC]/5 dark:bg-[#00F0FF]/5 group-hover:bg-[#0066CC]/10 dark:group-hover:bg-[#00F0FF]/10 transition-colors' : 'text-[#1D1D1F]/80 dark:text-white/80'}`}>
|
||
{parseInline(cell)}
|
||
</td>
|
||
))}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
);
|
||
inTable = false;
|
||
tableHeaders = [];
|
||
tableRows = [];
|
||
}
|
||
};
|
||
|
||
const pushList = () => {
|
||
if (listItems.length > 0) {
|
||
elements.push(
|
||
isOrderedList ? (
|
||
<ol key={`ol-${elements.length}`} className="list-decimal ml-5 mb-5 text-[#86868B] dark:text-[#A1A1A6] space-y-1.5 text-sm md:text-base font-light marker:text-[#0066CC] dark:marker:text-[#00F0FF]">
|
||
{listItems}
|
||
</ol>
|
||
) : (
|
||
<ul key={`ul-${elements.length}`} className="list-disc ml-5 mb-5 text-[#86868B] dark:text-[#A1A1A6] space-y-1.5 text-sm md:text-base font-light marker:text-[#0066CC] dark:marker:text-[#00F0FF]">
|
||
{listItems}
|
||
</ul>
|
||
)
|
||
);
|
||
listItems = [];
|
||
}
|
||
};
|
||
|
||
const parseInline = (str: string) => {
|
||
const boldRegex = /\*\*(.*?)\*\*/g;
|
||
const italicRegex = /\*(.*?)\*/g;
|
||
|
||
let parts = str.split(boldRegex);
|
||
return parts.map((part, i) => {
|
||
if (i % 2 === 1) return <strong key={i} className="font-semibold text-[#1D1D1F] dark:text-white">{part}</strong>;
|
||
|
||
let subParts = part.split(italicRegex);
|
||
return subParts.map((subPart, j) => {
|
||
if (j % 2 === 1) return <em key={`${i}-${j}`} className="italic text-[#1D1D1F]/90 dark:text-white/90">{subPart}</em>;
|
||
return subPart;
|
||
});
|
||
});
|
||
};
|
||
|
||
lines.forEach((line, idx) => {
|
||
const trimmed = line.trim();
|
||
|
||
if (!trimmed) {
|
||
pushList(); pushTable();
|
||
return;
|
||
}
|
||
|
||
if (trimmed.startsWith('|') && trimmed.endsWith('|')) {
|
||
pushList();
|
||
const cells = trimmed.split('|').filter((_, i, arr) => i !== 0 && i !== arr.length - 1).map(c => c.trim());
|
||
|
||
if (!inTable) {
|
||
inTable = true;
|
||
tableHeaders = cells;
|
||
} else if (cells.every(c => c.match(/^[-:]+$/))) {
|
||
} else {
|
||
tableRows.push(cells);
|
||
}
|
||
return;
|
||
} else {
|
||
pushTable();
|
||
}
|
||
|
||
const imgMatch = trimmed.match(/^!\[(.*?)\]\((.*?)\)$/);
|
||
if (imgMatch) {
|
||
pushList(); pushTable();
|
||
const imageUrl = imgMatch[2];
|
||
elements.push(
|
||
<div
|
||
key={`img-${idx}`}
|
||
className="relative w-full my-8 rounded-2xl md:rounded-[2rem] overflow-hidden border border-black/10 dark:border-white/10 shadow-xl bg-[#F5F5F7] dark:bg-[#111] cursor-pointer group"
|
||
onClick={() => onImageClick(imageUrl)}
|
||
>
|
||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/30 transition-all z-10 flex items-center justify-center opacity-0 group-hover:opacity-100 backdrop-blur-sm">
|
||
<Maximize size={40} className="text-white drop-shadow-xl scale-90 group-hover:scale-100 transition-transform" />
|
||
</div>
|
||
<img src={imageUrl} alt={imgMatch[1]} className="w-full h-auto object-cover group-hover:scale-105 transition-transform duration-1000" loading="lazy" />
|
||
</div>
|
||
);
|
||
return;
|
||
}
|
||
|
||
const videoMatch = trimmed.match(/^\[VIDEO:(.*?)\]$/);
|
||
if (videoMatch) {
|
||
pushList(); pushTable();
|
||
const videoSrc = videoMatch[1].trim();
|
||
const isLocalMp4 = videoSrc.endsWith('.mp4');
|
||
elements.push(
|
||
<div key={`vid-${idx}`} className="relative w-full my-8 rounded-2xl md:rounded-[2rem] overflow-hidden border border-black/10 dark:border-white/10 shadow-xl bg-black">
|
||
<div className="absolute top-3 left-3 z-10 pointer-events-none">
|
||
<div className="inline-flex items-center gap-1.5 bg-black/70 backdrop-blur-sm border border-white/10 rounded-full px-2.5 py-1">
|
||
<Play size={10} className="text-white fill-white" />
|
||
<span className="text-[9px] font-bold uppercase tracking-widest text-white/80 font-mono">Video</span>
|
||
</div>
|
||
</div>
|
||
<div className="aspect-video">
|
||
{isLocalMp4 ? (
|
||
<AutoPlayVideo src={videoSrc} className="w-full h-full object-contain" />
|
||
) : (
|
||
<iframe src={videoSrc} className="w-full h-full" allowFullScreen title="Embedded video" />
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
return;
|
||
}
|
||
|
||
const model3dMatch = trimmed.match(/^\[3D:(.*?)\]$/);
|
||
if (model3dMatch) {
|
||
pushList(); pushTable();
|
||
const modelPath = model3dMatch[1].trim();
|
||
elements.push(
|
||
<InlineModelViewer key={`3d-${idx}`} glbPath={modelPath} alt="3D Technical Preview" />
|
||
);
|
||
return;
|
||
}
|
||
|
||
const h3Match = trimmed.match(/^###\s*(.*)/);
|
||
if (h3Match) {
|
||
pushList();
|
||
elements.push(<h3 key={idx} className="text-lg md:text-xl mt-6 mb-3 font-medium text-[#0066CC] dark:text-[#00F0FF]">{parseInline(h3Match[1])}</h3>);
|
||
return;
|
||
}
|
||
|
||
const h2Match = trimmed.match(/^##\s*(.*)/);
|
||
if (h2Match) {
|
||
pushList();
|
||
elements.push(<h2 key={idx} className="text-2xl md:text-3xl mt-8 mb-4 font-light text-[#1D1D1F] dark:text-white tracking-tight">{parseInline(h2Match[1])}</h2>);
|
||
return;
|
||
}
|
||
|
||
const h1Match = trimmed.match(/^#\s*(.*)/);
|
||
if (h1Match) {
|
||
pushList();
|
||
elements.push(<h1 key={idx} className="text-3xl md:text-4xl mt-10 mb-5 font-light text-[#1D1D1F] dark:text-white tracking-tight">{parseInline(h1Match[1])}</h1>);
|
||
return;
|
||
}
|
||
|
||
const quoteMatch = trimmed.match(/^>\s*(.*)/);
|
||
if (quoteMatch) {
|
||
pushList();
|
||
elements.push(<blockquote key={idx} className="border-l-4 border-[#0066CC] dark:border-[#00F0FF] pl-4 py-2 my-6 text-base md:text-lg font-light italic text-[#1D1D1F]/70 dark:text-[#A1A1A6] bg-[#0066CC]/5 dark:bg-[#00F0FF]/5 rounded-r-xl shadow-inner">{parseInline(quoteMatch[1])}</blockquote>);
|
||
return;
|
||
}
|
||
|
||
const ulMatch = trimmed.match(/^[-*]\s+(.*)/);
|
||
if (ulMatch) {
|
||
isOrderedList = false;
|
||
listItems.push(<li key={idx} className="leading-relaxed pl-1">{parseInline(ulMatch[1])}</li>);
|
||
return;
|
||
}
|
||
|
||
const olMatch = trimmed.match(/^\d+\.\s*(.*)/);
|
||
if (olMatch) {
|
||
isOrderedList = true;
|
||
listItems.push(<li key={idx} className="leading-relaxed pl-1">{parseInline(olMatch[1])}</li>);
|
||
return;
|
||
}
|
||
|
||
if (trimmed === '---' || trimmed === '***' || trimmed === '___') {
|
||
pushList();
|
||
elements.push(<hr key={idx} className="my-8 border-0 h-px bg-gradient-to-r from-transparent via-black/10 dark:via-white/10 to-transparent" />);
|
||
return;
|
||
}
|
||
|
||
pushList();
|
||
elements.push(
|
||
<p key={idx} className="text-[#86868B] dark:text-[#A1A1A6] font-light leading-relaxed mb-4 text-sm md:text-base">
|
||
{parseInline(trimmed)}
|
||
</p>
|
||
);
|
||
});
|
||
|
||
pushList();
|
||
pushTable();
|
||
|
||
return <>{elements}</>;
|
||
};
|
||
|
||
// ── VISOR 3D TÉCNICO PROFESIONAL ─────────────────────────────────────────────
|
||
function TechnicalViewer3D({ node, usdzPath }: { node: any; usdzPath: string }) {
|
||
const [isActive, setIsActive] = useState(false);
|
||
const [isLoaded, setIsLoaded] = useState(false);
|
||
const [autoRotate, setAutoRotate] = useState(true);
|
||
const [showHuman, setShowHuman] = useState(true);
|
||
const [showGrid, setShowGrid] = useState(true);
|
||
const [modelDims, setModelDims] = useState<{ x: number; y: number; z: number } | null>(null);
|
||
const viewerId = useRef(`mv-${node.id || Math.random().toString(36).slice(2)}`);
|
||
|
||
useEffect(() => {
|
||
if (!isActive) return;
|
||
let attempts = 0;
|
||
const tryRead = () => {
|
||
const el = document.getElementById(viewerId.current) as any;
|
||
if (!el) { if (++attempts < 20) setTimeout(tryRead, 200); return; }
|
||
const read = () => {
|
||
setIsLoaded(true);
|
||
try {
|
||
const bb = el.getBoundingBox?.();
|
||
if (bb?.min && bb?.max) {
|
||
setModelDims({
|
||
x: parseFloat(Math.abs(bb.max.x - bb.min.x).toFixed(2)),
|
||
y: parseFloat(Math.abs(bb.max.y - bb.min.y).toFixed(2)),
|
||
z: parseFloat(Math.abs(bb.max.z - bb.min.z).toFixed(2)),
|
||
});
|
||
}
|
||
} catch (_) {}
|
||
};
|
||
if ((el as any).modelIsVisible) { read(); return; }
|
||
el.addEventListener('load', read, { once: true });
|
||
};
|
||
const t = setTimeout(tryRead, 300);
|
||
return () => clearTimeout(t);
|
||
}, [isActive]);
|
||
|
||
const toggleRotate = () => {
|
||
const el = document.getElementById(viewerId.current) as any;
|
||
const next = !autoRotate;
|
||
setAutoRotate(next);
|
||
if (el) el.autoRotate = next;
|
||
};
|
||
|
||
let datasheetDims: { w?: string; h?: string; d?: string } = {};
|
||
try {
|
||
const dimsJson = JSON.parse(node.model3DDimsJson || '{}');
|
||
if (dimsJson.w && dimsJson.h && dimsJson.d) {
|
||
const factor = dimsJson.unit === 'm' ? 1000 : dimsJson.unit === 'cm' ? 10 : 1;
|
||
datasheetDims = {
|
||
w: String(Math.round(Number(dimsJson.w) * factor)),
|
||
h: String(Math.round(Number(dimsJson.h) * factor)),
|
||
d: String(Math.round(Number(dimsJson.d) * factor)),
|
||
};
|
||
} else {
|
||
const ds = JSON.parse(node.specificDatasheetJson || '{}');
|
||
(ds.specs || []).forEach((s: any) => {
|
||
const lbl = (s.label || '').toLowerCase();
|
||
if (lbl.includes('dimension') || lbl.includes('dim')) {
|
||
const m = (s.value || '').match(/(\d+)\s*[xX×]\s*(\d+)\s*[xX×]\s*(\d+)/);
|
||
if (m) datasheetDims = { w: m[1], h: m[2], d: m[3] };
|
||
}
|
||
});
|
||
}
|
||
} catch (_) {}
|
||
|
||
const dims = modelDims
|
||
? { w: (modelDims.x * 1000).toFixed(0), h: (modelDims.y * 1000).toFixed(0), d: (modelDims.z * 1000).toFixed(0) }
|
||
: datasheetDims;
|
||
const hasDims = !!(dims.w && dims.h && dims.d);
|
||
|
||
const VIEWER_HEIGHT_MOBILE = 420;
|
||
const VIEWER_HEIGHT_DESKTOP = 580;
|
||
|
||
return (
|
||
<div className="w-full space-y-4">
|
||
<Script
|
||
type="module"
|
||
src="https://ajax.googleapis.com/ajax/libs/model-viewer/3.5.0/model-viewer.min.js"
|
||
strategy="afterInteractive"
|
||
/>
|
||
|
||
{!isActive && (
|
||
<motion.div
|
||
initial={{ opacity: 0, y: 8 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
className="relative rounded-[2rem] border border-[#0066CC]/20 shadow-2xl"
|
||
style={{ background: 'linear-gradient(135deg, #0a0a0c 0%, #0d1a2e 50%, #0a0a0c 100%)', overflow: 'hidden' }}
|
||
>
|
||
<div className="absolute inset-0 opacity-10 pointer-events-none" style={{
|
||
backgroundImage: 'linear-gradient(rgba(0,102,204,0.4) 1px,transparent 1px),linear-gradient(90deg,rgba(0,102,204,0.4) 1px,transparent 1px)',
|
||
backgroundSize: '40px 40px',
|
||
}} />
|
||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_center,rgba(0,102,204,0.15)_0%,transparent_70%)] pointer-events-none" />
|
||
|
||
<div className="relative z-10 p-8 md:p-12 flex flex-col md:flex-row items-start md:items-center gap-8">
|
||
<div className="flex-1">
|
||
<div className="inline-flex items-center gap-2 bg-[#0066CC]/20 border border-[#0066CC]/30 rounded-full px-4 py-1.5 mb-5">
|
||
<span className="w-2 h-2 rounded-full bg-[#0066CC] animate-pulse" />
|
||
<span className="text-[10px] font-bold uppercase tracking-[0.2em] text-[#4DA6FF]">Digital Twin — AR Ready</span>
|
||
</div>
|
||
<h4 className="text-2xl md:text-3xl font-light text-white mb-3 tracking-tight">
|
||
{node.title}
|
||
<span className="block text-base font-normal text-[#86868B] mt-1">Interactive 3D Technical Model</span>
|
||
</h4>
|
||
{hasDims && (
|
||
<div className="flex flex-wrap gap-3 mt-5">
|
||
{[{label:'WIDTH',value:dims.w},{label:'HEIGHT',value:dims.h},{label:'DEPTH',value:dims.d}].map(d => (
|
||
<div key={d.label} className="bg-white/5 border border-white/10 rounded-xl px-4 py-2 text-center">
|
||
<span className="block text-[9px] text-[#86868B] uppercase tracking-widest mb-0.5">{d.label}</span>
|
||
<span className="block text-white font-mono text-lg leading-none">{d.value}</span>
|
||
<span className="block text-[10px] text-[#4DA6FF]">mm</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
<div className="flex flex-wrap gap-2 mt-5">
|
||
{['Drag to rotate','Pinch to zoom','AR on mobile'].map(cap => (
|
||
<span key={cap} className="text-[10px] text-[#A1A1A6] bg-white/5 border border-white/10 px-3 py-1 rounded-full">{cap}</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<button onClick={() => setIsActive(true)} className="shrink-0 group relative bg-[#0066CC] hover:bg-[#0055BB] text-white px-8 py-4 rounded-2xl font-semibold text-sm transition-all shadow-[0_0_30px_rgba(0,102,204,0.4)] hover:shadow-[0_0_50px_rgba(0,102,204,0.6)] flex items-center gap-3 touch-manipulation">
|
||
<Box size={20} /> Launch 3D Viewer
|
||
</button>
|
||
</div>
|
||
</motion.div>
|
||
)}
|
||
|
||
{isActive && (
|
||
<motion.div
|
||
initial={{ opacity: 0, scale: 0.98 }}
|
||
animate={{ opacity: 1, scale: 1 }}
|
||
style={{ width: '100%', background: '#080c12', borderRadius: 20, border: '1px solid rgba(0,102,204,0.25)', boxShadow: '0 0 0 1px rgba(0,102,204,0.08), 0 20px 40px rgba(0,0,0,0.5)', position: 'relative', isolation: 'isolate' }}
|
||
>
|
||
{showGrid && (
|
||
<div style={{ position: 'absolute', inset: 0, borderRadius: 20, opacity: 0.06, pointerEvents: 'none', zIndex: 1, backgroundImage: 'linear-gradient(rgba(0,102,204,0.8) 1px,transparent 1px),linear-gradient(90deg,rgba(0,102,204,0.8) 1px,transparent 1px)', backgroundSize: '50px 50px' }} />
|
||
)}
|
||
|
||
<div style={{ position: 'absolute', top: 12, left: 12, right: 12, zIndex: 10, pointerEvents: 'none', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 6, boxSizing: 'border-box' }}>
|
||
<div style={{ pointerEvents: 'auto', display: 'inline-flex', alignItems: 'center', gap: 7, background: 'rgba(0,0,0,0.9)', border: `1px solid ${isLoaded ? 'rgba(74,222,128,0.3)' : 'rgba(59,130,246,0.3)'}`, borderRadius: 10, padding: '7px 11px', flexShrink: 1, minWidth: 0 }}>
|
||
<span style={{ width: 7, height: 7, borderRadius: '50%', flexShrink: 0, background: isLoaded ? '#4ade80' : '#3b82f6', boxShadow: isLoaded ? '0 0 6px rgba(74,222,128,0.8)' : '0 0 6px rgba(59,130,246,0.8)' }} />
|
||
<span style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.1em', textTransform: 'uppercase', fontFamily: 'monospace', color: isLoaded ? '#86efac' : 'rgba(255,255,255,0.5)', whiteSpace: 'nowrap', overflow: 'hidden' }}>{isLoaded ? 'Model Ready' : 'Loading...'}</span>
|
||
</div>
|
||
|
||
<div style={{ pointerEvents: 'auto', display: 'flex', alignItems: 'center', gap: 5, flexShrink: 0 }}>
|
||
{[
|
||
{ active: autoRotate, onClick: toggleRotate, title: 'Rotation', icon: <svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></svg> },
|
||
{ active: showHuman, onClick: () => setShowHuman(v => !v), title: 'Scale ref', icon: <svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="5" r="2.2"/><path d="M12 8v7m-4-4 4 2 4-2M9 21l3-4 3 4"/></svg> },
|
||
{ active: showGrid, onClick: () => setShowGrid(v => !v), title: 'Grid', icon: <svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/></svg> },
|
||
].map(btn => (
|
||
<button key={btn.title} onClick={btn.onClick} title={btn.title} style={{ width: 38, height: 38, borderRadius: 10, flexShrink: 0, border: btn.active ? '1.5px solid rgba(59,130,246,0.7)' : '1.5px solid rgba(255,255,255,0.1)', background: btn.active ? 'rgba(59,130,246,0.22)' : 'rgba(0,0,0,0.88)', color: btn.active ? '#93c5fd' : 'rgba(255,255,255,0.45)', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', WebkitTapHighlightColor: 'transparent', touchAction: 'manipulation' }}>
|
||
{btn.icon}
|
||
</button>
|
||
))}
|
||
<button onClick={() => { setIsActive(false); setIsLoaded(false); setModelDims(null); }} title="Close" style={{ width: 38, height: 38, borderRadius: 10, flexShrink: 0, border: '1.5px solid rgba(255,60,60,0.3)', background: 'rgba(0,0,0,0.88)', color: 'rgba(255,120,120,0.7)', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', WebkitTapHighlightColor: 'transparent', touchAction: 'manipulation' }}>
|
||
<X size={17} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<ModelViewer
|
||
id={viewerId.current}
|
||
src={`/cases/${nodeToSlug(node.title)}/models/${node.model3DPath}`}
|
||
ios-src={`/cases/${nodeToSlug(node.title)}/models/${usdzPath}`}
|
||
alt={`3D Technical Model: ${node.title}`}
|
||
ar
|
||
ar-modes="quick-look scene-viewer webxr"
|
||
ar-scale="fixed"
|
||
camera-controls
|
||
auto-rotate
|
||
auto-rotate-delay="1000"
|
||
rotation-per-second="12deg"
|
||
shadow-intensity="1.5"
|
||
shadow-softness="0.8"
|
||
exposure="1.1"
|
||
environment-image="neutral"
|
||
style={{ display: 'block', width: '100%', height: `${VIEWER_HEIGHT_MOBILE}px`, background: 'transparent', borderRadius: 20, ['--poster-color' as any]: 'transparent', ['--progress-bar-color' as any]: '#3b82f6', ['--progress-mask' as any]: 'none' }}
|
||
>
|
||
{showHuman && (
|
||
<div slot="hotspot-human" data-position="1.8 0 0" data-normal="0 1 0" style={{ pointerEvents: 'none' }}>
|
||
<svg width="26" height="84" viewBox="0 0 26 84" fill="none" style={{ filter: 'drop-shadow(0 0 8px rgba(59,130,246,0.9))' }}>
|
||
<circle cx="13" cy="7" r="5.5" fill="rgba(59,130,246,0.9)" />
|
||
<rect x="9" y="13" width="8" height="26" rx="3" fill="rgba(59,130,246,0.9)" />
|
||
<rect x="1" y="15" width="8" height="18" rx="3" fill="rgba(59,130,246,0.6)" />
|
||
<rect x="17" y="15" width="8" height="18" rx="3" fill="rgba(59,130,246,0.6)" />
|
||
<rect x="7" y="39" width="6" height="26" rx="3" fill="rgba(59,130,246,0.9)" />
|
||
<rect x="13" y="39" width="6" height="26" rx="3" fill="rgba(59,130,246,0.9)" />
|
||
<text x="13" y="80" textAnchor="middle" fill="#93c5fd" fontSize="6.5" fontFamily="monospace" fontWeight="bold">1.75m</text>
|
||
</svg>
|
||
</div>
|
||
)}
|
||
|
||
<button slot="ar-button" style={{ position: 'absolute', bottom: 16, left: '50%', transform: 'translateX(-50%)', display: 'flex', alignItems: 'center', gap: 9, background: 'linear-gradient(135deg,#1d4ed8,#2563eb)', color: 'white', border: 'none', borderRadius: 40, padding: '13px 24px', fontWeight: 700, fontSize: 14, letterSpacing: '0.04em', cursor: 'pointer', whiteSpace: 'nowrap', boxShadow: '0 0 24px rgba(37,99,235,0.55),0 6px 20px rgba(0,0,0,0.45)', WebkitTapHighlightColor: 'transparent', touchAction: 'manipulation', zIndex: 5 }}>
|
||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg>
|
||
View in AR
|
||
</button>
|
||
|
||
<div slot="poster" style={{ position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 14, background: 'transparent' }}>
|
||
<div style={{ width: 40, height: 40, borderRadius: '50%', border: '3px solid rgba(59,130,246,0.15)', borderTop: '3px solid #3b82f6', animation: 'mv-spin 1s linear infinite' }} />
|
||
<span style={{ color: '#6b7280', fontSize: 11, fontFamily: 'monospace', letterSpacing: '0.15em', textTransform: 'uppercase' }}>Loading model...</span>
|
||
<style>{`@keyframes mv-spin { to { transform: rotate(360deg); } }`}</style>
|
||
</div>
|
||
</ModelViewer>
|
||
|
||
<div style={{ borderTop: '1px solid rgba(255,255,255,0.06)', padding: '12px 14px', display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 7, position: 'relative', zIndex: 2 }}>
|
||
{hasDims ? (
|
||
<>
|
||
<span style={{ fontSize: 9, color: '#6b7280', textTransform: 'uppercase', letterSpacing: '0.1em', fontWeight: 700 }}>Dims:</span>
|
||
{[{l:'W',v:dims.w},{l:'H',v:dims.h},{l:'D',v:dims.d}].map(d => (
|
||
<div key={d.l} style={{ display: 'flex', alignItems: 'baseline', gap: 3, background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.08)', borderRadius: 8, padding: '5px 9px' }}>
|
||
<span style={{ fontSize: 9, color: '#60a5fa', fontWeight: 700 }}>{d.l}</span>
|
||
<span style={{ fontSize: 13, color: 'white', fontFamily: 'monospace', fontWeight: 500 }}>{d.v}</span>
|
||
<span style={{ fontSize: 9, color: '#6b7280' }}>mm</span>
|
||
</div>
|
||
))}
|
||
{dims.w && dims.d && (
|
||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 3, background: 'rgba(37,99,235,0.1)', border: '1px solid rgba(37,99,235,0.2)', borderRadius: 8, padding: '5px 9px' }}>
|
||
<span style={{ fontSize: 9, color: '#60a5fa', fontWeight: 700 }}>Area</span>
|
||
<span style={{ fontSize: 13, color: '#93c5fd', fontFamily: 'monospace', fontWeight: 500 }}>{((Number(dims.w)/1000)*(Number(dims.d)/1000)).toFixed(2)}</span>
|
||
<span style={{ fontSize: 9, color: '#6b7280' }}>m²</span>
|
||
</div>
|
||
)}
|
||
</>
|
||
) : (
|
||
<span style={{ fontSize: 10, color: '#6b7280', fontFamily: 'monospace' }}>Add dimensions to datasheet JSON</span>
|
||
)}
|
||
</div>
|
||
</motion.div>
|
||
)}
|
||
|
||
{isActive && (
|
||
<div className="flex items-start gap-3 bg-[#0066CC]/5 border border-[#0066CC]/15 rounded-2xl p-4">
|
||
<div className="shrink-0 mt-0.5 text-[#0066CC]">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
|
||
</svg>
|
||
</div>
|
||
<div>
|
||
<p className="text-[11px] font-semibold text-[#0066CC] uppercase tracking-widest mb-0.5">AR Scale — Real Dimensions</p>
|
||
<p className="text-[11px] text-[#86868B] leading-relaxed">
|
||
In AR mode, the machine appears at its exact real-world scale (<code className="text-[#4DA6FF] bg-[#0066CC]/10 px-1 rounded">ar-scale="fixed"</code>).
|
||
Point your camera at a floor area of at least {hasDims ? `${(Number(dims.w||0)/1000).toFixed(1)}m × ${(Number(dims.d||0)/1000).toFixed(1)}m` : 'sufficient space'}.
|
||
Use this to verify the machine fits your installation site before ordering.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function nodeToSlug(title: string): string {
|
||
return title.toLowerCase().trim().replace(/[^\w\s-]/g, '').replace(/[\s_-]+/g, '-').replace(/^-+|-+$/g, '');
|
||
}
|
||
|
||
// ── SUBCOMPONENTE DE CASO DE ESTUDIO ──
|
||
function ExpandedCaseStudy({ node }: { node: any }) {
|
||
const [activeTab, setActiveTab] = useState<"overview" | "tech-media">("overview");
|
||
const nodeSlug = nodeToSlug(node.title);
|
||
|
||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||
const [lightboxImages, setLightboxImages] = useState<string[]>([]);
|
||
const [lightboxInitialIndex, setLightboxInitialIndex] = useState(0);
|
||
|
||
let specificDatasheet: any = {};
|
||
let gallery: string[] = [];
|
||
let videos: string[] = [];
|
||
let renders: string[] = [];
|
||
try {
|
||
const parsed = JSON.parse(node.specificDatasheetJson || "{}");
|
||
if (typeof parsed === 'object' && !Array.isArray(parsed)) specificDatasheet = parsed;
|
||
} catch(e){}
|
||
try { gallery = JSON.parse(node.galleryJson || "[]"); } catch(e){}
|
||
try { videos = JSON.parse(node.videosJson || "[]"); } catch(e){}
|
||
try { renders = JSON.parse(node.rendersJson || "[]"); } catch(e){}
|
||
|
||
const hasTechMedia = node.model3DPath || renders.length > 0;
|
||
const hasDatasheetData = specificDatasheet.model && specificDatasheet.specs && specificDatasheet.specs.length > 0;
|
||
|
||
const usdzPath = node.model3DPath ? node.model3DPath.replace(/\.glb$/i, '.usdz') : '';
|
||
|
||
const openLightbox = (clickedImageUrl: string) => {
|
||
const allImages: string[] = [];
|
||
if (node.projectOverview) {
|
||
const regex = /!\[.*?\]\((.*?)\)/g;
|
||
let match;
|
||
while ((match = regex.exec(node.projectOverview)) !== null) {
|
||
allImages.push(match[1]);
|
||
}
|
||
}
|
||
gallery.forEach(img => allImages.push(`/cases/${nodeSlug}/${img}`));
|
||
renders.forEach(ren => allImages.push(`/cases/${nodeSlug}/renders/${ren}`));
|
||
if (!allImages.includes(clickedImageUrl)) {
|
||
allImages.unshift(clickedImageUrl);
|
||
}
|
||
setLightboxImages(allImages);
|
||
setLightboxInitialIndex(allImages.indexOf(clickedImageUrl));
|
||
setLightboxOpen(true);
|
||
};
|
||
|
||
return (
|
||
<div className="flex flex-col bg-white/50 dark:bg-black/40 backdrop-blur-xl rounded-b-[2.5rem] border-t border-black/5 dark:border-white/5 relative">
|
||
|
||
<AnimatePresence>
|
||
{lightboxOpen && (
|
||
<ImageLightbox images={lightboxImages} initialIndex={lightboxInitialIndex} onClose={() => setLightboxOpen(false)} />
|
||
)}
|
||
</AnimatePresence>
|
||
|
||
<div className="flex gap-8 px-8 pt-6 border-b border-black/5 dark:border-white/5 overflow-x-auto [scrollbar-width:none]">
|
||
<button onClick={() => setActiveTab("overview")} className={`pb-4 text-xs uppercase tracking-widest font-semibold flex items-center gap-2 transition-all border-b-2 whitespace-nowrap ${activeTab === "overview" ? "text-[#0066CC] dark:text-[#00F0FF] border-[#0066CC] dark:border-[#00F0FF]" : "text-[#86868B] border-transparent hover:text-[#1D1D1F] dark:hover:text-white"}`}>
|
||
<FileText size={16}/> Project Overview
|
||
</button>
|
||
{hasTechMedia && (
|
||
<button onClick={() => setActiveTab("tech-media")} className={`pb-4 text-xs uppercase tracking-widest font-semibold flex items-center gap-2 transition-all border-b-2 whitespace-nowrap ${activeTab === "tech-media" ? "text-[#0066CC] dark:text-[#00F0FF] border-[#0066CC] dark:border-[#00F0FF]" : "text-[#86868B] border-transparent hover:text-[#1D1D1F] dark:hover:text-white"}`}>
|
||
<Box size={16}/> 3D & Technical Renders
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
<div className="p-4 md:p-12">
|
||
{activeTab === "overview" && (
|
||
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} className="flex flex-col gap-16">
|
||
|
||
{node.projectOverview && (
|
||
<div className="max-w-none">
|
||
{renderMarkdown(node.projectOverview, openLightbox)}
|
||
</div>
|
||
)}
|
||
|
||
{(videos.length > 0 || gallery.length > 0) && (
|
||
<div className="space-y-8">
|
||
<h5 className="text-lg font-medium text-[#1D1D1F] dark:text-white flex items-center gap-2 border-b border-black/5 dark:border-white/5 pb-4">
|
||
<Play size={18} className="text-[#0066CC] dark:text-[#00F0FF]"/> Installation Media
|
||
</h5>
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||
{videos.map((vid: string, idx: number) => {
|
||
const isLocalMp4 = vid.endsWith('.mp4');
|
||
const videoSrc = isLocalMp4 && !vid.startsWith('http') ? `/cases/${nodeSlug}/videos/${vid}` : vid;
|
||
return (
|
||
<div key={`v-${idx}`} className="relative w-full aspect-video rounded-2xl md:rounded-3xl overflow-hidden border border-black/10 dark:border-white/10 shadow-xl bg-black col-span-1 md:col-span-2">
|
||
{isLocalMp4 ? (
|
||
<AutoPlayVideo src={videoSrc} className="absolute inset-0 w-full h-full object-contain" />
|
||
) : (
|
||
<iframe src={videoSrc} className="absolute inset-0 w-full h-full" allowFullScreen title={`Video ${idx + 1}`} />
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
{gallery.map((img: string, idx: number) => {
|
||
const fullImgSrc = `/cases/${nodeSlug}/${img}`;
|
||
return (
|
||
<div key={`g-${idx}`} className="relative w-full aspect-video rounded-2xl md:rounded-3xl overflow-hidden border border-black/10 dark:border-white/10 shadow-lg group bg-white">
|
||
<Image src={fullImgSrc} alt="Installation" fill sizes="(max-width: 768px) 100vw, 50vw" className="object-cover group-hover:scale-105 transition-transform duration-700" />
|
||
<button
|
||
onClick={() => openLightbox(fullImgSrc)}
|
||
className="absolute inset-0 w-full h-full bg-black/0 hover:bg-black/30 active:bg-black/40 transition-all flex items-center justify-center opacity-0 group-hover:opacity-100 backdrop-blur-[0px] hover:backdrop-blur-sm touch-manipulation"
|
||
aria-label="Ver imagen completa"
|
||
>
|
||
<span className="bg-black/50 backdrop-blur-md rounded-full p-4 scale-90 group-hover:scale-100 transition-transform">
|
||
<Maximize size={28} className="text-white" />
|
||
</span>
|
||
</button>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{hasDatasheetData && (
|
||
<div className="w-full bg-[#1D1D1F] dark:bg-[#0A0A0C] text-[#F5F5F7] p-6 md:p-12 rounded-2xl md:rounded-[2.5rem] shadow-2xl relative overflow-hidden group">
|
||
<div className="absolute inset-0 bg-[linear-gradient(to_bottom,transparent_0%,rgba(0,102,204,0.05)_50%,transparent_100%)] animate-scan-slow pointer-events-none hidden group-hover:block"></div>
|
||
<div className="absolute top-0 right-0 p-12 opacity-10 pointer-events-none transition-opacity group-hover:opacity-20"><Cpu size={200} /></div>
|
||
|
||
<h3 className="text-sm font-semibold text-[#00F0FF] uppercase tracking-widest mb-3 transition-colors flex items-center gap-2">
|
||
<span className="w-2 h-2 bg-[#00F0FF] rounded-full animate-pulse"></span> Technical Datasheet
|
||
</h3>
|
||
<h4 className="text-2xl md:text-4xl font-light mb-10 font-mono tracking-tight text-white">{specificDatasheet.model}</h4>
|
||
|
||
<div className="grid grid-cols-2 md:grid-cols-4 gap-y-10 gap-x-6 relative z-10 font-mono text-sm">
|
||
{specificDatasheet.specs.map((spec: any, idx: number) => {
|
||
if (spec.highlight) {
|
||
return (
|
||
<div key={idx} className="col-span-2 p-4 bg-white/5 rounded-2xl border border-white/10 flex flex-col justify-center shadow-inner">
|
||
<span className="block text-[10px] text-[#86868B] uppercase tracking-widest mb-2 flex items-center gap-2">
|
||
<Zap size={12} className="text-[#00F0FF]"/> {spec.label}
|
||
</span>
|
||
<span className="block text-[#00F0FF] text-xl md:text-2xl font-bold drop-shadow-[0_0_12px_rgba(0,240,255,0.4)]">
|
||
{spec.value}
|
||
</span>
|
||
</div>
|
||
);
|
||
}
|
||
return (
|
||
<div key={idx} className="col-span-2 md:col-span-1">
|
||
<span className="block text-[10px] text-[#86868B] uppercase tracking-widest mb-2">{spec.label}</span>
|
||
<span className="block text-white text-sm">{spec.value}</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</motion.div>
|
||
)}
|
||
|
||
{activeTab === "tech-media" && (
|
||
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} className="space-y-12">
|
||
|
||
{node.model3DPath && (
|
||
<TechnicalViewer3D node={node} usdzPath={usdzPath} />
|
||
)}
|
||
|
||
{renders.length > 0 && (
|
||
<div>
|
||
<h5 className="text-lg font-medium text-[#1D1D1F] dark:text-white mb-6 flex items-center gap-2 border-b border-black/5 dark:border-white/5 pb-4">
|
||
<PencilRuler size={18} className="text-purple-500"/> Engineering Renders
|
||
</h5>
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||
{renders.map((ren: string, idx: number) => {
|
||
const fullRenSrc = `/cases/${nodeSlug}/renders/${ren}`;
|
||
return (
|
||
<div key={idx} className="relative w-full aspect-[4/3] rounded-2xl md:rounded-3xl overflow-hidden border border-black/10 dark:border-white/10 shadow-lg group bg-white dark:bg-[#1a1a1a]">
|
||
<Image src={fullRenSrc} alt="Technical Render" fill className="object-contain group-hover:scale-105 transition-transform duration-700" sizes="(max-width: 768px) 100vw, 50vw" />
|
||
<button
|
||
onClick={() => openLightbox(fullRenSrc)}
|
||
className="absolute inset-0 w-full h-full bg-black/0 hover:bg-black/20 active:bg-black/30 transition-all flex items-center justify-center opacity-0 group-hover:opacity-100 backdrop-blur-[0px] hover:backdrop-blur-sm touch-manipulation"
|
||
aria-label="Ver render completo"
|
||
>
|
||
<span className="bg-black/50 backdrop-blur-md rounded-full p-4 scale-90 group-hover:scale-100 transition-transform">
|
||
<Maximize size={28} className="text-white" />
|
||
</span>
|
||
</button>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</motion.div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// --- COMPONENTE PRINCIPAL ---
|
||
export default function ApplicationClient({ data, realCases, images, breadcrumbs }: { data: any, realCases: any[], images: any, breadcrumbs?: BreadcrumbItem[] }) {
|
||
const [expandedCase, setExpandedCase] = useState<string | null>(null);
|
||
|
||
const [mainLightboxOpen, setMainLightboxOpen] = useState(false);
|
||
const [mainLightboxImages, setMainLightboxImages] = useState<string[]>([]);
|
||
const [mainLightboxInitialIndex, setMainLightboxInitialIndex] = useState(0);
|
||
|
||
const sections = JSON.parse(data.sectionsJson || "[]");
|
||
const advantages = JSON.parse(data.advantagesJson || "[]");
|
||
const { heroImage, blueprints, machines } = images;
|
||
|
||
const openMainLightbox = (clickedImageUrl: string) => {
|
||
const allImages: string[] = [];
|
||
if (data.heroDescription) {
|
||
const regex = /!\[.*?\]\((.*?)\)/g;
|
||
let match;
|
||
while ((match = regex.exec(data.heroDescription)) !== null) {
|
||
allImages.push(match[1]);
|
||
}
|
||
}
|
||
if (!allImages.includes(clickedImageUrl)) {
|
||
allImages.unshift(clickedImageUrl);
|
||
}
|
||
setMainLightboxImages(allImages);
|
||
setMainLightboxInitialIndex(allImages.indexOf(clickedImageUrl));
|
||
setMainLightboxOpen(true);
|
||
};
|
||
|
||
return (
|
||
<main className="relative min-h-screen pb-24 bg-[#F5F5F7] dark:bg-[#050505] transition-colors duration-500">
|
||
<BreathingField />
|
||
|
||
<AnimatePresence>
|
||
{mainLightboxOpen && (
|
||
<ImageLightbox images={mainLightboxImages} initialIndex={mainLightboxInitialIndex} onClose={() => setMainLightboxOpen(false)} />
|
||
)}
|
||
</AnimatePresence>
|
||
|
||
<div className="fixed top-24 left-6 z-50 hidden md:block">
|
||
<Link href="/#applications-deep" className="inline-flex items-center gap-2 text-sm font-medium text-[#86868B] hover:text-[#0066CC] dark:hover:text-[#00F0FF] transition-colors py-2 px-4 bg-white/80 dark:bg-black/50 backdrop-blur-md rounded-full group border border-black/5 dark:border-white/10 shadow-lg">
|
||
<ArrowLeft size={16} className="group-hover:-translate-x-1 transition-transform" /> Back to Overview
|
||
</Link>
|
||
</div>
|
||
|
||
<section className="relative w-full h-[50vh] md:h-[70vh] flex items-end justify-start overflow-hidden">
|
||
{heroImage ? (
|
||
<Image src={heroImage} alt={data.title} fill sizes="100vw" className="object-cover object-center scale-105 animate-slow-zoom" priority />
|
||
) : (
|
||
<div className="absolute inset-0 bg-gradient-to-br from-[#0066CC]/20 to-black/80" />
|
||
)}
|
||
<div className="absolute inset-0 bg-gradient-to-t from-[#F5F5F7] via-[#F5F5F7]/80 dark:from-[#0A0A0C] dark:via-[#0A0A0C]/80 to-transparent transition-colors duration-500" />
|
||
|
||
<div className="relative z-10 max-w-4xl mx-auto px-6 pb-12 md:pb-16 w-full">
|
||
<header>
|
||
{breadcrumbs && <Breadcrumbs items={breadcrumbs} />}
|
||
<div className="inline-flex items-center gap-2 text-[#0066CC] dark:text-[#00F0FF] mb-3 bg-white/80 dark:bg-black/80 backdrop-blur-sm px-3 py-1.5 rounded-full border border-black/5 dark:border-white/10">
|
||
<LayoutDashboard size={14} />
|
||
<span className="text-[10px] md:text-xs font-semibold uppercase tracking-widest">{data.category}</span>
|
||
</div>
|
||
<h1 className="text-4xl md:text-6xl font-light text-[#1D1D1F] dark:text-[#F5F5F7] tracking-tighter mb-3 transition-colors drop-shadow-sm">
|
||
{data.title}
|
||
</h1>
|
||
<h2 className="text-lg md:text-2xl font-light text-[#1D1D1F]/80 dark:text-[#F5F5F7]/80 transition-colors max-w-2xl leading-normal">
|
||
{data.subtitle}
|
||
</h2>
|
||
</header>
|
||
</div>
|
||
</section>
|
||
|
||
<div className="max-w-4xl mx-auto px-6 relative z-10 mt-6">
|
||
|
||
<div className="mb-20">
|
||
<div className="max-w-none">
|
||
{renderMarkdown(data.heroDescription, openMainLightbox)}
|
||
</div>
|
||
</div>
|
||
|
||
{sections.length > 0 && (
|
||
<div className="space-y-12 mb-20">
|
||
{sections.map((section: any, idx: number) => (
|
||
<div key={idx} className="bg-white/60 dark:bg-[#1D1D1F]/40 backdrop-blur-2xl border border-black/5 dark:border-white/10 p-6 md:p-10 rounded-2xl md:rounded-[2.5rem] shadow-sm transition-colors relative overflow-hidden">
|
||
{section.isMainTech && <div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-br from-[#0066CC]/20 dark:from-[#00F0FF]/10 to-transparent blur-3xl -z-10" />}
|
||
<div className="mb-6 flex flex-col md:flex-row md:items-center gap-2 md:gap-4">
|
||
{section.isMainTech && (
|
||
<span className="w-fit text-[9px] md:text-[10px] bg-[#0066CC] dark:bg-[#00F0FF] text-white dark:text-black px-2.5 py-1 rounded-full uppercase tracking-widest font-bold shadow-sm whitespace-nowrap">
|
||
Main Tech
|
||
</span>
|
||
)}
|
||
<h3 className="text-xl md:text-2xl font-medium text-[#1D1D1F] dark:text-[#F5F5F7] transition-colors leading-tight">
|
||
{section.title}
|
||
</h3>
|
||
</div>
|
||
<ul className="space-y-4">
|
||
{section.items.map((item: any, i: number) => (
|
||
<li key={i} className="flex flex-col md:flex-row gap-1.5 md:gap-5 pb-4 border-b border-black/5 dark:border-white/5 last:border-0 last:pb-0 transition-colors group">
|
||
<span className="text-[#86868B] dark:text-[#A1A1A6] font-medium min-w-[180px] shrink-0 transition-colors group-hover:text-[#0066CC] dark:group-hover:text-[#00F0FF] text-sm">
|
||
{item.label}
|
||
</span>
|
||
<span className="text-[#1D1D1F] dark:text-[#F5F5F7] font-light leading-relaxed transition-colors whitespace-pre-line text-sm">
|
||
{item.content}
|
||
</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{advantages.length > 0 && (
|
||
<div className="mb-20">
|
||
<div className="flex items-center gap-3 mb-8">
|
||
<div className="p-2 rounded-xl bg-[#0066CC]/10 dark:bg-[#00F0FF]/10 text-[#0066CC] dark:text-[#00F0FF]"><Zap size={20} /></div>
|
||
<h3 className="text-2xl md:text-3xl font-light text-[#1D1D1F] dark:text-[#F5F5F7] transition-colors">The FLUX Advantage</h3>
|
||
</div>
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||
{advantages.map((adv: any, idx: number) => (
|
||
<div key={idx} className="bg-white/50 dark:bg-[#1D1D1F]/60 backdrop-blur-md border border-black/5 dark:border-white/10 p-6 rounded-2xl transition-all hover:shadow-md hover:-translate-y-1 group">
|
||
<CheckCircle2 size={20} className="text-[#0066CC] dark:text-[#00F0FF] mb-3 group-hover:scale-110 transition-transform" />
|
||
<h4 className="text-lg font-medium text-[#1D1D1F] dark:text-[#F5F5F7] mb-2 transition-colors">{adv.title}</h4>
|
||
<p className="text-sm text-[#86868B] dark:text-[#A1A1A6] font-light leading-relaxed transition-colors whitespace-pre-line">{adv.description}</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{realCases.length > 0 && (
|
||
<section className="relative z-10 px-6 max-w-6xl mx-auto pt-12 mt-12 border-t border-black/10 dark:border-white/10">
|
||
<div className="mb-10 border-b border-black/5 dark:border-white/5 pb-6 flex items-center justify-between">
|
||
<div>
|
||
<h3 className="text-3xl md:text-4xl font-light text-[#1D1D1F] dark:text-white flex items-center gap-3 transition-colors mb-2">
|
||
<Factory className="text-[#0066CC] dark:text-[#00F0FF]" size={30} /> Proven Solutions
|
||
</h3>
|
||
<p className="text-base text-[#86868B]">Real-world implementations of our customized technology.</p>
|
||
</div>
|
||
<div className="hidden md:flex items-center gap-2 text-xs font-semibold uppercase tracking-widest text-[#0066CC] dark:text-[#00F0FF] bg-[#0066CC]/10 dark:bg-[#00F0FF]/10 px-4 py-2.5 rounded-full border border-[#0066CC]/20 dark:border-[#00F0FF]/20">
|
||
<CheckCircle2 size={16} /> {realCases.length} Installations
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex flex-col gap-6">
|
||
{realCases.map((node) => {
|
||
const isExpanded = expandedCase === node.id;
|
||
return (
|
||
<div key={node.id} className="bg-white/80 dark:bg-[#111]/90 backdrop-blur-2xl border border-black/10 dark:border-white/10 rounded-2xl md:rounded-[2.5rem] overflow-hidden transition-all duration-500 shadow-xl">
|
||
<div onClick={() => setExpandedCase(isExpanded ? null : node.id)} className="p-5 md:p-8 cursor-pointer flex flex-col md:flex-row items-start md:items-center justify-between gap-5 group hover:bg-black/5 dark:hover:bg-white/[0.02] transition-colors">
|
||
<div className="flex items-center gap-5 flex-1">
|
||
<div className="w-16 h-16 md:w-20 md:h-20 rounded-2xl md:rounded-3xl bg-black/5 dark:bg-black/50 border border-black/10 dark:border-white/5 flex items-center justify-center shrink-0 overflow-hidden relative shadow-inner">
|
||
{node.mediaFileName ? (
|
||
<Image src={`/cases/${nodeToSlug(node.title)}/${node.mediaFileName}`} alt={node.title} fill sizes="100px" className="object-cover opacity-90 group-hover:scale-110 transition-transform duration-700" />
|
||
) : (
|
||
<Cpu size={28} className="text-[#0066CC]/50 dark:text-[#00F0FF]/50" />
|
||
)}
|
||
</div>
|
||
<div>
|
||
<h4 className="text-xl md:text-2xl font-medium text-[#1D1D1F] dark:text-white group-hover:text-[#0066CC] dark:group-hover:text-[#00F0FF] transition-colors tracking-tight">{node.title}</h4>
|
||
<span className="text-sm text-[#86868B] flex items-center gap-1.5 mt-1.5">
|
||
<MapPin size={14} /> {node.location}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-5 w-full md:w-auto">
|
||
{node.energySavings && (
|
||
<div className="bg-[#0066CC]/10 dark:bg-[#00F0FF]/10 border border-[#0066CC]/20 dark:border-[#00F0FF]/20 px-5 py-2.5 rounded-xl text-center flex-1 md:flex-none">
|
||
<span className="block text-[9px] uppercase tracking-widest text-[#0066CC] dark:text-[#00F0FF] font-semibold mb-0.5">Highlight</span>
|
||
<span className="block text-base font-medium text-[#1D1D1F] dark:text-white">{node.energySavings}</span>
|
||
</div>
|
||
)}
|
||
<button className={`w-12 h-12 rounded-full flex items-center justify-center shrink-0 transition-transform duration-500 shadow-md ${isExpanded ? 'bg-[#0066CC] dark:bg-[#00F0FF] text-white dark:text-black rotate-180' : 'bg-white dark:bg-white/10 text-[#1D1D1F] dark:text-white border border-black/10 dark:border-transparent group-hover:scale-105'}`}>
|
||
<ChevronDown size={22} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<AnimatePresence>
|
||
{isExpanded && (
|
||
<motion.div initial={{ height: 0, opacity: 0 }} animate={{ height: "auto", opacity: 1 }} exit={{ height: 0, opacity: 0 }} transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}>
|
||
<ExpandedCaseStudy node={node} />
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</section>
|
||
)}
|
||
</main>
|
||
);
|
||
} |