Files
flux-srl/src/app/[locale]/applications/[slug]/ApplicationClient.tsx
T
davidherran cb7458cded feat(seo): visual breadcrumb navigation on article + application pages
- 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>
2026-05-06 18:10:49 -05:00

1194 lines
63 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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=&quot;fixed&quot;</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>
);
}