This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
"use client";
|
||||
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { MapPin, Factory, Zap, Clock, ChevronDown, ArrowRight, Globe2, Image as ImageIcon, FileText, Play } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import Image from "next/image";
|
||||
|
||||
// ── Interface matches GlobalNode shape from Prisma ──
|
||||
interface CaseStudyData {
|
||||
found: boolean;
|
||||
id: string;
|
||||
title: string;
|
||||
location: string;
|
||||
application: string;
|
||||
industry: string;
|
||||
stats: string;
|
||||
energySavings: string | null;
|
||||
projectOverview: string | null;
|
||||
mediaFileName: string | null;
|
||||
gallery: string[];
|
||||
datasheet: { label: string; value: string }[];
|
||||
videos: string[];
|
||||
relevanceNote: string;
|
||||
}
|
||||
|
||||
function Metric({ icon: Icon, label, value }: { icon: typeof Zap; label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 bg-white/50 dark:bg-white/[0.04] border border-white/60 dark:border-white/[0.06] rounded-xl px-3 py-2 transition-colors">
|
||||
<Icon size={12} className="text-[#0066CC] dark:text-[#4DA6FF] shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<span className="text-[9px] text-[#86868B] dark:text-[#A1A1A6] uppercase tracking-wider block">{label}</span>
|
||||
<span className="text-[12px] font-medium text-[#1D1D1F] dark:text-[#F5F5F7] truncate block">{value}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ACCENTS: Record<string, { gradient: string; badge: string }> = {
|
||||
textile: { gradient: "from-indigo-500/30 to-blue-500/10", badge: "bg-indigo-50 dark:bg-indigo-500/10 text-indigo-700 dark:text-indigo-300 border-indigo-200 dark:border-indigo-500/20" },
|
||||
food: { gradient: "from-orange-500/30 to-amber-500/10", badge: "bg-orange-50 dark:bg-orange-500/10 text-orange-700 dark:text-orange-300 border-orange-200 dark:border-orange-500/20" },
|
||||
rubber: { gradient: "from-emerald-500/30 to-green-500/10", badge: "bg-emerald-50 dark:bg-emerald-500/10 text-emerald-700 dark:text-emerald-300 border-emerald-200 dark:border-emerald-500/20" },
|
||||
pharma: { gradient: "from-violet-500/30 to-purple-500/10", badge: "bg-violet-50 dark:bg-violet-500/10 text-violet-700 dark:text-violet-300 border-violet-200 dark:border-violet-500/20" },
|
||||
wood: { gradient: "from-amber-500/30 to-yellow-500/10", badge: "bg-amber-50 dark:bg-amber-500/10 text-amber-700 dark:text-amber-300 border-amber-200 dark:border-amber-500/20" },
|
||||
};
|
||||
|
||||
export default function CaseStudyViewer({ data }: { data: CaseStudyData }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [showGallery, setShowGallery] = useState(false);
|
||||
|
||||
if (!data.found) return null;
|
||||
|
||||
const accent = ACCENTS[data.industry] || ACCENTS.textile;
|
||||
const coverSrc = data.mediaFileName ? `/cases/${data.mediaFileName}` : null;
|
||||
const appLabel = data.application.replace(/-/g, " ").replace(/\b\w/g, c => c.toUpperCase());
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12, scale: 0.97 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="my-3 w-full max-w-[400px]"
|
||||
>
|
||||
<div className="bg-white/60 dark:bg-white/[0.04] backdrop-blur-2xl border border-white/70 dark:border-white/[0.06] shadow-[0_4px_24px_rgba(0,0,0,0.06)] dark:shadow-none rounded-2xl overflow-hidden transition-colors">
|
||||
|
||||
{/* Hero — Real cover image or gradient fallback */}
|
||||
<div className={`relative h-28 overflow-hidden ${!coverSrc ? `bg-gradient-to-br ${accent.gradient}` : ''}`}>
|
||||
{coverSrc ? (
|
||||
<>
|
||||
<Image src={coverSrc} alt={data.title} fill className="object-cover" sizes="400px" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/50 via-black/20 to-transparent" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/20 to-transparent" />
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Factory size={40} className="text-white/10" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Location overlay */}
|
||||
<div className="absolute bottom-3 left-3 flex items-center gap-1.5 z-10">
|
||||
<Globe2 size={12} className="text-white/80" />
|
||||
<span className="text-[10px] text-white/90 font-medium drop-shadow-md">{data.location}</span>
|
||||
</div>
|
||||
|
||||
{/* Application badge */}
|
||||
<div className="absolute top-3 right-3 bg-black/40 backdrop-blur-md text-white text-[9px] font-semibold px-2.5 py-1 rounded-full uppercase tracking-wider">
|
||||
{appLabel}
|
||||
</div>
|
||||
|
||||
{/* Media indicators */}
|
||||
<div className="absolute bottom-3 right-3 flex items-center gap-1.5 z-10">
|
||||
{data.gallery.length > 0 && (
|
||||
<div className="bg-black/40 backdrop-blur-md text-white text-[9px] px-2 py-0.5 rounded-full flex items-center gap-1">
|
||||
<ImageIcon size={9} /> {data.gallery.length}
|
||||
</div>
|
||||
)}
|
||||
{data.videos.length > 0 && (
|
||||
<div className="bg-black/40 backdrop-blur-md text-white text-[9px] px-2 py-0.5 rounded-full flex items-center gap-1">
|
||||
<Play size={9} /> {data.videos.length}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4">
|
||||
{/* Title */}
|
||||
<h3 className="text-[14px] font-semibold text-[#1D1D1F] dark:text-[#F5F5F7] leading-snug mb-2 transition-colors">
|
||||
{data.title}
|
||||
</h3>
|
||||
|
||||
{/* AI Relevance Note */}
|
||||
<div className="mb-3 bg-[#0066CC]/[0.04] dark:bg-[#4DA6FF]/[0.06] border border-[#0066CC]/10 dark:border-[#4DA6FF]/10 rounded-lg px-3 py-2 transition-colors">
|
||||
<span className="text-[10px] text-[#0066CC] dark:text-[#4DA6FF] leading-relaxed">
|
||||
{data.relevanceNote}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Key Metrics */}
|
||||
<div className="grid grid-cols-2 gap-2 mb-3">
|
||||
{data.energySavings && (
|
||||
<Metric icon={Zap} label="Energy Impact" value={data.energySavings} />
|
||||
)}
|
||||
<Metric icon={Clock} label="Performance" value={data.stats} />
|
||||
<Metric icon={MapPin} label="Region" value={data.location.split(",").pop()?.trim() || data.location} />
|
||||
{data.datasheet.length > 0 && (
|
||||
<Metric icon={FileText} label="Specs" value={`${data.datasheet.length} parameters`} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expandable Section */}
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="w-full flex items-center justify-between py-2 text-[11px] font-semibold text-[#0066CC] dark:text-[#4DA6FF] uppercase tracking-wider"
|
||||
>
|
||||
Project Details
|
||||
<ChevronDown size={14} className={`transition-transform duration-300 ${expanded ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{expanded && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
{/* Project Overview */}
|
||||
{data.projectOverview && (
|
||||
<p className="text-[12px] text-[#1D1D1F] dark:text-[#E5E5EA] leading-relaxed mb-3 transition-colors whitespace-pre-line">
|
||||
{data.projectOverview.length > 500
|
||||
? data.projectOverview.slice(0, 500) + "..."
|
||||
: data.projectOverview}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Equipment Datasheet (from specificDatasheetJson) */}
|
||||
{data.datasheet.length > 0 && (
|
||||
<div className="bg-white/40 dark:bg-white/[0.03] border border-white/60 dark:border-white/[0.06] rounded-xl p-3 mb-3 transition-colors">
|
||||
<span className="text-[9px] text-[#86868B] dark:text-[#A1A1A6] uppercase tracking-wider font-semibold block mb-2">
|
||||
Equipment Specifications
|
||||
</span>
|
||||
<div className="flex flex-col gap-1">
|
||||
{data.datasheet.slice(0, 6).map((spec, i) => (
|
||||
<div key={i} className="flex items-center justify-between py-1 border-b border-black/[0.03] dark:border-white/[0.04] last:border-0">
|
||||
<span className="text-[10px] text-[#86868B] dark:text-[#A1A1A6]">{spec.label}</span>
|
||||
<span className="text-[10px] font-medium text-[#1D1D1F] dark:text-[#F5F5F7]">{spec.value}</span>
|
||||
</div>
|
||||
))}
|
||||
{data.datasheet.length > 6 && (
|
||||
<span className="text-[9px] text-[#86868B] dark:text-[#A1A1A6] text-center pt-1">
|
||||
+{data.datasheet.length - 6} more specs in full view
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Gallery Preview */}
|
||||
{data.gallery.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<button
|
||||
onClick={() => setShowGallery(!showGallery)}
|
||||
className="text-[10px] text-[#0066CC] dark:text-[#4DA6FF] font-medium flex items-center gap-1 mb-2"
|
||||
>
|
||||
<ImageIcon size={10} /> {showGallery ? 'Hide' : 'Show'} Gallery ({data.gallery.length} images)
|
||||
</button>
|
||||
{showGallery && (
|
||||
<div className="grid grid-cols-3 gap-1.5 rounded-lg overflow-hidden">
|
||||
{data.gallery.slice(0, 6).map((img, i) => (
|
||||
<div key={i} className="relative aspect-square bg-[#F5F5F7] dark:bg-[#1D1D1F] rounded-md overflow-hidden">
|
||||
<Image src={`/cases/${img}`} alt={`Gallery ${i + 1}`} fill className="object-cover" sizes="120px" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* CTAs */}
|
||||
<div className="flex gap-2 mt-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
window.dispatchEvent(new CustomEvent("flux:open-case-study-modal", {
|
||||
detail: { nodeId: data.id },
|
||||
}));
|
||||
}}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 py-2.5 rounded-xl text-[11px] font-medium bg-[#1D1D1F] dark:bg-[#0066CC] text-white hover:bg-[#0066CC] dark:hover:bg-[#4DA6FF] transition-all shadow-sm"
|
||||
>
|
||||
Full Case Study <ArrowRight size={11} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
window.dispatchEvent(new CustomEvent("flux:navigate-to-case", {
|
||||
detail: { nodeId: data.id, location: data.location },
|
||||
}));
|
||||
}}
|
||||
className="flex items-center justify-center gap-1.5 px-3 py-2.5 rounded-xl text-[11px] font-medium bg-black/[0.04] dark:bg-white/[0.05] text-[#1D1D1F] dark:text-[#F5F5F7] hover:bg-black/[0.07] dark:hover:bg-white/[0.08] transition-all"
|
||||
>
|
||||
<Globe2 size={11} /> Globe
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
"use client";
|
||||
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Calendar, User, Building2, Mail, Phone, MessageSquare, ArrowRight, CheckCircle2, Sparkles, ChevronDown } from "lucide-react";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
|
||||
// ── Data from the AI tool execute ──
|
||||
interface ConsultationData {
|
||||
industry: string;
|
||||
industryLabel: string;
|
||||
process: string;
|
||||
conversationInsights: string[];
|
||||
estimatedSavingsPercent: number | null;
|
||||
productionVolume: string | null;
|
||||
suggestedTopics: string[];
|
||||
}
|
||||
|
||||
// ── Form state ──
|
||||
interface FormState {
|
||||
name: string;
|
||||
email: string;
|
||||
company: string;
|
||||
phone: string;
|
||||
preferredContact: "email" | "phone" | "video";
|
||||
message: string;
|
||||
timeframe: string;
|
||||
}
|
||||
|
||||
const INITIAL_FORM: FormState = {
|
||||
name: "",
|
||||
email: "",
|
||||
company: "",
|
||||
phone: "",
|
||||
preferredContact: "email",
|
||||
message: "",
|
||||
timeframe: "this-week",
|
||||
};
|
||||
|
||||
const TIMEFRAMES = [
|
||||
{ id: "asap", label: "As soon as possible" },
|
||||
{ id: "this-week", label: "This week" },
|
||||
{ id: "next-week", label: "Next week" },
|
||||
{ id: "this-month", label: "Within this month" },
|
||||
{ id: "just-info", label: "Just exploring for now" },
|
||||
];
|
||||
|
||||
const CONTACT_METHODS = [
|
||||
{ id: "email" as const, label: "Email", icon: Mail },
|
||||
{ id: "phone" as const, label: "Call", icon: Phone },
|
||||
{ id: "video" as const, label: "Video", icon: MessageSquare },
|
||||
];
|
||||
|
||||
// ── Context Card: Shows what the AI already knows ──
|
||||
function InsightsCard({ data }: { data: ConsultationData }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2, duration: 0.4 }}
|
||||
className="mb-4 bg-[#0066CC]/[0.06] dark:bg-[#4DA6FF]/[0.06] border border-[#0066CC]/10 dark:border-[#4DA6FF]/10 rounded-xl p-3.5 transition-colors"
|
||||
>
|
||||
<button onClick={() => setExpanded(!expanded)} className="w-full flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles size={13} className="text-[#0066CC] dark:text-[#4DA6FF]" />
|
||||
<span className="text-[11px] font-semibold text-[#0066CC] dark:text-[#4DA6FF] uppercase tracking-wider">AI-Prepared Brief</span>
|
||||
</div>
|
||||
<ChevronDown size={14} className={`text-[#0066CC] dark:text-[#4DA6FF] transition-transform ${expanded ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{expanded && (
|
||||
<motion.div initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: "auto" }} exit={{ opacity: 0, height: 0 }} className="overflow-hidden">
|
||||
<div className="mt-3 pt-3 border-t border-[#0066CC]/10 dark:border-[#4DA6FF]/10 flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] text-[#86868B] dark:text-[#A1A1A6] uppercase tracking-wider w-16 shrink-0">Industry</span>
|
||||
<span className="text-[12px] font-medium text-[#1D1D1F] dark:text-[#E5E5EA]">{data.industryLabel} — {data.process}</span>
|
||||
</div>
|
||||
{data.estimatedSavingsPercent && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] text-[#86868B] dark:text-[#A1A1A6] uppercase tracking-wider w-16 shrink-0">Savings</span>
|
||||
<span className="text-[12px] font-medium text-emerald-600 dark:text-emerald-400">~{data.estimatedSavingsPercent}% energy reduction estimated</span>
|
||||
</div>
|
||||
)}
|
||||
{data.productionVolume && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] text-[#86868B] dark:text-[#A1A1A6] uppercase tracking-wider w-16 shrink-0">Volume</span>
|
||||
<span className="text-[12px] text-[#1D1D1F] dark:text-[#E5E5EA]">{data.productionVolume}</span>
|
||||
</div>
|
||||
)}
|
||||
{data.conversationInsights.length > 0 && (
|
||||
<div className="mt-1">
|
||||
<span className="text-[10px] text-[#86868B] dark:text-[#A1A1A6] uppercase tracking-wider block mb-1.5">Key Discussion Points</span>
|
||||
<div className="flex flex-col gap-1">
|
||||
{data.conversationInsights.map((insight, i) => (
|
||||
<div key={i} className="flex items-start gap-1.5">
|
||||
<div className="w-1 h-1 rounded-full bg-[#0066CC] dark:bg-[#4DA6FF] mt-1.5 shrink-0" />
|
||||
<span className="text-[11px] text-[#1D1D1F] dark:text-[#E5E5EA] leading-relaxed">{insight}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{!expanded && (
|
||||
<p className="text-[10px] text-[#0066CC]/70 dark:text-[#4DA6FF]/70 mt-1.5">
|
||||
{data.industryLabel} · {data.process}
|
||||
{data.estimatedSavingsPercent ? ` · ~${data.estimatedSavingsPercent}% savings` : ""}
|
||||
{data.conversationInsights.length > 0 ? ` · ${data.conversationInsights.length} discussion points` : ""}
|
||||
</p>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Input Field Component ──
|
||||
function FormField({
|
||||
icon: Icon, label, placeholder, value, onChange, type = "text", required = false, delay = 0,
|
||||
}: {
|
||||
icon: typeof User; label: string; placeholder: string;
|
||||
value: string; onChange: (v: string) => void;
|
||||
type?: string; required?: boolean; delay?: number;
|
||||
}) {
|
||||
return (
|
||||
<motion.div initial={{ opacity: 0, y: 6 }} animate={{ opacity: 1, y: 0 }} transition={{ delay, duration: 0.3 }}>
|
||||
<label className="flex items-center gap-1.5 mb-1.5">
|
||||
<Icon size={11} className="text-[#86868B] dark:text-[#A1A1A6]" />
|
||||
<span className="text-[10px] text-[#86868B] dark:text-[#A1A1A6] uppercase tracking-wider font-semibold">
|
||||
{label}{required && <span className="text-[#0066CC] dark:text-[#4DA6FF] ml-0.5">*</span>}
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
required={required}
|
||||
className="w-full rounded-xl border-none outline-none text-[13px] px-3.5 py-2.5 bg-black/[0.04] dark:bg-white/[0.06] text-[#1D1D1F] dark:text-[#F5F5F7] placeholder:text-[#86868B]/50 dark:placeholder:text-[#A1A1A6]/40 focus:bg-black/[0.06] dark:focus:bg-white/[0.08] focus:ring-1 focus:ring-[#0066CC]/20 dark:focus:ring-[#4DA6FF]/20 transition-all duration-200"
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Success State (now shows ticket ID) ──
|
||||
function SuccessView({ data, ticketId }: { data: ConsultationData; ticketId: string | null }) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 25 }}
|
||||
className="flex flex-col items-center text-center py-6 gap-4"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 15, delay: 0.2 }}
|
||||
className="w-14 h-14 rounded-2xl bg-emerald-50 dark:bg-emerald-500/10 border border-emerald-200 dark:border-emerald-500/20 flex items-center justify-center"
|
||||
>
|
||||
<CheckCircle2 size={28} className="text-emerald-600 dark:text-emerald-400" />
|
||||
</motion.div>
|
||||
|
||||
<div>
|
||||
<p className="text-[15px] font-medium text-[#1D1D1F] dark:text-[#F5F5F7] mb-1 transition-colors">
|
||||
Consultation Requested
|
||||
</p>
|
||||
{ticketId && (
|
||||
<p className="text-[11px] font-mono text-[#0066CC] dark:text-[#4DA6FF] mb-2">{ticketId}</p>
|
||||
)}
|
||||
<p className="text-[12px] text-[#86868B] dark:text-[#A1A1A6] leading-relaxed max-w-[260px] transition-colors">
|
||||
Our {data.industryLabel.toLowerCase()} specialist will reach out within 24 hours with a personalized assessment.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
className="w-full bg-white/50 dark:bg-white/[0.04] rounded-xl p-3 border border-white/60 dark:border-white/[0.06] transition-colors"
|
||||
>
|
||||
<span className="text-[9px] text-[#86868B] dark:text-[#A1A1A6] uppercase tracking-wider font-semibold block mb-2">
|
||||
What happens next
|
||||
</span>
|
||||
<div className="flex flex-col gap-2">
|
||||
{[
|
||||
"Engineer reviews your AI-prepared brief",
|
||||
`Custom RF analysis for your ${data.process} process`,
|
||||
"Proposal with ROI projections and timeline",
|
||||
].map((step, i) => (
|
||||
<div key={i} className="flex items-start gap-2">
|
||||
<span className="text-[10px] text-[#0066CC] dark:text-[#4DA6FF] font-semibold mt-0.5 shrink-0">{i + 1}.</span>
|
||||
<span className="text-[11px] text-[#1D1D1F] dark:text-[#E5E5EA]">{step}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// MAIN COMPONENT
|
||||
// ═══════════════════════════════════════════
|
||||
export default function ConsultationScheduler({ data }: { data: ConsultationData }) {
|
||||
const [form, setForm] = useState<FormState>(INITIAL_FORM);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const [ticketId, setTicketId] = useState<string | null>(null);
|
||||
const formRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const update = (field: keyof FormState) => (value: string) =>
|
||||
setForm((prev) => ({ ...prev, [field]: value }));
|
||||
|
||||
const isValid = form.name.trim() && form.email.trim() && form.email.includes("@") && form.company.trim();
|
||||
|
||||
// 🔥 CONNECTED TO REAL API — saves to OperationsSignal + sends email via Resend
|
||||
const handleSubmit = async () => {
|
||||
if (!isValid || isSubmitting) return;
|
||||
setIsSubmitting(true);
|
||||
setSubmitError(null);
|
||||
|
||||
const payload = {
|
||||
contact: {
|
||||
name: form.name,
|
||||
email: form.email,
|
||||
company: form.company,
|
||||
phone: form.phone || null,
|
||||
preferredContact: form.preferredContact,
|
||||
message: form.message || null,
|
||||
timeframe: form.timeframe,
|
||||
},
|
||||
aiContext: {
|
||||
industry: data.industry,
|
||||
industryLabel: data.industryLabel,
|
||||
process: data.process,
|
||||
estimatedSavingsPercent: data.estimatedSavingsPercent,
|
||||
productionVolume: data.productionVolume,
|
||||
conversationInsights: data.conversationInsights,
|
||||
suggestedTopics: data.suggestedTopics,
|
||||
},
|
||||
meta: {
|
||||
source: "flux-ai-chat",
|
||||
timestamp: new Date().toISOString(),
|
||||
url: typeof window !== "undefined" ? window.location.href : "",
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/consultation", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const result = await res.json();
|
||||
|
||||
if (result.success) {
|
||||
setTicketId(result.ticketId);
|
||||
setSubmitted(true);
|
||||
|
||||
// Also dispatch the event for any external integrations
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("flux:consultation-submitted", { detail: { ...payload, ticketId: result.ticketId } })
|
||||
);
|
||||
} else {
|
||||
setSubmitError(result.error || "Something went wrong. Please try again.");
|
||||
}
|
||||
} catch (err) {
|
||||
setSubmitError("Network error. Please check your connection and try again.");
|
||||
}
|
||||
|
||||
setIsSubmitting(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12, scale: 0.97 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="my-3 w-full max-w-[400px]"
|
||||
>
|
||||
<div
|
||||
ref={formRef}
|
||||
className="bg-white/60 dark:bg-white/[0.04] backdrop-blur-2xl border border-white/70 dark:border-white/[0.06] shadow-[0_4px_24px_rgba(0,0,0,0.06)] dark:shadow-none rounded-2xl p-5 transition-colors"
|
||||
>
|
||||
<AnimatePresence mode="wait">
|
||||
{submitted ? (
|
||||
<SuccessView key="success" data={data} ticketId={ticketId} />
|
||||
) : (
|
||||
<motion.div key="form" exit={{ opacity: 0, scale: 0.95 }} transition={{ duration: 0.3 }}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="w-6 h-6 rounded-lg bg-[#0066CC]/10 dark:bg-[#4DA6FF]/15 flex items-center justify-center">
|
||||
<Calendar size={13} className="text-[#0066CC] dark:text-[#4DA6FF]" />
|
||||
</div>
|
||||
<span className="text-[9px] font-semibold uppercase tracking-widest text-[#0066CC] dark:text-[#4DA6FF]">
|
||||
Engineering Consultation
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[12px] text-[#86868B] dark:text-[#A1A1A6] mb-4 leading-relaxed">
|
||||
Your conversation details are pre-loaded. Just add your contact info.
|
||||
</p>
|
||||
|
||||
<InsightsCard data={data} />
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<FormField icon={User} label="Name" placeholder="Your full name"
|
||||
value={form.name} onChange={update("name")} required delay={0.3} />
|
||||
<FormField icon={Mail} label="Work Email" placeholder="name@company.com"
|
||||
value={form.email} onChange={update("email")} type="email" required delay={0.35} />
|
||||
<FormField icon={Building2} label="Company" placeholder="Your organization"
|
||||
value={form.company} onChange={update("company")} required delay={0.4} />
|
||||
<FormField icon={Phone} label="Phone (optional)" placeholder="+39 ..."
|
||||
value={form.phone} onChange={update("phone")} type="tel" delay={0.45} />
|
||||
|
||||
{/* Preferred Contact Method */}
|
||||
<motion.div initial={{ opacity: 0, y: 6 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.5 }}>
|
||||
<span className="text-[10px] text-[#86868B] dark:text-[#A1A1A6] uppercase tracking-wider font-semibold block mb-1.5">Preferred Contact Method</span>
|
||||
<div className="flex gap-2">
|
||||
{CONTACT_METHODS.map((m) => {
|
||||
const active = form.preferredContact === m.id;
|
||||
return (
|
||||
<button key={m.id} type="button" onClick={() => setForm((prev) => ({ ...prev, preferredContact: m.id }))}
|
||||
className={`flex-1 flex items-center justify-center gap-1.5 py-2 rounded-xl text-[11px] font-medium transition-all border ${
|
||||
active
|
||||
? "bg-[#0066CC]/8 dark:bg-[#4DA6FF]/10 border-[#0066CC]/20 dark:border-[#4DA6FF]/20 text-[#0066CC] dark:text-[#4DA6FF]"
|
||||
: "bg-black/[0.02] dark:bg-white/[0.03] border-transparent text-[#86868B] dark:text-[#A1A1A6] hover:bg-black/[0.04] dark:hover:bg-white/[0.05]"
|
||||
}`}>
|
||||
<m.icon size={12} /> {m.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Timeframe */}
|
||||
<motion.div initial={{ opacity: 0, y: 6 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.55 }}>
|
||||
<span className="text-[10px] text-[#86868B] dark:text-[#A1A1A6] uppercase tracking-wider font-semibold block mb-1.5">When do you need this?</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{TIMEFRAMES.map((t) => {
|
||||
const active = form.timeframe === t.id;
|
||||
return (
|
||||
<button key={t.id} type="button" onClick={() => setForm((prev) => ({ ...prev, timeframe: t.id }))}
|
||||
className={`px-2.5 py-1.5 rounded-lg text-[10px] font-medium transition-all border ${
|
||||
active
|
||||
? "bg-[#1D1D1F] dark:bg-white text-white dark:text-[#1D1D1F] border-[#1D1D1F] dark:border-white shadow-sm"
|
||||
: "bg-white/40 dark:bg-white/[0.04] border-black/[0.04] dark:border-white/[0.06] text-[#86868B] dark:text-[#A1A1A6] hover:text-[#1D1D1F] dark:hover:text-white"
|
||||
}`}>
|
||||
{t.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Message */}
|
||||
<motion.div initial={{ opacity: 0, y: 6 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.6 }}>
|
||||
<label className="flex items-center gap-1.5 mb-1.5">
|
||||
<MessageSquare size={11} className="text-[#86868B] dark:text-[#A1A1A6]" />
|
||||
<span className="text-[10px] text-[#86868B] dark:text-[#A1A1A6] uppercase tracking-wider font-semibold">Additional notes (optional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={form.message}
|
||||
onChange={(e) => update("message")(e.target.value)}
|
||||
placeholder="Any specific requirements or questions..."
|
||||
rows={2}
|
||||
className="w-full rounded-xl border-none outline-none text-[13px] px-3.5 py-2.5 bg-black/[0.04] dark:bg-white/[0.06] text-[#1D1D1F] dark:text-[#F5F5F7] placeholder:text-[#86868B]/50 dark:placeholder:text-[#A1A1A6]/40 focus:bg-black/[0.06] dark:focus:bg-white/[0.08] focus:ring-1 focus:ring-[#0066CC]/20 dark:focus:ring-[#4DA6FF]/20 transition-all duration-200 resize-none [scrollbar-width:none]"
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{submitError && (
|
||||
<motion.p initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-[11px] text-red-500 dark:text-red-400 text-center mt-3 bg-red-500/10 py-2 px-3 rounded-lg">
|
||||
{submitError}
|
||||
</motion.p>
|
||||
)}
|
||||
|
||||
{/* Submit */}
|
||||
<motion.button
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.7 }}
|
||||
onClick={handleSubmit}
|
||||
disabled={!isValid || isSubmitting}
|
||||
className={`w-full mt-4 py-3 rounded-xl text-[13px] font-medium flex items-center justify-center gap-2 transition-all duration-300 ${
|
||||
isValid
|
||||
? "bg-[#1D1D1F] dark:bg-[#0066CC] text-white hover:bg-[#0066CC] dark:hover:bg-[#4DA6FF] shadow-md cursor-pointer"
|
||||
: "bg-black/10 dark:bg-white/5 text-[#86868B] dark:text-[#A1A1A6]/50 cursor-not-allowed"
|
||||
}`}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<motion.div animate={{ rotate: 360 }} transition={{ duration: 1, repeat: Infinity, ease: "linear" }} className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full" />
|
||||
Sending...
|
||||
</>
|
||||
) : (
|
||||
<>Request Consultation <ArrowRight size={14} /></>
|
||||
)}
|
||||
</motion.button>
|
||||
|
||||
<p className="text-[9px] text-[#86868B] dark:text-[#A1A1A6]/60 text-center mt-2.5 leading-relaxed">
|
||||
Your data will be processed by FLUX Srl, Romano d'Ezzelino, Italy.
|
||||
<br />We respond within 24 business hours.
|
||||
</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { Zap, TrendingDown } from "lucide-react";
|
||||
|
||||
interface EfficiencyCardProps {
|
||||
industry: string;
|
||||
estimatedSavingsPercent: number;
|
||||
}
|
||||
|
||||
export default function EfficiencyCard({ industry, estimatedSavingsPercent }: EfficiencyCardProps) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
className="my-4 bg-white/60 backdrop-blur-md border border-white/80 shadow-sm rounded-2xl p-5 w-full max-w-sm"
|
||||
>
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<span className="text-[10px] font-semibold uppercase tracking-widest text-[#0066CC]">
|
||||
AI Analysis • {industry}
|
||||
</span>
|
||||
<h4 className="text-[#1D1D1F] font-medium text-lg">Solid-State ROI</h4>
|
||||
</div>
|
||||
<div className="p-2 bg-[#0066CC]/10 rounded-full text-[#0066CC]">
|
||||
<Zap size={16} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end gap-3 mb-4">
|
||||
<span className="text-4xl font-light tracking-tighter text-[#1D1D1F]">
|
||||
-{estimatedSavingsPercent}%
|
||||
</span>
|
||||
<span className="text-sm text-[#86868B] mb-1 font-medium flex items-center gap-1">
|
||||
<TrendingDown size={14} /> Energy Usage
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-[#86868B] leading-relaxed">
|
||||
Compared to legacy vacuum tube generators, FLUX solid-state technology ensures 95%+ power transfer efficiency directly to the product mass.
|
||||
</p>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { Zap, TrendingDown, Leaf, Clock, Factory, ArrowRight } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
interface CalculatorData {
|
||||
industry: string;
|
||||
process: string;
|
||||
productionVolumeKgPerHour: number;
|
||||
operatingHoursPerDay: number;
|
||||
traditionalMethod: string;
|
||||
traditionalKwhPerKg: number;
|
||||
rfKwhPerKg: number;
|
||||
savingsPercent: number;
|
||||
annualKwhTraditional: number;
|
||||
annualKwhRF: number;
|
||||
annualSavingsKwh: number;
|
||||
annualCO2SavedTonnes: number;
|
||||
annualCostSavingsEur: number;
|
||||
paybackMonths: number;
|
||||
rfEfficiency: number;
|
||||
}
|
||||
|
||||
function formatNumber(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
|
||||
return n.toLocaleString();
|
||||
}
|
||||
|
||||
function industryLabel(id: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
textile: "Textile", food: "Food Processing", rubber: "Rubber & Latex",
|
||||
pharma: "Pharma & Cosmetics", wood: "Wood Treatment", other: "Industrial",
|
||||
};
|
||||
return labels[id] || id;
|
||||
}
|
||||
|
||||
function SavingsGauge({ percent }: { percent: number }) {
|
||||
const r = 54, c = 2 * Math.PI * r;
|
||||
const offset = c - (percent / 100) * c;
|
||||
return (
|
||||
<div className="relative w-[130px] h-[130px] flex items-center justify-center shrink-0">
|
||||
<svg width="130" height="130" viewBox="0 0 140 140" className="transform -rotate-90">
|
||||
<circle cx="70" cy="70" r={r} fill="none" stroke="currentColor" strokeWidth="10" className="text-black/5 dark:text-white/5" />
|
||||
<motion.circle cx="70" cy="70" r={r} fill="none" stroke="url(#gGauge)" strokeWidth="10" strokeLinecap="round"
|
||||
strokeDasharray={c} initial={{ strokeDashoffset: c }} animate={{ strokeDashoffset: offset }}
|
||||
transition={{ duration: 1.8, ease: [0.16, 1, 0.3, 1], delay: 0.3 }}
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient id="gGauge" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor="#0066CC" /><stop offset="100%" stopColor="#00AAFF" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<motion.span className="text-3xl font-light tracking-tighter text-[#1D1D1F] dark:text-[#F5F5F7]"
|
||||
initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.8 }}>
|
||||
{percent}%
|
||||
</motion.span>
|
||||
<span className="text-[9px] text-[#86868B] dark:text-[#A1A1A6] font-semibold uppercase tracking-wider">savings</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ComparisonBar({ label, value, maxValue, color, delay }: {
|
||||
label: string; value: string; maxValue: number; color: string; delay: number;
|
||||
}) {
|
||||
const w = (parseFloat(value) / maxValue) * 100;
|
||||
return (
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="text-[10px] text-[#86868B] dark:text-[#A1A1A6] w-[55px] text-right font-medium shrink-0">{label}</span>
|
||||
<div className="flex-1 h-[6px] bg-black/5 dark:bg-white/5 rounded-full overflow-hidden">
|
||||
<motion.div className="h-full rounded-full" style={{ backgroundColor: color }}
|
||||
initial={{ width: 0 }} animate={{ width: `${Math.min(w, 100)}%` }}
|
||||
transition={{ duration: 1.2, ease: [0.16, 1, 0.3, 1], delay }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[10px] font-semibold text-[#1D1D1F] dark:text-[#E5E5EA] w-[60px] shrink-0">{value} kWh/kg</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ icon: Icon, label, value, unit, delay }: {
|
||||
icon: typeof Zap; label: string; value: string; unit: string; delay: number;
|
||||
}) {
|
||||
return (
|
||||
<motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} transition={{ delay, duration: 0.5 }}
|
||||
className="flex flex-col gap-1 p-3 bg-white/50 dark:bg-white/[0.04] rounded-xl border border-white/60 dark:border-white/[0.06] transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Icon size={12} className="text-[#0066CC] dark:text-[#4DA6FF]" />
|
||||
<span className="text-[9px] text-[#86868B] dark:text-[#A1A1A6] uppercase tracking-wider font-semibold">{label}</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-lg font-light text-[#1D1D1F] dark:text-[#F5F5F7]">{value}</span>
|
||||
<span className="text-[10px] text-[#86868B] dark:text-[#A1A1A6]">{unit}</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function EnergySavingsCalculator({ data }: { data: CalculatorData }) {
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
return (
|
||||
<motion.div initial={{ opacity: 0, y: 12, scale: 0.97 }} animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1] }} className="my-3 w-full max-w-[400px]"
|
||||
>
|
||||
<div className="bg-white/60 dark:bg-white/[0.04] backdrop-blur-2xl border border-white/70 dark:border-white/[0.06] shadow-[0_4px_24px_rgba(0,0,0,0.06)] dark:shadow-none rounded-2xl p-5 transition-colors">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 rounded-lg bg-[#0066CC]/10 dark:bg-[#4DA6FF]/15 flex items-center justify-center">
|
||||
<Zap size={13} className="text-[#0066CC] dark:text-[#4DA6FF]" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[9px] font-semibold uppercase tracking-widest text-[#0066CC] dark:text-[#4DA6FF]">FluxAI Analysis</span>
|
||||
<p className="text-[11px] text-[#86868B] dark:text-[#A1A1A6]">{industryLabel(data.industry)} — {data.process}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-2 py-0.5 bg-emerald-50 dark:bg-emerald-500/10 border border-emerald-200 dark:border-emerald-500/20 rounded-full">
|
||||
<span className="text-[10px] font-semibold text-emerald-700 dark:text-emerald-400">-{data.savingsPercent}% energy</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Gauge + Bars */}
|
||||
<div className="flex items-center gap-4 mb-5">
|
||||
<SavingsGauge percent={data.savingsPercent} />
|
||||
<div className="flex-1 flex flex-col gap-3">
|
||||
<ComparisonBar label={data.traditionalMethod.length > 8 ? "Legacy" : data.traditionalMethod}
|
||||
value={data.traditionalKwhPerKg.toFixed(2)}
|
||||
maxValue={Math.max(data.traditionalKwhPerKg, data.rfKwhPerKg) * 1.1}
|
||||
color="#9CA3AF" delay={0.5} />
|
||||
<ComparisonBar label="FLUX RF" value={data.rfKwhPerKg.toFixed(2)}
|
||||
maxValue={Math.max(data.traditionalKwhPerKg, data.rfKwhPerKg) * 1.1}
|
||||
color="#0066CC" delay={0.7} />
|
||||
<p className="text-[10px] text-[#86868B] dark:text-[#A1A1A6] leading-tight">
|
||||
{data.rfEfficiency}% power transfer at 27.12 MHz solid-state
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-2 gap-2 mb-4">
|
||||
<StatCard icon={TrendingDown} label="Annual Savings" value={`€${formatNumber(data.annualCostSavingsEur)}`} unit="/year" delay={0.9} />
|
||||
<StatCard icon={Leaf} label="CO2 Reduced" value={`${data.annualCO2SavedTonnes}`} unit="t/year" delay={1.0} />
|
||||
<StatCard icon={Clock} label="Payback" value={`${data.paybackMonths}`} unit="months" delay={1.1} />
|
||||
<StatCard icon={Factory} label="kWh Saved" value={formatNumber(data.annualSavingsKwh)} unit="/year" delay={1.2} />
|
||||
</div>
|
||||
|
||||
{/* Details toggle */}
|
||||
<button onClick={() => setShowDetails(!showDetails)}
|
||||
className="text-[11px] text-[#0066CC] dark:text-[#4DA6FF] font-medium flex items-center gap-1 hover:gap-2 transition-all"
|
||||
>
|
||||
{showDetails ? "Hide assumptions" : "View calculation details"}
|
||||
<ArrowRight size={11} className={`transition-transform ${showDetails ? "rotate-90" : ""}`} />
|
||||
</button>
|
||||
|
||||
{showDetails && (
|
||||
<motion.div initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: "auto" }}
|
||||
className="mt-3 pt-3 border-t border-black/5 dark:border-white/5 text-[11px] text-[#86868B] dark:text-[#A1A1A6] space-y-1"
|
||||
>
|
||||
<p>Production: {data.productionVolumeKgPerHour} kg/h × {data.operatingHoursPerDay}h/day × 300 days/year</p>
|
||||
<p>Traditional: {formatNumber(data.annualKwhTraditional)} kWh/year @ {data.traditionalKwhPerKg} kWh/kg</p>
|
||||
<p>FLUX RF: {formatNumber(data.annualKwhRF)} kWh/year @ {data.rfKwhPerKg} kWh/kg</p>
|
||||
<p>Electricity cost: €0.15/kWh (EU avg) · CO2: 0.4 kg/kWh</p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* CTA */}
|
||||
<motion.button initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 1.5 }}
|
||||
className="w-full mt-4 py-2.5 bg-[#1D1D1F] dark:bg-[#0066CC] text-white text-xs font-medium rounded-xl hover:bg-[#0066CC] dark:hover:bg-[#4DA6FF] transition-colors flex items-center justify-center gap-2 shadow-sm"
|
||||
onClick={() => window.dispatchEvent(new CustomEvent("flux:request-consultation", {
|
||||
detail: { source: "energy-calculator", industry: data.industry, savings: data.savingsPercent }
|
||||
}))}
|
||||
>
|
||||
Request Detailed Engineering Study <ArrowRight size={13} />
|
||||
</motion.button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
"use client";
|
||||
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Cpu, ArrowRight, Settings2, ChevronDown, MapPin, Factory, Zap, Box } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import Image from "next/image";
|
||||
|
||||
// ── Interface matches GlobalNode + specificDatasheetJson from Prisma ──
|
||||
interface EquipmentData {
|
||||
found: boolean;
|
||||
id: string;
|
||||
title: string; // Reference installation name
|
||||
location: string; // Where this machine runs
|
||||
application: string;
|
||||
industry: string;
|
||||
stats: string;
|
||||
mediaFileName: string | null;
|
||||
model3DPath: string | null;
|
||||
dimensions: { w: number; h: number; d: number; unit: string; weight?: string } | null;
|
||||
datasheet: { label: string; value: string }[]; // Real specs from specificDatasheetJson
|
||||
whyThisModel: string; // AI-generated
|
||||
sizingNote: string; // AI-generated
|
||||
alternativeNote: string | null; // AI-generated
|
||||
}
|
||||
|
||||
function SpecRow({ label, value, highlight }: { label: string; value: string; highlight?: boolean }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-2 border-b border-black/[0.03] dark:border-white/[0.04] last:border-0 transition-colors">
|
||||
<span className="text-[10px] text-[#86868B] dark:text-[#A1A1A6] uppercase tracking-wider">{label}</span>
|
||||
<span className={`text-[11px] font-medium transition-colors ${
|
||||
highlight ? 'text-[#0066CC] dark:text-[#4DA6FF]' : 'text-[#1D1D1F] dark:text-[#F5F5F7]'
|
||||
}`}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function EquipmentConfigurator({ data }: { data: EquipmentData }) {
|
||||
const [showAllSpecs, setShowAllSpecs] = useState(false);
|
||||
|
||||
if (!data.found) return null;
|
||||
|
||||
const coverSrc = data.mediaFileName ? `/cases/${data.mediaFileName}` : null;
|
||||
const appLabel = data.application.replace(/-/g, " ").replace(/\b\w/g, c => c.toUpperCase());
|
||||
|
||||
// Find key specs for header pills (power, frequency, model — from datasheet)
|
||||
const findSpec = (keywords: string[]) => {
|
||||
return data.datasheet.find(s =>
|
||||
keywords.some(kw => s.label.toLowerCase().includes(kw))
|
||||
)?.value;
|
||||
};
|
||||
|
||||
const powerSpec = findSpec(['power', 'potencia', 'kw', 'watt']);
|
||||
const freqSpec = findSpec(['frequen', 'frecuencia', 'mhz']);
|
||||
const modelSpec = findSpec(['model', 'modelo', 'type', 'tipo', 'series']);
|
||||
|
||||
// Split datasheet into primary (first 4) and extended
|
||||
const primarySpecs = data.datasheet.slice(0, 4);
|
||||
const extendedSpecs = data.datasheet.slice(4);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12, scale: 0.97 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="my-3 w-full max-w-[400px]"
|
||||
>
|
||||
<div className="bg-white/60 dark:bg-white/[0.04] backdrop-blur-2xl border border-white/70 dark:border-white/[0.06] shadow-[0_4px_24px_rgba(0,0,0,0.06)] dark:shadow-none rounded-2xl overflow-hidden transition-colors">
|
||||
|
||||
{/* Header */}
|
||||
<div className="relative bg-gradient-to-br from-[#0066CC]/8 to-[#0066CC]/3 dark:from-[#4DA6FF]/10 dark:to-[#4DA6FF]/3 px-4 pt-4 pb-3 border-b border-black/[0.04] dark:border-white/[0.06] transition-colors">
|
||||
|
||||
{/* Cover image thumbnail + identity */}
|
||||
<div className="flex items-start gap-3 mb-3">
|
||||
{/* Thumbnail */}
|
||||
<div className="w-14 h-14 rounded-xl bg-black/5 dark:bg-white/5 border border-black/5 dark:border-white/10 overflow-hidden shrink-0 relative">
|
||||
{coverSrc ? (
|
||||
<Image src={coverSrc} alt={data.title} fill className="object-cover" sizes="56px" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Cpu size={20} className="text-[#0066CC]/30 dark:text-[#4DA6FF]/30" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Identity */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<Cpu size={11} className="text-[#0066CC] dark:text-[#4DA6FF] shrink-0" />
|
||||
<span className="text-[9px] font-semibold uppercase tracking-widest text-[#0066CC] dark:text-[#4DA6FF]">
|
||||
Real Installation Specs
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-[14px] font-semibold text-[#1D1D1F] dark:text-[#F5F5F7] leading-snug transition-colors truncate">
|
||||
{modelSpec || appLabel}
|
||||
</h3>
|
||||
<div className="flex items-center gap-1 mt-0.5">
|
||||
<MapPin size={9} className="text-[#86868B] dark:text-[#A1A1A6]" />
|
||||
<span className="text-[10px] text-[#86868B] dark:text-[#A1A1A6] truncate">
|
||||
Installed at {data.title}, {data.location}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick spec pills */}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{powerSpec && (
|
||||
<span className="text-[10px] font-medium px-2 py-0.5 rounded-md bg-[#1D1D1F] dark:bg-white text-white dark:text-[#1D1D1F]">
|
||||
{powerSpec}
|
||||
</span>
|
||||
)}
|
||||
{freqSpec && (
|
||||
<span className="text-[10px] font-medium px-2 py-0.5 rounded-md bg-black/[0.06] dark:bg-white/[0.08] text-[#1D1D1F] dark:text-[#F5F5F7] transition-colors">
|
||||
{freqSpec}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[10px] font-medium px-2 py-0.5 rounded-md bg-black/[0.06] dark:bg-white/[0.08] text-[#1D1D1F] dark:text-[#F5F5F7] transition-colors">
|
||||
{appLabel}
|
||||
</span>
|
||||
{data.model3DPath && (
|
||||
<span className="text-[10px] font-medium px-2 py-0.5 rounded-md bg-[#0066CC]/10 dark:bg-[#4DA6FF]/10 text-[#0066CC] dark:text-[#4DA6FF] flex items-center gap-0.5">
|
||||
<Box size={9} /> 3D
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-4">
|
||||
{/* AI Recommendation */}
|
||||
<div className="mb-3 bg-[#0066CC]/[0.04] dark:bg-[#4DA6FF]/[0.06] border border-[#0066CC]/10 dark:border-[#4DA6FF]/10 rounded-xl px-3 py-2.5 transition-colors">
|
||||
<span className="text-[9px] font-semibold text-[#0066CC] dark:text-[#4DA6FF] uppercase tracking-wider block mb-1">
|
||||
Why This Configuration
|
||||
</span>
|
||||
<p className="text-[11px] text-[#1D1D1F] dark:text-[#E5E5EA] leading-relaxed transition-colors">
|
||||
{data.whyThisModel}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Sizing Note */}
|
||||
<div className="mb-3 bg-emerald-50 dark:bg-emerald-500/[0.06] border border-emerald-200 dark:border-emerald-500/15 rounded-xl px-3 py-2.5 transition-colors">
|
||||
<span className="text-[9px] font-semibold text-emerald-700 dark:text-emerald-400 uppercase tracking-wider block mb-1">
|
||||
Sizing Guidance
|
||||
</span>
|
||||
<p className="text-[11px] text-emerald-800 dark:text-emerald-200/80 leading-relaxed">
|
||||
{data.sizingNote}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Primary Specs (always visible) */}
|
||||
{primarySpecs.length > 0 && (
|
||||
<div className="bg-white/40 dark:bg-white/[0.02] border border-white/60 dark:border-white/[0.06] rounded-xl p-3 mb-3 transition-colors">
|
||||
<span className="text-[9px] text-[#86868B] dark:text-[#A1A1A6] uppercase tracking-wider font-semibold block mb-1">
|
||||
Key Specifications
|
||||
</span>
|
||||
{primarySpecs.map((spec, i) => (
|
||||
<SpecRow key={i} label={spec.label} value={spec.value} highlight={i === 0} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Extended Specs (expandable) */}
|
||||
{extendedSpecs.length > 0 && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setShowAllSpecs(!showAllSpecs)}
|
||||
className="w-full flex items-center justify-between py-2 text-[11px] font-semibold text-[#0066CC] dark:text-[#4DA6FF] uppercase tracking-wider"
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Settings2 size={12} />
|
||||
All Specifications ({data.datasheet.length})
|
||||
</span>
|
||||
<ChevronDown size={14} className={`transition-transform duration-300 ${showAllSpecs ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{showAllSpecs && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="bg-white/40 dark:bg-white/[0.02] border border-white/60 dark:border-white/[0.06] rounded-xl p-3 mb-3 transition-colors">
|
||||
{extendedSpecs.map((spec, i) => (
|
||||
<SpecRow key={i} label={spec.label} value={spec.value} />
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Physical Dimensions (from model3DDimsJson) */}
|
||||
{data.dimensions && (
|
||||
<div className="flex items-center gap-2 mb-3 px-3 py-2 bg-black/[0.02] dark:bg-white/[0.02] rounded-lg border border-black/[0.03] dark:border-white/[0.04] transition-colors">
|
||||
<Box size={12} className="text-[#86868B] dark:text-[#A1A1A6] shrink-0" />
|
||||
<span className="text-[10px] text-[#86868B] dark:text-[#A1A1A6]">
|
||||
{data.dimensions.w} × {data.dimensions.h} × {data.dimensions.d} {data.dimensions.unit}
|
||||
{data.dimensions.weight && ` · ${data.dimensions.weight}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Alternative Note */}
|
||||
{data.alternativeNote && (
|
||||
<div className="mb-3 bg-black/[0.02] dark:bg-white/[0.02] border border-black/[0.04] dark:border-white/[0.04] rounded-xl px-3 py-2 transition-colors">
|
||||
<span className="text-[9px] text-[#86868B] dark:text-[#A1A1A6] uppercase tracking-wider font-semibold block mb-1">
|
||||
Scaling Options
|
||||
</span>
|
||||
<p className="text-[11px] text-[#1D1D1F] dark:text-[#E5E5EA] transition-colors">
|
||||
{data.alternativeNote}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CTA */}
|
||||
<button
|
||||
onClick={() => {
|
||||
window.dispatchEvent(new CustomEvent("flux:request-consultation", {
|
||||
detail: { industry: data.industry, source: "equipment-specs", installation: data.title },
|
||||
}));
|
||||
}}
|
||||
className="w-full mt-1 flex items-center justify-center gap-2 py-3 rounded-xl text-[12px] font-medium bg-[#1D1D1F] dark:bg-[#0066CC] text-white hover:bg-[#0066CC] dark:hover:bg-[#4DA6FF] shadow-md transition-all duration-300 cursor-pointer"
|
||||
>
|
||||
Request Quote Based on This System <ArrowRight size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import { Fragment } from "react";
|
||||
|
||||
/**
|
||||
* Lightweight Markdown renderer for FluxAI chat bubbles.
|
||||
* Handles: **bold**, *italic*, `code`, ### headers, - lists, and line breaks.
|
||||
* No external dependencies.
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
content: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function parseLine(text: string): React.ReactNode[] {
|
||||
const parts: React.ReactNode[] = [];
|
||||
// Pattern: **bold**, *italic*, `code`
|
||||
const regex = /(\*\*(.+?)\*\*|\*(.+?)\*|`(.+?)`)/g;
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
// Text before match
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(text.slice(lastIndex, match.index));
|
||||
}
|
||||
|
||||
if (match[2]) {
|
||||
// **bold**
|
||||
parts.push(<strong key={match.index} className="font-semibold">{match[2]}</strong>);
|
||||
} else if (match[3]) {
|
||||
// *italic*
|
||||
parts.push(<em key={match.index}>{match[3]}</em>);
|
||||
} else if (match[4]) {
|
||||
// `code`
|
||||
parts.push(
|
||||
<code
|
||||
key={match.index}
|
||||
className="px-1.5 py-0.5 rounded-md bg-black/5 dark:bg-white/10 text-[12px] font-mono"
|
||||
>
|
||||
{match[4]}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
// Remaining text
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(text.slice(lastIndex));
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts : [text];
|
||||
}
|
||||
|
||||
export default function MarkdownRenderer({ content, className = "" }: Props) {
|
||||
const lines = content.split("\n");
|
||||
const elements: React.ReactNode[] = [];
|
||||
let listBuffer: string[] = [];
|
||||
|
||||
const flushList = () => {
|
||||
if (listBuffer.length > 0) {
|
||||
elements.push(
|
||||
<ul key={`list-${elements.length}`} className="flex flex-col gap-1 my-1.5">
|
||||
{listBuffer.map((item, i) => (
|
||||
<li key={i} className="flex items-start gap-2">
|
||||
<span className="w-1 h-1 rounded-full bg-current mt-[7px] shrink-0 opacity-40" />
|
||||
<span>{parseLine(item)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
listBuffer = [];
|
||||
}
|
||||
};
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
|
||||
// Skip empty lines
|
||||
if (!line) {
|
||||
flushList();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Headers
|
||||
if (line.startsWith("### ")) {
|
||||
flushList();
|
||||
elements.push(
|
||||
<p key={i} className="font-semibold text-[12px] uppercase tracking-wider opacity-60 mt-3 mb-1">
|
||||
{parseLine(line.slice(4))}
|
||||
</p>
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("## ")) {
|
||||
flushList();
|
||||
elements.push(
|
||||
<p key={i} className="font-semibold text-[13px] mt-3 mb-1">
|
||||
{parseLine(line.slice(3))}
|
||||
</p>
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// List items (- or *)
|
||||
if (/^[-*]\s/.test(line)) {
|
||||
listBuffer.push(line.slice(2));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Numbered list items
|
||||
if (/^\d+\.\s/.test(line)) {
|
||||
listBuffer.push(line.replace(/^\d+\.\s/, ""));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Regular paragraph
|
||||
flushList();
|
||||
elements.push(
|
||||
<p key={i} className="leading-relaxed">
|
||||
{parseLine(line)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
flushList();
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col gap-1 ${className}`}>
|
||||
{elements.map((el, i) => (
|
||||
<Fragment key={i}>{el}</Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { Scale, Info } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
interface ComparisonCategory {
|
||||
label: string; rf: number; competitor: number; unit: string; note: string;
|
||||
}
|
||||
|
||||
interface ComparisonData {
|
||||
fluxMethod: string; competitorMethod: string; context: string; categories: ComparisonCategory[];
|
||||
}
|
||||
|
||||
function ComparisonRow({ category, index }: { category: ComparisonCategory; index: number }) {
|
||||
const [showNote, setShowNote] = useState(false);
|
||||
const maxVal = Math.max(category.rf, category.competitor);
|
||||
const rfW = (category.rf / maxVal) * 100;
|
||||
const compW = (category.competitor / maxVal) * 100;
|
||||
const rfWins = category.label === "Carbon Footprint" ? category.rf < category.competitor : category.rf > category.competitor;
|
||||
const delay = 0.3 + index * 0.12;
|
||||
|
||||
return (
|
||||
<motion.div initial={{ opacity: 0, x: -10 }} animate={{ opacity: 1, x: 0 }} transition={{ delay, duration: 0.4 }} className="group">
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[11px] font-medium text-[#1D1D1F] dark:text-[#E5E5EA]">{category.label}</span>
|
||||
<button onClick={() => setShowNote(!showNote)} className="opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Info size={11} className="text-[#86868B] dark:text-[#A1A1A6]" />
|
||||
</button>
|
||||
</div>
|
||||
{rfWins && (
|
||||
<span className="text-[8px] font-semibold text-[#0066CC] dark:text-[#4DA6FF] uppercase tracking-wider bg-[#0066CC]/8 dark:bg-[#4DA6FF]/10 px-1.5 py-0.5 rounded">
|
||||
FLUX advantage
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] text-[#0066CC] dark:text-[#4DA6FF] font-semibold w-[32px] text-right shrink-0">RF</span>
|
||||
<div className="flex-1 h-[7px] bg-black/[0.03] dark:bg-white/[0.04] rounded-full overflow-hidden">
|
||||
<motion.div className="h-full rounded-full bg-gradient-to-r from-[#0066CC] to-[#00AAFF]"
|
||||
initial={{ width: 0 }} animate={{ width: `${rfW}%` }}
|
||||
transition={{ duration: 1, ease: [0.16, 1, 0.3, 1], delay }} />
|
||||
</div>
|
||||
<span className="text-[10px] font-semibold text-[#1D1D1F] dark:text-[#E5E5EA] w-[42px] shrink-0">{category.rf}{category.unit}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] text-[#86868B] dark:text-[#A1A1A6] w-[32px] text-right shrink-0">Trad.</span>
|
||||
<div className="flex-1 h-[7px] bg-black/[0.03] dark:bg-white/[0.04] rounded-full overflow-hidden">
|
||||
<motion.div className="h-full rounded-full bg-[#D1D5DB] dark:bg-[#4A4A4A]"
|
||||
initial={{ width: 0 }} animate={{ width: `${compW}%` }}
|
||||
transition={{ duration: 1, ease: [0.16, 1, 0.3, 1], delay: delay + 0.1 }} />
|
||||
</div>
|
||||
<span className="text-[10px] text-[#86868B] dark:text-[#A1A1A6] w-[42px] shrink-0">{category.competitor}{category.unit}</span>
|
||||
</div>
|
||||
</div>
|
||||
{showNote && (
|
||||
<motion.p initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: "auto" }}
|
||||
className="text-[10px] text-[#86868B] dark:text-[#A1A1A6] mt-1.5 pl-[42px] leading-relaxed italic">
|
||||
{category.note}
|
||||
</motion.p>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProcessComparisonTable({ data }: { data: ComparisonData }) {
|
||||
return (
|
||||
<motion.div initial={{ opacity: 0, y: 12, scale: 0.97 }} animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1] }} className="my-3 w-full max-w-[400px]"
|
||||
>
|
||||
<div className="bg-white/60 dark:bg-white/[0.04] backdrop-blur-2xl border border-white/70 dark:border-white/[0.06] shadow-[0_4px_24px_rgba(0,0,0,0.06)] dark:shadow-none rounded-2xl p-5 transition-colors">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="w-6 h-6 rounded-lg bg-[#0066CC]/10 dark:bg-[#4DA6FF]/15 flex items-center justify-center">
|
||||
<Scale size={13} className="text-[#0066CC] dark:text-[#4DA6FF]" />
|
||||
</div>
|
||||
<span className="text-[9px] font-semibold uppercase tracking-widest text-[#0066CC] dark:text-[#4DA6FF]">Technology Comparison</span>
|
||||
</div>
|
||||
<div className="mb-5">
|
||||
<h4 className="text-[15px] font-medium text-[#1D1D1F] dark:text-[#F5F5F7] leading-tight">{data.fluxMethod}</h4>
|
||||
<p className="text-[12px] text-[#86868B] dark:text-[#A1A1A6]">
|
||||
vs {data.competitorMethod}<span className="text-[#B0B0B0] dark:text-[#666]"> — {data.context}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
{data.categories.map((cat, i) => (
|
||||
<ComparisonRow key={cat.label} category={cat} index={i} />
|
||||
))}
|
||||
</div>
|
||||
<motion.p initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 1.5 }}
|
||||
className="mt-5 pt-3 border-t border-black/5 dark:border-white/5 text-[10px] text-[#86868B] dark:text-[#A1A1A6] leading-relaxed">
|
||||
Based on FLUX engineering benchmarks. Hover rows for details.
|
||||
</motion.p>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
"use client";
|
||||
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Radio, Flame, Zap } from "lucide-react";
|
||||
import { useState, useMemo } from "react";
|
||||
|
||||
interface ExplainerData {
|
||||
material: string; materialLabel: string; comparisonMethod: string; rfFrequency: string;
|
||||
penetrationDepth: string; heatingMechanism: string; waterContentPercent: number;
|
||||
dielectricConstant: number; processingTimeReduction: number; keyAdvantages: string[];
|
||||
physicsNote: string;
|
||||
}
|
||||
|
||||
const MATERIAL_COLORS: Record<string, { surface: string; core: string; bgLight: string; bgDark: string }> = {
|
||||
textile: { surface: "#C2B8A3", core: "#8B7D6B", bgLight: "#F5F0EB", bgDark: "#1A1815" },
|
||||
food: { surface: "#D4956A", core: "#A0522D", bgLight: "#FFF5EE", bgDark: "#1A1410" },
|
||||
rubber: { surface: "#4A4A4A", core: "#2D2D2D", bgLight: "#F0F0F0", bgDark: "#141414" },
|
||||
pharma: { surface: "#B8D4E3", core: "#6B9DBF", bgLight: "#F0F7FC", bgDark: "#101820" },
|
||||
wood: { surface: "#C19A6B", core: "#8B6914", bgLight: "#FAF3EB", bgDark: "#1A1510" },
|
||||
default: { surface: "#B0B0B0", core: "#606060", bgLight: "#F5F5F5", bgDark: "#151515" },
|
||||
};
|
||||
|
||||
function WaterMolecule({ x, y, active, delay }: { x: number; y: number; active: boolean; delay: number }) {
|
||||
return (
|
||||
<motion.g transform={`translate(${x}, ${y})`}>
|
||||
<motion.circle r={3} fill="#E63946"
|
||||
animate={active ? { rotate: [0, 180, 360] } : { rotate: 0 }}
|
||||
transition={active ? { duration: 0.4, repeat: Infinity, ease: "linear", delay } : {}} />
|
||||
<motion.circle cx={-4} cy={-2} r={1.8} fill="#457B9D"
|
||||
animate={active ? { x: [-4, -3, -5, -4], y: [-2, -3, -1, -2] } : {}}
|
||||
transition={active ? { duration: 0.3, repeat: Infinity, delay } : {}} />
|
||||
<motion.circle cx={4} cy={-2} r={1.8} fill="#457B9D"
|
||||
animate={active ? { x: [4, 5, 3, 4], y: [-2, -1, -3, -2] } : {}}
|
||||
transition={active ? { duration: 0.3, repeat: Infinity, delay: delay + 0.15 } : {}} />
|
||||
</motion.g>
|
||||
);
|
||||
}
|
||||
|
||||
function RFWaves({ side }: { side: "left" | "right" }) {
|
||||
const xStart = side === "left" ? 15 : 315;
|
||||
const dir = side === "left" ? 1 : -1;
|
||||
return (
|
||||
<g>
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<motion.line key={`${side}-${i}`} x1={xStart} y1={30 + i * 25} x2={xStart + 20 * dir} y2={30 + i * 25}
|
||||
stroke="#0066CC" strokeWidth={1.5} strokeLinecap="round"
|
||||
initial={{ opacity: 0 }} animate={{ opacity: [0, 0.8, 0], x: [0, 40 * dir, 80 * dir] }}
|
||||
transition={{ duration: 1.5, repeat: Infinity, delay: i * 0.2, ease: "easeOut" }} />
|
||||
))}
|
||||
<motion.path
|
||||
d={side === "left" ? "M 5,60 Q 10,50 15,60 Q 20,70 25,60" : "M 325,60 Q 320,50 315,60 Q 310,70 305,60"}
|
||||
fill="none" stroke="#0066CC" strokeWidth={1.5}
|
||||
animate={{ opacity: [0.3, 0.8, 0.3] }} transition={{ duration: 1.2, repeat: Infinity }} />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function HeatArrows() {
|
||||
return (
|
||||
<g>
|
||||
{[100, 165, 230].map((x, i) => (
|
||||
<motion.line key={`t-${i}`} x1={x} y1={5} x2={x} y2={22} stroke="#FF6B35" strokeWidth={2} strokeLinecap="round" markerEnd="url(#ah)"
|
||||
animate={{ opacity: [0.3, 0.9, 0.3], y: [0, 3, 0] }} transition={{ duration: 1.5, repeat: Infinity, delay: i * 0.3 }} />
|
||||
))}
|
||||
{[100, 165, 230].map((x, i) => (
|
||||
<motion.line key={`b-${i}`} x1={x} y1={135} x2={x} y2={118} stroke="#FF6B35" strokeWidth={2} strokeLinecap="round" markerEnd="url(#ah)"
|
||||
animate={{ opacity: [0.3, 0.9, 0.3], y: [0, -3, 0] }} transition={{ duration: 1.5, repeat: Infinity, delay: i * 0.3 + 0.15 }} />
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function StatPill({ label, value, delay }: { label: string; value: string; delay: number }) {
|
||||
return (
|
||||
<motion.div initial={{ opacity: 0, scale: 0.9 }} animate={{ opacity: 1, scale: 1 }} transition={{ delay, duration: 0.4 }}
|
||||
className="flex flex-col items-center px-3 py-2 bg-white/50 dark:bg-white/[0.04] rounded-xl border border-white/60 dark:border-white/[0.06] transition-colors"
|
||||
>
|
||||
<span className="text-[13px] font-medium text-[#1D1D1F] dark:text-[#F5F5F7]">{value}</span>
|
||||
<span className="text-[8px] text-[#86868B] dark:text-[#A1A1A6] uppercase tracking-wider font-semibold">{label}</span>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RFTechExplainer({ data }: { data: ExplainerData }) {
|
||||
const [mode, setMode] = useState<"rf" | "traditional">("rf");
|
||||
const colors = MATERIAL_COLORS[data.material] || MATERIAL_COLORS.default;
|
||||
|
||||
const molecules = useMemo(() => {
|
||||
const pos: { x: number; y: number }[] = [];
|
||||
for (let r = 0; r < 4; r++) for (let c = 0; c < 5; c++)
|
||||
pos.push({ x: 80 + c * 40 + (r % 2 === 0 ? 0 : 20), y: 38 + r * 24 });
|
||||
return pos;
|
||||
}, []);
|
||||
|
||||
// Detect dark mode from parent class (CSS-driven)
|
||||
// The SVG bg color adapts via inline style since SVG doesn't support Tailwind dark:
|
||||
return (
|
||||
<motion.div initial={{ opacity: 0, y: 12, scale: 0.97 }} animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1] }} className="my-3 w-full max-w-[400px]"
|
||||
>
|
||||
<div className="bg-white/60 dark:bg-white/[0.04] backdrop-blur-2xl border border-white/70 dark:border-white/[0.06] shadow-[0_4px_24px_rgba(0,0,0,0.06)] dark:shadow-none rounded-2xl p-5 transition-colors">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 rounded-lg bg-[#0066CC]/10 dark:bg-[#4DA6FF]/15 flex items-center justify-center">
|
||||
<Radio size={13} className="text-[#0066CC] dark:text-[#4DA6FF]" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[9px] font-semibold uppercase tracking-widest text-[#0066CC] dark:text-[#4DA6FF]">How RF Works</span>
|
||||
<p className="text-[11px] text-[#86868B] dark:text-[#A1A1A6]">{data.materialLabel}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toggle */}
|
||||
<div className="flex items-center justify-center gap-2 mb-4">
|
||||
<button onClick={() => setMode("rf")}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-[11px] font-medium transition-all ${
|
||||
mode === "rf" ? "bg-[#0066CC] dark:bg-[#4DA6FF] text-white dark:text-[#0A0A0C] shadow-md" : "bg-black/5 dark:bg-white/5 text-[#86868B] dark:text-[#A1A1A6] hover:bg-black/8 dark:hover:bg-white/8"
|
||||
}`}>
|
||||
<Zap size={11} /> Solid-State RF
|
||||
</button>
|
||||
<button onClick={() => setMode("traditional")}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-[11px] font-medium transition-all ${
|
||||
mode === "traditional" ? "bg-[#FF6B35] text-white shadow-md" : "bg-black/5 dark:bg-white/5 text-[#86868B] dark:text-[#A1A1A6] hover:bg-black/8 dark:hover:bg-white/8"
|
||||
}`}>
|
||||
<Flame size={11} /> {data.comparisonMethod}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* SVG Visualization */}
|
||||
<div className="rounded-xl overflow-hidden border border-black/5 dark:border-white/5 mb-4 bg-[var(--viz-bg)] transition-colors"
|
||||
style={{ "--viz-bg": colors.bgLight } as any}
|
||||
>
|
||||
{/* Dark mode override via CSS class */}
|
||||
<style>{`.dark [style*="--viz-bg"] { --viz-bg: ${colors.bgDark} !important; }`}</style>
|
||||
<svg viewBox="0 0 330 140" className="w-full" style={{ height: "auto" }}>
|
||||
<defs>
|
||||
<marker id="ah" markerWidth="6" markerHeight="4" refX="5" refY="2" orient="auto">
|
||||
<path d="M0,0 L6,2 L0,4 Z" fill="#FF6B35" />
|
||||
</marker>
|
||||
<radialGradient id="rfH" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stopColor="#FF6B35" stopOpacity="0.35" />
|
||||
<stop offset="100%" stopColor="#FF6B35" stopOpacity="0.25" />
|
||||
</radialGradient>
|
||||
<linearGradient id="tHH" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stopColor="#FF6B35" stopOpacity="0.5" />
|
||||
<stop offset="30%" stopColor="#FF6B35" stopOpacity="0.08" />
|
||||
<stop offset="70%" stopColor="#FF6B35" stopOpacity="0.08" />
|
||||
<stop offset="100%" stopColor="#FF6B35" stopOpacity="0.5" />
|
||||
</linearGradient>
|
||||
<linearGradient id="tHV" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stopColor="#FF6B35" stopOpacity="0.4" />
|
||||
<stop offset="30%" stopColor="#FF6B35" stopOpacity="0.05" />
|
||||
<stop offset="70%" stopColor="#FF6B35" stopOpacity="0.05" />
|
||||
<stop offset="100%" stopColor="#FF6B35" stopOpacity="0.4" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<rect x="55" y="20" width="220" height="100" rx="6" fill={colors.core} opacity={0.15} />
|
||||
<rect x="55" y="20" width="220" height="100" rx="6" fill="none" stroke={colors.core} strokeWidth={1} opacity={0.3} />
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{mode === "rf" ? (
|
||||
<motion.rect key="rf" x="55" y="20" width="220" height="100" rx="6" fill="url(#rfH)"
|
||||
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.6 }} />
|
||||
) : (
|
||||
<motion.g key="trad" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.6 }}>
|
||||
<rect x="55" y="20" width="220" height="100" rx="6" fill="url(#tHH)" />
|
||||
<rect x="55" y="20" width="220" height="100" rx="6" fill="url(#tHV)" />
|
||||
</motion.g>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{molecules.map((m, i) => (
|
||||
<WaterMolecule key={i} x={m.x} y={m.y} active={mode === "rf"} delay={i * 0.05} />
|
||||
))}
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{mode === "rf" ? (
|
||||
<motion.g key="waves" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}>
|
||||
<RFWaves side="left" /><RFWaves side="right" />
|
||||
<text x="8" y="95" fontSize="7" fill="#0066CC" fontWeight="600" opacity={0.7}>RF</text>
|
||||
<text x="8" y="103" fontSize="6" fill="#0066CC" opacity={0.5}>electrode</text>
|
||||
<text x="322" y="95" fontSize="7" fill="#0066CC" fontWeight="600" opacity={0.7} textAnchor="end">RF</text>
|
||||
<text x="322" y="103" fontSize="6" fill="#0066CC" opacity={0.5} textAnchor="end">electrode</text>
|
||||
</motion.g>
|
||||
) : (
|
||||
<motion.g key="heat" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}>
|
||||
<HeatArrows />
|
||||
<text x="165" y="134" fontSize="7" fill="#FF6B35" fontWeight="500" textAnchor="middle" opacity={0.6}>Surface heating only</text>
|
||||
</motion.g>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{mode === "rf" && (
|
||||
<motion.text x="165" y="134" fontSize="7" fill="#0066CC" fontWeight="600" textAnchor="middle"
|
||||
initial={{ opacity: 0 }} animate={{ opacity: 0.7 }}>
|
||||
{data.rfFrequency} — Volumetric heating
|
||||
</motion.text>
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Temperature Profile */}
|
||||
<div className="mb-4">
|
||||
<span className="text-[9px] text-[#86868B] dark:text-[#A1A1A6] uppercase tracking-wider font-semibold block mb-1">
|
||||
Temperature profile (cross-section)
|
||||
</span>
|
||||
<div className="relative h-[10px] rounded-full overflow-hidden bg-black/5 dark:bg-white/5">
|
||||
<motion.div className="absolute inset-0 rounded-full" initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.8 }}
|
||||
style={{ background: mode === "rf" ? "linear-gradient(to right, #FF6B35, #FF6B35)" : "linear-gradient(to right, #FF6B35, #FFD4B8, #FFF5EE, #FFD4B8, #FF6B35)" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between mt-0.5">
|
||||
<span className="text-[8px] text-[#86868B] dark:text-[#A1A1A6]">Surface</span>
|
||||
<span className="text-[8px] text-[#86868B] dark:text-[#A1A1A6]">Core</span>
|
||||
<span className="text-[8px] text-[#86868B] dark:text-[#A1A1A6]">Surface</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-[#86868B] dark:text-[#A1A1A6] mt-1 text-center">
|
||||
{mode === "rf" ? "RF: Uniform temperature throughout the entire mass" : "Traditional: Hot surface, cold core — slow and uneven"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-3 gap-2 mb-4">
|
||||
<StatPill label="Frequency" value={data.rfFrequency} delay={0.3} />
|
||||
<StatPill label="Penetration" value={data.penetrationDepth} delay={0.4} />
|
||||
<StatPill label="Speed gain" value={`${data.processingTimeReduction}%`} delay={0.5} />
|
||||
</div>
|
||||
|
||||
{/* Key advantages */}
|
||||
<div className="pt-3 border-t border-black/5 dark:border-white/5">
|
||||
<p className="text-[9px] font-semibold uppercase tracking-widest text-[#0066CC] dark:text-[#4DA6FF] mb-2">
|
||||
Key advantages for {data.materialLabel}
|
||||
</p>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{data.keyAdvantages.map((adv, i) => (
|
||||
<motion.div key={i} initial={{ opacity: 0, x: -8 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: 0.6 + i * 0.1 }}
|
||||
className="flex items-start gap-2">
|
||||
<div className="w-1 h-1 rounded-full bg-[#0066CC] dark:bg-[#4DA6FF] mt-1.5 shrink-0" />
|
||||
<span className="text-[11px] text-[#1D1D1F] dark:text-[#E5E5EA] leading-relaxed">{adv}</span>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Physics note */}
|
||||
<motion.p initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 1.2 }}
|
||||
className="mt-3 text-[10px] text-[#86868B] dark:text-[#A1A1A6] leading-relaxed italic">
|
||||
{data.physicsNote}
|
||||
</motion.p>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
"use client";
|
||||
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Sparkles, ArrowRight, X, Minus, Database, Maximize2, Minimize2 } from "lucide-react";
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { DefaultChatTransport, lastAssistantMessageIsCompleteWithToolCalls } from "ai";
|
||||
import { useUIStore } from "@/lib/store/uiStore";
|
||||
import { useState, useEffect, useRef, useMemo } from "react";
|
||||
|
||||
// ── Renderers ──
|
||||
import MarkdownRenderer from "./MarkdownRenderer";
|
||||
import EnergySavingsCalculator from "./EnergySavingsCalculator";
|
||||
import ProcessComparisonTable from "./ProcessComparisonTable";
|
||||
import RFTechExplainer from "./RFTechExplainer";
|
||||
import ConsultationScheduler from "./ConsultationScheduler";
|
||||
import CaseStudyViewer from "./CaseStudyViewer";
|
||||
import EquipmentConfigurator from "./EquipmentConfigurator";
|
||||
import EfficiencyCard from "./EfficiencyCard";
|
||||
|
||||
export default function SilentObserver() {
|
||||
const {
|
||||
isAiExpanded, toggleAi, setAiExpanded,
|
||||
currentSection, activeApplicationTab, setActiveApplicationTab,
|
||||
setHighlightedMapNode, setSelectedMarkerId,
|
||||
} = useUIStore();
|
||||
|
||||
const [input, setInput] = useState("");
|
||||
const [isDark, setIsDark] = useState(false);
|
||||
const [isWideMode, setIsWideMode] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Refs for dynamic body (accessed inside transport function)
|
||||
const sectionRef = useRef(currentSection);
|
||||
const tabRef = useRef(activeApplicationTab);
|
||||
useEffect(() => { sectionRef.current = currentSection; }, [currentSection]);
|
||||
useEffect(() => { tabRef.current = activeApplicationTab; }, [activeApplicationTab]);
|
||||
|
||||
useEffect(() => {
|
||||
const checkTheme = () => setIsDark(document.documentElement.classList.contains("dark"));
|
||||
checkTheme();
|
||||
const observer = new MutationObserver(checkTheme);
|
||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const handleClose = () => {
|
||||
setAiExpanded(false);
|
||||
setTimeout(() => setIsWideMode(false), 400);
|
||||
};
|
||||
|
||||
// ═══ AI SDK 6: Transport with dynamic body ═══
|
||||
const transport = useMemo(() => new DefaultChatTransport({
|
||||
api: "/api/chat",
|
||||
body: () => ({
|
||||
context: {
|
||||
section: sectionRef.current,
|
||||
activeTab: tabRef.current,
|
||||
},
|
||||
}),
|
||||
}), []);
|
||||
|
||||
// ═══ AI SDK 6: useChat ═══
|
||||
const { messages, sendMessage, addToolOutput, status } = useChat({
|
||||
transport,
|
||||
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
|
||||
|
||||
async onToolCall({ toolCall }) {
|
||||
if (toolCall.dynamic) return;
|
||||
|
||||
if (toolCall.toolName === "navigate_to_section") {
|
||||
const { section, subAction, tabId, nodeId } = toolCall.input as {
|
||||
section: string; subAction?: string; tabId?: string; nodeId?: string;
|
||||
};
|
||||
handleClose();
|
||||
setTimeout(() => {
|
||||
const el = document.getElementById(section);
|
||||
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
if (subAction === "activate-tab" && tabId) setActiveApplicationTab(tabId);
|
||||
if (subAction === "highlight-node" && nodeId) {
|
||||
setHighlightedMapNode(nodeId);
|
||||
setTimeout(() => setHighlightedMapNode(null), 5000);
|
||||
}
|
||||
}, 400);
|
||||
addToolOutput({
|
||||
tool: "navigate_to_section" as any,
|
||||
toolCallId: toolCall.toolCallId,
|
||||
output: `Navigated to "${section}" section${tabId ? `, activated tab "${tabId}"` : ""}${nodeId ? `, highlighted node "${nodeId}"` : ""}`,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const isLoading = status === "submitted" || status === "streaming";
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}, [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
if (!isAiExpanded) setAiExpanded(true);
|
||||
setTimeout(() => {
|
||||
sendMessage({
|
||||
text: `I'd like to schedule an engineering consultation${detail?.industry ? ` for my ${detail.industry} operation` : ""}${detail?.installation ? ` (reference: ${detail.installation})` : ""}. Please set that up.`,
|
||||
});
|
||||
}, 500);
|
||||
};
|
||||
window.addEventListener("flux:request-consultation", handler);
|
||||
return () => window.removeEventListener("flux:request-consultation", handler);
|
||||
}, [isAiExpanded, setAiExpanded, sendMessage]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
handleClose();
|
||||
setTimeout(() => {
|
||||
const section = document.getElementById("global");
|
||||
if (section) section.scrollIntoView({ behavior: "smooth" });
|
||||
if (detail?.nodeId) {
|
||||
setSelectedMarkerId(detail.nodeId);
|
||||
setHighlightedMapNode(detail.nodeId);
|
||||
setTimeout(() => setHighlightedMapNode(null), 5000);
|
||||
}
|
||||
}, 400);
|
||||
};
|
||||
window.addEventListener("flux:navigate-to-case", handler);
|
||||
return () => window.removeEventListener("flux:navigate-to-case", handler);
|
||||
}, [setSelectedMarkerId, setHighlightedMapNode]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
if (!detail?.nodeId) return;
|
||||
handleClose();
|
||||
setTimeout(() => {
|
||||
const section = document.getElementById("global");
|
||||
if (section) section.scrollIntoView({ behavior: "smooth" });
|
||||
setSelectedMarkerId(detail.nodeId);
|
||||
}, 400);
|
||||
};
|
||||
window.addEventListener("flux:open-case-study-modal", handler);
|
||||
return () => window.removeEventListener("flux:open-case-study-modal", handler);
|
||||
}, [setSelectedMarkerId]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
if (!isAiExpanded) setAiExpanded(true);
|
||||
setTimeout(() => {
|
||||
if (detail?.prompt) sendMessage({ text: detail.prompt });
|
||||
}, 500);
|
||||
};
|
||||
window.addEventListener("flux:trigger-ai", handler);
|
||||
return () => window.removeEventListener("flux:trigger-ai", handler);
|
||||
}, [isAiExpanded, setAiExpanded, sendMessage]);
|
||||
|
||||
const onSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!input.trim() || isLoading) return;
|
||||
sendMessage({ text: input });
|
||||
setInput("");
|
||||
};
|
||||
|
||||
function renderToolPart(part: any, index: number) {
|
||||
const key = `tool-${index}`;
|
||||
const ToolLoading = ({ label }: { label: string }) => (<div key={key} className="self-start flex items-center gap-2.5 py-2"><div className="w-5 h-5 rounded-full bg-[#0066CC]/10 dark:bg-[#4DA6FF]/15 flex items-center justify-center"><Sparkles size={10} className="text-[#0066CC] dark:text-[#4DA6FF] animate-pulse" /></div><span className="text-[11px] text-[#86868B] dark:text-[#A1A1A6] animate-pulse">{label}</span></div>);
|
||||
const DataToolWorking = ({ label }: { label: string }) => (<div key={key} className="self-start flex items-center gap-2 py-1"><Database size={10} className="text-[#0066CC]/40 dark:text-[#4DA6FF]/40 animate-pulse" /><span className="text-[10px] text-[#86868B]/60 dark:text-[#A1A1A6]/40 italic">{label}</span></div>);
|
||||
const ToolError = ({ msg }: { msg?: string }) => (<div key={key} className="text-[11px] text-red-400 dark:text-red-300/80 self-start py-1">Analysis unavailable. {msg}</div>);
|
||||
|
||||
if (part.type === "tool-search_installations") {
|
||||
if (part.state === "input-streaming" || part.state === "input-available") return <DataToolWorking key={key} label="Searching installation database..." />;
|
||||
if (part.state === "output-available") return null;
|
||||
if (part.state === "output-error") return <ToolError key={key} msg={(part as any).errorText} />;
|
||||
}
|
||||
if (part.type === "tool-get_application_knowledge") {
|
||||
if (part.state === "input-streaming" || part.state === "input-available") return <DataToolWorking key={key} label="Loading technical knowledge..." />;
|
||||
if (part.state === "output-available") return null;
|
||||
if (part.state === "output-error") return <ToolError key={key} msg={(part as any).errorText} />;
|
||||
}
|
||||
if (part.type === "tool-show_case_study") {
|
||||
if (part.state === "input-streaming" || part.state === "input-available") return <ToolLoading key={key} label="Loading case study..." />;
|
||||
if (part.state === "output-available") { const o = (part as any).output; if (!o?.found) return null; return <CaseStudyViewer key={key} data={o} />; }
|
||||
if (part.state === "output-error") return <ToolError key={key} msg={(part as any).errorText} />;
|
||||
}
|
||||
if (part.type === "tool-show_equipment_specs") {
|
||||
if (part.state === "input-streaming" || part.state === "input-available") return <ToolLoading key={key} label="Loading equipment specifications..." />;
|
||||
if (part.state === "output-available") { const o = (part as any).output; if (!o?.found) return null; return <EquipmentConfigurator key={key} data={o} />; }
|
||||
if (part.state === "output-error") return <ToolError key={key} msg={(part as any).errorText} />;
|
||||
}
|
||||
if (part.type === "tool-energy_savings_calculator") {
|
||||
if (part.state === "input-streaming" || part.state === "input-available") return <ToolLoading key={key} label={`Calculating savings for ${(part as any).input?.industry || "your industry"}...`} />;
|
||||
if (part.state === "output-available") return <EnergySavingsCalculator key={key} data={(part as any).output} />;
|
||||
if (part.state === "output-error") return <ToolError key={key} msg={(part as any).errorText} />;
|
||||
}
|
||||
if (part.type === "tool-process_comparison_table") {
|
||||
if (part.state === "input-streaming" || part.state === "input-available") return <ToolLoading key={key} label="Building technology comparison..." />;
|
||||
if (part.state === "output-available") return <ProcessComparisonTable key={key} data={(part as any).output} />;
|
||||
if (part.state === "output-error") return <ToolError key={key} msg={(part as any).errorText} />;
|
||||
}
|
||||
if (part.type === "tool-rf_technology_explainer") {
|
||||
if (part.state === "input-streaming" || part.state === "input-available") return <ToolLoading key={key} label="Preparing RF visualization..." />;
|
||||
if (part.state === "output-available") return <RFTechExplainer key={key} data={(part as any).output} />;
|
||||
if (part.state === "output-error") return <ToolError key={key} msg={(part as any).errorText} />;
|
||||
}
|
||||
if (part.type === "tool-schedule_consultation") {
|
||||
if (part.state === "input-streaming" || part.state === "input-available") return <ToolLoading key={key} label="Preparing your consultation brief..." />;
|
||||
if (part.state === "output-available") return <ConsultationScheduler key={key} data={(part as any).output} />;
|
||||
if (part.state === "output-error") return <ToolError key={key} msg={(part as any).errorText} />;
|
||||
}
|
||||
if (part.type === "tool-navigate_to_section") {
|
||||
if (part.state === "input-streaming" || part.state === "input-available") return <div key={key} className="self-start flex items-center gap-2 py-1"><div className="w-3 h-3 rounded-full bg-[#0066CC] dark:bg-[#4DA6FF] animate-ping opacity-40" /><span className="text-[11px] text-[#86868B] dark:text-[#A1A1A6]">Navigating...</span></div>;
|
||||
if (part.state === "output-available") return <motion.div key={key} initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="self-start text-[10px] text-[#86868B] dark:text-[#A1A1A6]/60 italic py-1">Navigated to section</motion.div>;
|
||||
}
|
||||
if (part.type === "tool-show_efficiency_calculator") {
|
||||
if (part.state === "input-available" || part.state === "output-available") return <EfficiencyCard key={key} industry={(part as any).input?.industry || "Industrial"} estimatedSavingsPercent={(part as any).input?.estimatedSavingsPercent || 40} />;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<AnimatePresence>
|
||||
{isAiExpanded && (
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.5, ease: "easeInOut" }} onClick={handleClose}
|
||||
className={`fixed inset-0 z-40 backdrop-blur-[4px] transition-colors duration-500 ${isWideMode ? "bg-black/30 dark:bg-black/60" : "bg-black/10 dark:bg-black/40"}`} />
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className={`fixed inset-0 z-50 flex pointer-events-none px-3 pb-3 md:p-6 transition-all duration-700 ease-[cubic-bezier(0.16,1,0.3,1)] ${isWideMode ? "items-center justify-center" : "items-end justify-center md:justify-end"}`} style={{ paddingBottom: 'max(0.75rem, env(safe-area-inset-bottom))' }}>
|
||||
<AnimatePresence mode="wait">
|
||||
{!isAiExpanded ? (
|
||||
<motion.button key="pill" layoutId="flux-ai-shell" onClick={toggleAi}
|
||||
initial={{ opacity: 0, scale: 0.8, filter: "blur(10px)" }} animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }} exit={{ opacity: 0, scale: 0.9, filter: "blur(10px)" }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 30 }}
|
||||
className="pointer-events-auto flex items-center gap-3 px-5 py-3.5 rounded-full cursor-pointer select-none bg-white/70 dark:bg-[#1D1D1F]/80 backdrop-blur-2xl backdrop-saturate-150 border border-white/60 dark:border-white/10 shadow-[0_8px_32px_rgba(0,0,0,0.08)] dark:shadow-[0_8px_32px_rgba(0,0,0,0.5)] hover:shadow-[0_12px_40px_rgba(0,0,0,0.12)] dark:hover:shadow-[0_12px_40px_rgba(0,0,0,0.6)] hover:scale-[1.02] active:scale-[0.98] transition-all duration-300 group">
|
||||
<div className="relative flex items-center justify-center">
|
||||
<Sparkles size={18} className="text-[#0066CC] dark:text-[#4DA6FF] relative z-10" />
|
||||
<div className="absolute inset-0 bg-[#0066CC] dark:bg-[#4DA6FF] rounded-full blur-md opacity-20 group-hover:opacity-40 transition-opacity" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-[#1D1D1F] dark:text-[#F5F5F7] transition-colors">Ask FluxAI</span>
|
||||
</motion.button>
|
||||
) : (
|
||||
<motion.div layout key="panel" layoutId="flux-ai-shell"
|
||||
initial={{ opacity: 0, scale: 0.92, y: 30, filter: "blur(20px)" }} animate={{ opacity: 1, scale: 1, y: 0, filter: "blur(0px)" }} exit={{ opacity: 0, scale: 0.92, y: 30, filter: "blur(20px)" }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 28 }}
|
||||
className={`pointer-events-auto flex flex-col overflow-hidden bg-white/70 dark:bg-[#1D1D1F]/60 backdrop-blur-[40px] backdrop-saturate-150 border border-white/60 dark:border-white/[0.08] shadow-[0_24px_80px_rgba(0,0,0,0.12)] dark:shadow-[inset_0_1px_1px_rgba(255,255,255,0.04),0_24px_80px_rgba(0,0,0,0.6)] transition-all duration-500 w-full max-w-[calc(100vw-1.5rem)] h-[70vh] max-h-[600px] rounded-[24px] ${isWideMode ? "md:w-[860px] lg:w-[1000px] md:h-[85vh] md:max-h-[900px] md:rounded-[32px]" : "md:w-[460px] md:h-[640px] md:max-h-[640px] md:rounded-[32px]"}`}>
|
||||
<div className="absolute top-0 left-0 w-full h-24 bg-gradient-to-b from-white/[0.06] to-transparent pointer-events-none rounded-t-[24px] md:rounded-t-[32px] hidden dark:block" />
|
||||
|
||||
<div className="relative z-10 flex items-center justify-between px-5 py-4 border-b border-black/[0.04] dark:border-white/[0.06] bg-white/30 dark:bg-white/[0.02] transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative flex items-center justify-center">
|
||||
<Sparkles size={17} className="text-[#0066CC] dark:text-[#4DA6FF] relative z-10" />
|
||||
{isLoading && (<motion.div animate={{ scale: [1, 1.6, 1], opacity: [0.4, 0, 0.4] }} transition={{ duration: 1.5, repeat: Infinity }} className="absolute inset-0 bg-[#0066CC] dark:bg-[#4DA6FF] rounded-full blur-sm" />)}
|
||||
</div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-[14px] font-medium text-[#1D1D1F] dark:text-[#F5F5F7] transition-colors">FluxAI</span>
|
||||
<span className="hidden md:inline text-[11px] text-[#86868B] dark:text-[#A1A1A6] transition-colors">Engineering Advisor</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{messages.length > 0 && (
|
||||
<button onClick={() => setIsWideMode(!isWideMode)} className="hidden md:flex p-2 rounded-full hover:bg-black/5 dark:hover:bg-white/5 transition-colors group" title={isWideMode ? "Standard View" : "Immersive Focus Mode"}>
|
||||
{isWideMode ? <Minimize2 size={15} className="text-[#86868B] dark:text-[#A1A1A6] group-hover:text-[#1D1D1F] dark:group-hover:text-white transition-colors" /> : <Maximize2 size={15} className="text-[#86868B] dark:text-[#A1A1A6] group-hover:text-[#1D1D1F] dark:group-hover:text-white transition-colors" />}
|
||||
</button>
|
||||
)}
|
||||
<div className="w-px h-4 bg-black/10 dark:bg-white/10 mx-1 hidden md:block" />
|
||||
<button onClick={handleClose} className="p-2 rounded-full hover:bg-black/5 dark:hover:bg-white/5 transition-colors group"><Minus size={15} className="text-[#86868B] dark:text-[#A1A1A6] group-hover:text-[#1D1D1F] dark:group-hover:text-white transition-colors" /></button>
|
||||
<button onClick={handleClose} className="p-2 rounded-full hover:bg-black/5 dark:hover:bg-white/5 transition-colors group"><X size={15} className="text-[#86868B] dark:text-[#A1A1A6] group-hover:text-red-500 transition-colors" /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto px-5 py-4 flex flex-col gap-4 text-[13px] font-light scroll-smooth [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden">
|
||||
{messages.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center flex-1 gap-5 py-8">
|
||||
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-[#0066CC]/10 to-[#00AAFF]/10 dark:from-[#4DA6FF]/15 dark:to-[#00AAFF]/10 flex items-center justify-center border border-[#0066CC]/10 dark:border-[#4DA6FF]/10 transition-colors"><Sparkles size={24} className="text-[#0066CC] dark:text-[#4DA6FF]" /></div>
|
||||
<div className="text-center">
|
||||
<p className="text-[15px] font-medium text-[#1D1D1F] dark:text-[#F5F5F7] mb-1.5 transition-colors">FluxAI Engineering Advisor</p>
|
||||
<p className="text-[12px] text-[#86868B] dark:text-[#A1A1A6] max-w-[280px] leading-relaxed transition-colors">Ask about energy savings, compare RF vs traditional methods, or let me guide you through our technology.</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 justify-center mt-1">
|
||||
{["How much energy can I save?", "RF vs Hot Air", "How does RF heating work?", "Show me proven installations"].map((q) => (
|
||||
<button key={q} onClick={() => sendMessage({ text: q })} className="px-3.5 py-2 rounded-full text-[11px] font-medium bg-white/60 dark:bg-white/[0.06] border border-black/[0.06] dark:border-white/[0.08] text-[#1D1D1F] dark:text-[#A1A1A6] hover:bg-black/5 dark:hover:bg-white/10 hover:text-[#0066CC] dark:hover:text-[#4DA6FF] transition-all duration-200">{q}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{messages.map((m) => (
|
||||
<div key={m.id} className="flex flex-col gap-2 min-w-0">
|
||||
{m.parts?.map((part, index) => {
|
||||
if (part.type === "text" && part.text) {
|
||||
if (m.role === "user") return <div key={index} className="max-w-[80%] md:max-w-[700px] p-3.5 rounded-2xl rounded-tr-md self-end bg-[#0066CC] dark:bg-[#0066CC]/90 text-white text-[13px] leading-relaxed shadow-md break-words">{part.text}</div>;
|
||||
return <div key={index} className="max-w-[88%] md:max-w-[700px] p-4 rounded-2xl rounded-tl-md self-start bg-white/60 dark:bg-white/[0.05] border border-white/70 dark:border-white/[0.06] text-[#1D1D1F] dark:text-[#E5E5EA] shadow-sm dark:shadow-none transition-colors duration-300 break-words"><MarkdownRenderer content={part.text} /></div>;
|
||||
}
|
||||
if (part.type?.startsWith("tool-")) return renderToolPart(part, index);
|
||||
if (part.type === "step-start" && index > 0) return <div key={index} className="border-t border-black/[0.04] dark:border-white/[0.04] my-1" />;
|
||||
return null;
|
||||
})}
|
||||
|
||||
</div>
|
||||
))}
|
||||
{isLoading && messages.length > 0 && (
|
||||
<div className="self-start flex items-center gap-1.5 py-2 px-1">
|
||||
{[0, 1, 2].map((i) => (<motion.div key={i} className="w-1.5 h-1.5 rounded-full bg-[#0066CC] dark:bg-[#4DA6FF]" animate={{ opacity: [0.2, 1, 0.2], scale: [0.8, 1, 0.8] }} transition={{ duration: 1, repeat: Infinity, delay: i * 0.2 }} />))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form onSubmit={onSubmit} className="relative z-10 px-4 py-3 pb-[max(0.75rem,env(safe-area-inset-bottom))] md:pb-3 border-t border-black/[0.04] dark:border-white/[0.06] bg-white/40 dark:bg-white/[0.02] flex items-center gap-2.5 transition-colors duration-300">
|
||||
<input value={input} onChange={(e) => setInput(e.target.value)} placeholder="Ask about energy savings, RF technology..." className="flex-1 rounded-2xl border-none outline-none text-[13px] px-4 py-3 bg-black/[0.04] dark:bg-white/[0.06] text-[#1D1D1F] dark:text-[#F5F5F7] placeholder:text-[#86868B] dark:placeholder:text-[#A1A1A6]/60 focus:bg-black/[0.06] dark:focus:bg-white/[0.08] transition-colors duration-200" />
|
||||
<button type="submit" disabled={!input.trim() || isLoading} className="w-10 h-10 rounded-full flex items-center justify-center shrink-0 bg-[#0066CC] dark:bg-[#4DA6FF] text-white dark:text-[#0A0A0C] disabled:opacity-30 hover:shadow-lg hover:scale-105 active:scale-95 shadow-[0_4px_12px_rgba(0,102,204,0.3)] dark:shadow-[0_4px_12px_rgba(77,166,255,0.3)] transition-all duration-200"><ArrowRight size={16} strokeWidth={2.5} /></button>
|
||||
</form>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef } from "react";
|
||||
import { useUIStore } from "@/lib/store/uiStore";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
X, ShoppingBag, Plus, Minus, Trash2, Camera, Video,
|
||||
ArrowRight, ShieldCheck, Loader2, AlertCircle, Wrench, CheckCircle2
|
||||
} from "lucide-react";
|
||||
import { submitOperationsSignal } from "@/app/actions/operations";
|
||||
import { useTranslations } from "next-intl"; // 🔥 Importamos el motor de idiomas
|
||||
|
||||
export default function CartDrawer() {
|
||||
const { isCartOpen, toggleCart, cartItems, updateQuantity, removeFromCart, clearCart } = useUIStore();
|
||||
const t = useTranslations("CartDrawer"); // 🔥 Instanciamos el bloque de traducciones
|
||||
|
||||
const [mode, setMode] = useState<"CART" | "DIAGNOSTIC">("CART");
|
||||
const [isSuccess, setIsSuccess] = useState<string | null>(null);
|
||||
|
||||
const [formData, setFormData] = useState({ name: "", email: "", company: "", phone: "", message: "" });
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const [gdprAccepted, setGdprAccepted] = useState(false);
|
||||
const [showGdprModal, setShowGdprModal] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files) {
|
||||
const selectedFiles = Array.from(e.target.files);
|
||||
const validFiles = selectedFiles.filter(f => f.size <= 50 * 1024 * 1024);
|
||||
setFiles(prev => [...prev, ...validFiles]);
|
||||
}
|
||||
};
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
setFiles(files.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!gdprAccepted) return alert("You must accept the privacy policy.");
|
||||
|
||||
setIsSubmitting(true);
|
||||
let uploadedFileUrls: string[] = [];
|
||||
const tempTicketId = `TMP-${Date.now()}`;
|
||||
|
||||
if (files.length > 0) {
|
||||
setIsUploading(true);
|
||||
for (const file of files) {
|
||||
const uploadData = new FormData();
|
||||
uploadData.append("file", file);
|
||||
uploadData.append("ticketId", tempTicketId);
|
||||
uploadData.append("clientName", formData.name);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/public-upload", { method: "POST", body: uploadData });
|
||||
const result = await res.json();
|
||||
if (result.success) uploadedFileUrls.push(result.url);
|
||||
} catch (error) {
|
||||
console.error("Upload failed", error);
|
||||
}
|
||||
}
|
||||
setIsUploading(false);
|
||||
}
|
||||
|
||||
const payloadType = mode === "DIAGNOSTIC" ? "DIAGNOSTIC" : (cartItems.length > 0 ? "ORDER" : "CONSULTATION");
|
||||
|
||||
const response = await submitOperationsSignal({
|
||||
type: payloadType,
|
||||
clientName: formData.name,
|
||||
clientEmail: formData.email,
|
||||
clientCompany: formData.company,
|
||||
clientPhone: formData.phone,
|
||||
message: formData.message,
|
||||
cartPayload: JSON.stringify(cartItems),
|
||||
attachedFiles: JSON.stringify(uploadedFileUrls),
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
setIsSuccess(response.ticketId as string);
|
||||
clearCart();
|
||||
} else {
|
||||
alert(response.error);
|
||||
}
|
||||
setIsSubmitting(false);
|
||||
};
|
||||
|
||||
const subtotal = cartItems.reduce((acc, item) => item.showPrice && item.price ? acc + (item.price * item.quantity) : acc, 0);
|
||||
const hasItemsWithoutPrice = cartItems.some(item => !item.showPrice);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isCartOpen && (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
||||
onClick={toggleCart}
|
||||
className="fixed inset-0 z-[80] bg-black/60 backdrop-blur-sm"
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
initial={{ x: "100%" }} animate={{ x: 0 }} exit={{ x: "100%" }}
|
||||
transition={{ type: "spring", damping: 25, stiffness: 200 }}
|
||||
className="fixed inset-y-0 right-0 z-[90] w-full max-w-md bg-[#F5F5F7] dark:bg-[#0A0A0C] border-l border-black/10 dark:border-white/10 shadow-2xl flex flex-col"
|
||||
>
|
||||
<div className="flex items-center justify-between p-6 border-b border-black/5 dark:border-white/5 bg-white/50 dark:bg-black/50 backdrop-blur shrink-0">
|
||||
<h2 className="text-xl font-light text-[#1D1D1F] dark:text-white flex items-center gap-2">
|
||||
<ShoppingBag size={20} className={mode === "DIAGNOSTIC" ? "text-rose-500" : "text-[#00F0FF]"} />
|
||||
{mode === "DIAGNOSTIC" ? t("titleSupport") : t("titleCart")}
|
||||
</h2>
|
||||
<button onClick={toggleCart} className="p-2 text-[#86868B] hover:text-[#1D1D1F] dark:hover:text-white transition-colors bg-black/5 dark:bg-white/5 rounded-full">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isSuccess ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-8 text-center">
|
||||
<div className="w-16 h-16 bg-emerald-500/10 text-emerald-500 flex items-center justify-center rounded-2xl mb-6">
|
||||
<CheckCircle2 size={32} />
|
||||
</div>
|
||||
<h3 className="text-2xl font-light text-[#1D1D1F] dark:text-white mb-2">{t("successTitle")}</h3>
|
||||
<p className="text-[#86868B] text-sm mb-6">{t("successDesc1")} <strong className="text-[#1D1D1F] dark:text-white font-mono">{isSuccess}</strong> {t("successDesc2")}</p>
|
||||
<button onClick={() => { setIsSuccess(null); toggleCart(); }} className="px-6 py-3 bg-[#1D1D1F] dark:bg-white text-white dark:text-black rounded-xl text-sm font-semibold">{t("closePanel")}</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-y-auto [scrollbar-width:none] p-6 flex flex-col">
|
||||
|
||||
<div className="flex p-1 bg-black/5 dark:bg-white/5 rounded-xl mb-6 shrink-0">
|
||||
<button onClick={() => setMode("CART")} className={`flex-1 py-2 text-xs font-semibold rounded-lg transition-all ${mode === "CART" ? "bg-white dark:bg-[#1D1D1F] text-[#1D1D1F] dark:text-white shadow-sm" : "text-[#86868B]"}`}>{t("tabParts")}</button>
|
||||
<button onClick={() => setMode("DIAGNOSTIC")} className={`flex-1 py-2 text-xs font-semibold rounded-lg transition-all ${mode === "DIAGNOSTIC" ? "bg-white dark:bg-[#1D1D1F] text-[#1D1D1F] dark:text-white shadow-sm" : "text-[#86868B]"}`}>{t("tabDiagnostic")}</button>
|
||||
</div>
|
||||
|
||||
{mode === "CART" && (
|
||||
<div className="flex-1 flex flex-col">
|
||||
{cartItems.length === 0 ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-[#86868B] text-center">
|
||||
<ShoppingBag size={48} className="opacity-20 mb-4" />
|
||||
<p>{t("emptyCart")}</p>
|
||||
<button onClick={() => setMode("DIAGNOSTIC")} className="text-[#0066CC] dark:text-[#00F0FF] mt-4 text-sm hover:underline">{t("needHelp")}</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 space-y-4">
|
||||
{cartItems.map(item => (
|
||||
<div key={item.sku} className="flex gap-4 bg-white dark:bg-[#111] p-3 rounded-2xl border border-black/5 dark:border-white/5">
|
||||
<div className="w-16 h-16 bg-black/5 dark:bg-black/40 rounded-xl flex items-center justify-center shrink-0">
|
||||
{item.mediaUrl ? <img src={`/parts/${item.sku.toLowerCase()}/${item.mediaUrl}`} className="w-full h-full object-contain p-2" alt="" /> : <Wrench size={24} className="text-[#86868B]/30" />}
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col justify-between">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-[#1D1D1F] dark:text-white line-clamp-1">{item.title}</h4>
|
||||
<p className="text-[10px] text-[#86868B] font-mono">{item.sku}</p>
|
||||
</div>
|
||||
<button onClick={() => removeFromCart(item.sku)} className="text-[#86868B] hover:text-red-500"><Trash2 size={14} /></button>
|
||||
</div>
|
||||
<div className="flex justify-between items-center mt-2">
|
||||
<span className="text-sm font-mono font-medium text-[#1D1D1F] dark:text-white">
|
||||
{item.showPrice && item.price ? `€${(item.price * item.quantity).toFixed(2)}` : t("quote")}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 bg-black/5 dark:bg-white/5 rounded-lg px-1">
|
||||
<button onClick={() => updateQuantity(item.sku, item.quantity - 1)} className="p-1 text-[#86868B] hover:text-[#1D1D1F] dark:hover:text-white"><Minus size={12} /></button>
|
||||
<span className="text-xs font-mono w-4 text-center dark:text-white">{item.quantity}</span>
|
||||
<button onClick={() => updateQuantity(item.sku, item.quantity + 1)} className="p-1 text-[#86868B] hover:text-[#1D1D1F] dark:hover:text-white"><Plus size={12} /></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form id="operations-form" onSubmit={handleSubmit} className="mt-6 border-t border-black/5 dark:border-white/5 pt-6 flex flex-col gap-3">
|
||||
<p className="text-[10px] uppercase tracking-widest text-[#86868B] font-semibold mb-1">{t("contactDetails")}</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<input required value={formData.name} onChange={e=>setFormData({...formData, name: e.target.value})} placeholder={t("fullName")} className="w-full bg-white dark:bg-black/40 border border-black/5 dark:border-white/10 rounded-xl px-4 py-3 text-[13px] text-[#1D1D1F] dark:text-white outline-none focus:border-[#0066CC] dark:focus:border-[#00F0FF]" />
|
||||
<input required type="email" value={formData.email} onChange={e=>setFormData({...formData, email: e.target.value})} placeholder={t("email")} className="w-full bg-white dark:bg-black/40 border border-black/5 dark:border-white/10 rounded-xl px-4 py-3 text-[13px] text-[#1D1D1F] dark:text-white outline-none focus:border-[#0066CC] dark:focus:border-[#00F0FF]" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<input required value={formData.company} onChange={e=>setFormData({...formData, company: e.target.value})} placeholder={t("company")} className="w-full bg-white dark:bg-black/40 border border-black/5 dark:border-white/10 rounded-xl px-4 py-3 text-[13px] text-[#1D1D1F] dark:text-white outline-none focus:border-[#0066CC] dark:focus:border-[#00F0FF]" />
|
||||
<input value={formData.phone} onChange={e=>setFormData({...formData, phone: e.target.value})} placeholder={t("phone")} className="w-full bg-white dark:bg-black/40 border border-black/5 dark:border-white/10 rounded-xl px-4 py-3 text-[13px] text-[#1D1D1F] dark:text-white outline-none focus:border-[#0066CC] dark:focus:border-[#00F0FF]" />
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
value={formData.message} onChange={e=>setFormData({...formData, message: e.target.value})}
|
||||
placeholder={mode === "DIAGNOSTIC" ? t("placeholderDiagnostic") : t("placeholderCart")}
|
||||
rows={3}
|
||||
className="w-full bg-white dark:bg-black/40 border border-black/5 dark:border-white/10 rounded-xl px-4 py-3 text-[13px] text-[#1D1D1F] dark:text-white outline-none focus:border-[#0066CC] dark:focus:border-[#00F0FF] resize-none mt-2"
|
||||
/>
|
||||
|
||||
<div className="mt-2 bg-white dark:bg-[#111] border border-dashed border-black/10 dark:border-white/20 rounded-xl p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[10px] uppercase tracking-widest text-[#86868B] flex items-center gap-1"><Camera size={12}/> {t("attachMedia")}</span>
|
||||
<button type="button" onClick={() => fileInputRef.current?.click()} className="text-xs text-[#0066CC] dark:text-[#00F0FF] font-medium hover:underline">{t("selectFiles")}</button>
|
||||
<input type="file" multiple accept=".jpg,.png,.mp4,.mov" className="hidden" ref={fileInputRef} onChange={handleFileChange} />
|
||||
</div>
|
||||
{files.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{files.map((f, i) => (
|
||||
<div key={i} className="bg-black/5 dark:bg-white/5 text-[10px] text-[#1D1D1F] dark:text-white px-2 py-1 rounded flex items-center gap-1">
|
||||
<span className="truncate max-w-[80px]">{f.name}</span>
|
||||
<button type="button" onClick={() => removeFile(i)} className="text-[#86868B] hover:text-red-500"><X size={10}/></button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[10px] text-[#86868B] text-center">{t("dragDrop")}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-start gap-3 bg-black/5 dark:bg-white/5 p-3 rounded-xl">
|
||||
<input type="checkbox" id="gdpr" checked={gdprAccepted} onChange={e => setGdprAccepted(e.target.checked)} className="mt-0.5 accent-[#00F0FF]" />
|
||||
<label htmlFor="gdpr" className="text-[10px] text-[#86868B] leading-relaxed">
|
||||
{t("gdprAgreement")} <button type="button" onClick={() => setShowGdprModal(true)} className="text-[#1D1D1F] dark:text-white underline">{t("dataPrivacy")}</button>{t("gdprDesc")}
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isSuccess && (
|
||||
<div className="p-6 border-t border-black/5 dark:border-white/5 bg-white dark:bg-[#111] shrink-0">
|
||||
{mode === "CART" && subtotal > 0 && (
|
||||
<div className="flex justify-between items-end mb-4 px-1">
|
||||
<span className="text-xs text-[#86868B] uppercase tracking-widest">{t("estSubtotal")}</span>
|
||||
<div className="text-right">
|
||||
<p className="text-2xl font-light text-[#1D1D1F] dark:text-white font-mono">€{subtotal.toFixed(2)}</p>
|
||||
{hasItemsWithoutPrice && <p className="text-[10px] text-[#0066CC] dark:text-[#00F0FF]">{t("quotePending")}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => (document.getElementById("operations-form") as HTMLFormElement)?.requestSubmit()}
|
||||
disabled={isSubmitting || (mode === "CART" && cartItems.length === 0)}
|
||||
className="w-full bg-[#1D1D1F] dark:bg-white text-white dark:text-black py-4 rounded-xl text-sm font-semibold flex items-center justify-center gap-2 disabled:opacity-50 transition-transform active:scale-95 shadow-xl"
|
||||
>
|
||||
{isSubmitting || isUploading ? <><Loader2 size={18} className="animate-spin" /> {isUploading ? t("encrypting") : t("connecting")}</>
|
||||
: <>{mode === "DIAGNOSTIC" ? t("btnSubmitEngineering") : t("btnRequestComponents")} <ArrowRight size={16} /></>}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
<AnimatePresence>
|
||||
{showGdprModal && (
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="fixed inset-0 z-[100] bg-black/80 backdrop-blur-md flex items-center justify-center p-4">
|
||||
<div className="bg-[#F5F5F7] dark:bg-[#111] max-w-sm w-full rounded-[2rem] p-8 border border-black/5 dark:border-white/10 shadow-2xl">
|
||||
<ShieldCheck size={32} className="text-emerald-500 mb-4" />
|
||||
<h3 className="text-xl font-medium text-[#1D1D1F] dark:text-white mb-2">{t("modalTitle")}</h3>
|
||||
<p className="text-sm text-[#86868B] leading-relaxed mb-6">
|
||||
{t("modalDesc1")} <br/><br/>
|
||||
<strong>{t("modalDesc2")}</strong> {t("modalDesc3")} <strong>{t("modalDesc4")}</strong> {t("modalDesc5")}
|
||||
</p>
|
||||
<button onClick={() => setShowGdprModal(false)} className="w-full bg-[#1D1D1F] dark:bg-white text-white dark:text-black py-3 rounded-xl text-sm font-semibold">{t("understood")}</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { Link } from "@/i18n/routing";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getLocalizedData } from "@/lib/i18nHelper";
|
||||
import { getTranslations, getLocale } from "next-intl/server";
|
||||
// 🔥 IMPORTAMOS ESTO PARA ROMPER LA CACHÉ
|
||||
import { unstable_noStore as noStore } from "next/cache";
|
||||
|
||||
// Importamos nuestros componentes interactivos
|
||||
import { AiContactButton, AiFooterLink } from "@/components/ui/AiTriggerButton";
|
||||
|
||||
export default async function Footer() {
|
||||
noStore(); // 🔥 Esta línea asegura que el Footer SIEMPRE consulte Prisma al recargar
|
||||
|
||||
const locale = await getLocale();
|
||||
const t = await getTranslations("Footer");
|
||||
|
||||
let activeApps: any[] = [];
|
||||
try {
|
||||
const rawApps = await prisma.application.findMany({
|
||||
where: { isActive: true },
|
||||
orderBy: { createdAt: "asc" },
|
||||
take: 4
|
||||
});
|
||||
activeApps = rawApps.map((app: any) => getLocalizedData(app, locale));
|
||||
} catch (error) {
|
||||
console.error("Error loading apps in footer", error);
|
||||
}
|
||||
|
||||
return (
|
||||
<footer className="bg-[#1D1D1F] text-[#F5F5F7] pt-24 pb-12 rounded-t-[40px] mt-20 relative z-20 shadow-2xl">
|
||||
<div className="max-w-7xl mx-auto px-6">
|
||||
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-end mb-24 gap-12">
|
||||
<div>
|
||||
<h2 className="text-4xl md:text-6xl font-light tracking-tight mb-6">
|
||||
Ready to optimize <br />
|
||||
<span className="font-medium text-[#0066CC] dark:text-[#00F0FF] transition-colors">your production?</span>
|
||||
</h2>
|
||||
<p className="text-[#86868B] text-lg max-w-md font-light">
|
||||
Connect with our engineering team to calculate your ROI and explore custom RF solutions.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<AiContactButton />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-12 mb-24 border-t border-white/10 pt-16">
|
||||
|
||||
{/* 🔥 COLUMNA TECNOLOGÍA: AHORA DISPARA A LA IA 🔥 */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<span className="text-sm font-semibold tracking-widest text-[#86868B] uppercase mb-2">{t("techTitle")}</span>
|
||||
<AiFooterLink
|
||||
label={t("techSolidState")}
|
||||
prompt="Explain how FLUX solid-state RF technology works and its advantages over conventional methods."
|
||||
/>
|
||||
<AiFooterLink
|
||||
label={t("techMicrowave")}
|
||||
prompt="Compare Solid-State RF technology versus Microwave (2450 MHz) systems."
|
||||
/>
|
||||
<AiFooterLink
|
||||
label={t("techEfficiency")}
|
||||
prompt="Explain the energy efficiency and ROI of volumetric RF heating."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 🔥 COLUMNA APLICACIONES: 100% DINÁMICA Y ANTI-CACHÉ 🔥 */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<span className="text-sm font-semibold tracking-widest text-[#86868B] uppercase mb-2">{t("appsTitle")}</span>
|
||||
{activeApps.map(app => (
|
||||
<Link key={app.slug} href={`/applications/${app.slug}` as any} className="hover:text-[#0066CC] dark:hover:text-[#00F0FF] transition-colors font-light truncate">
|
||||
{app.title}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<span className="text-sm font-semibold tracking-widest text-[#86868B] uppercase mb-2">{t("companyTitle")}</span>
|
||||
<Link href="/heritage" className="hover:text-[#0066CC] dark:hover:text-[#00F0FF] transition-colors font-light">{t("companyStory")}</Link>
|
||||
<Link href="/#global" className="hover:text-[#0066CC] dark:hover:text-[#00F0FF] transition-colors font-light">{t("companyMap")}</Link>
|
||||
<Link href="/news" className="hover:text-[#0066CC] dark:hover:text-[#00F0FF] transition-colors font-light">{t("companyNews")}</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<span className="text-sm font-semibold tracking-widest text-[#86868B] uppercase mb-2">{t("hqTitle")}</span>
|
||||
<p className="text-[#86868B] font-light leading-relaxed">
|
||||
Via Benedetto Marcello 32 <br />
|
||||
36060 Romano d'Ezzelino <br />
|
||||
Vicenza, Italy
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row justify-between items-center border-t border-white/10 pt-8 text-sm text-[#86868B] font-light">
|
||||
<p>© {new Date().getFullYear()} FLUX Srl. {t("rights")}.</p>
|
||||
<div className="flex gap-6 mt-4 md:mt-0">
|
||||
<Link href="#" className="hover:text-white transition-colors">Privacy Policy</Link>
|
||||
<Link href="#" className="hover:text-white transition-colors">Terms of Service</Link>
|
||||
<span className="flex items-center gap-2 text-white">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-[#0066CC] dark:bg-[#00F0FF] animate-pulse"></span>
|
||||
{t("madeInItaly")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Globe, Moon, Sun, Sparkles, Menu, X, ChevronDown, ShoppingBag, UserCircle, Lock } from "lucide-react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { Link, usePathname, useRouter } from "@/i18n/routing";
|
||||
import { useUIStore } from "@/lib/store/uiStore";
|
||||
|
||||
const NAV_KEYS = [
|
||||
{ key: "applications", href: "/#applications-deep" },
|
||||
{ key: "globalMap", href: "/#global" },
|
||||
{ key: "ourStory", href: "/#our-story" },
|
||||
{ key: "insideFlux", href: "/news" },
|
||||
{ key: "parts", href: "/parts" },
|
||||
];
|
||||
|
||||
const LOCALES = [
|
||||
{ code: "en", label: "EN" },
|
||||
{ code: "it", label: "IT" },
|
||||
{ code: "vec", label: "VEC" },
|
||||
{ code: "es", label: "ES" },
|
||||
{ code: "de", label: "DE" }
|
||||
];
|
||||
|
||||
export default function NavBar() {
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const [isPastHero, setIsPastHero] = useState(false);
|
||||
const [isDark, setIsDark] = useState(false);
|
||||
|
||||
const [isTranslating, setIsTranslating] = useState(false);
|
||||
const [isLangMenuOpen, setIsLangMenuOpen] = useState(false);
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||
|
||||
const [logoLightError, setLogoLightError] = useState(false);
|
||||
const [logoDarkError, setLogoDarkError] = useState(false);
|
||||
|
||||
// 🔥 NUEVO ESTADO PARA SABER SI HAY SESIÓN B2B
|
||||
const [hasSession, setHasSession] = useState(false);
|
||||
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const t = useTranslations("Navigation");
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setScrolled(window.scrollY > 20);
|
||||
setIsPastHero(window.scrollY > window.innerHeight * 0.7);
|
||||
};
|
||||
|
||||
window.addEventListener("scroll", handleScroll);
|
||||
handleScroll();
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
||||
setIsLangMenuOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
|
||||
// Verificar si existe la cookie "flux_b2b_session"
|
||||
const checkSession = () => {
|
||||
const cookies = document.cookie.split("; ");
|
||||
const sessionExists = cookies.some(c => c.startsWith("flux_b2b_session="));
|
||||
setHasSession(sessionExists);
|
||||
};
|
||||
checkSession();
|
||||
// Re-chequear cuando el modal dispare un refresh
|
||||
const interval = setInterval(checkSession, 2000);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("scroll", handleScroll);
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const savedTheme = localStorage.getItem("flux-theme");
|
||||
|
||||
if (pathname.includes("/heritage")) {
|
||||
setIsDark(true);
|
||||
document.documentElement.classList.add("dark");
|
||||
} else {
|
||||
if (savedTheme === "dark") {
|
||||
setIsDark(true);
|
||||
document.documentElement.classList.add("dark");
|
||||
} else {
|
||||
setIsDark(false);
|
||||
document.documentElement.classList.remove("dark");
|
||||
}
|
||||
}
|
||||
}, [pathname]);
|
||||
|
||||
if (pathname.startsWith("/hq-command")) return null;
|
||||
|
||||
const toggleTheme = () => {
|
||||
const newTheme = isDark ? "light" : "dark";
|
||||
setIsDark(!isDark);
|
||||
if (newTheme === "dark") {
|
||||
document.documentElement.classList.add("dark");
|
||||
} else {
|
||||
document.documentElement.classList.remove("dark");
|
||||
}
|
||||
localStorage.setItem("flux-theme", newTheme);
|
||||
};
|
||||
|
||||
const switchLanguage = (newLocale: string) => {
|
||||
setIsLangMenuOpen(false);
|
||||
if (newLocale === locale) return;
|
||||
|
||||
setIsTranslating(true);
|
||||
setTimeout(() => {
|
||||
router.replace(pathname, { locale: newLocale });
|
||||
setIsTranslating(false);
|
||||
setIsMobileMenuOpen(false);
|
||||
}, 600);
|
||||
};
|
||||
|
||||
const isDarkEsthetic = isDark || isMobileMenuOpen || !isPastHero;
|
||||
|
||||
// 🔥 ESTILO DINÁMICO PARA EL BOTÓN B2B
|
||||
const b2bButtonClass = scrolled
|
||||
? (isDarkEsthetic
|
||||
? "bg-white/10 text-white hover:bg-white/20 border border-white/10"
|
||||
: "bg-black/5 text-[#1D1D1F] hover:bg-black/10 border border-black/5")
|
||||
: "bg-black/60 text-white backdrop-blur-md hover:bg-black/80 border border-white/10 shadow-lg";
|
||||
|
||||
return (
|
||||
<header className={`fixed top-0 w-full z-50 transition-all duration-700 flex justify-center ${scrolled ? "pt-3" : "pt-6 md:pt-8"}`}>
|
||||
|
||||
<nav className={`relative flex items-center justify-between px-6 md:px-8 py-3 mx-4 w-full max-w-6xl rounded-full transition-all duration-700 ${
|
||||
scrolled ? (
|
||||
isDarkEsthetic
|
||||
? "bg-[#0A0A0C]/80 backdrop-blur-3xl border border-white/10 shadow-[0_8px_32px_rgba(0,0,0,0.5)]"
|
||||
: "bg-white/70 backdrop-blur-2xl border border-white/60 shadow-[0_8px_32px_rgba(0,0,0,0.06)]"
|
||||
) : (
|
||||
isDarkEsthetic
|
||||
? "bg-[#0A0A0C]/40 backdrop-blur-xl border border-white/10 shadow-sm"
|
||||
: "bg-white/40 backdrop-blur-xl border border-white/40 shadow-sm"
|
||||
)
|
||||
}`}>
|
||||
|
||||
{/* LOGO LINK */}
|
||||
<Link href="/#technology" className="flex items-center gap-2 group z-50" onClick={() => setIsMobileMenuOpen(false)}>
|
||||
{!logoLightError ? (
|
||||
<img
|
||||
src="/flux-logo.svg"
|
||||
alt="FLUX Logo"
|
||||
className={`h-7 md:h-8 w-auto transition-all duration-500 group-hover:scale-105 ${isDarkEsthetic ? 'hidden' : 'block'}`}
|
||||
onError={() => setLogoLightError(true)}
|
||||
/>
|
||||
) : (
|
||||
<span className={`font-bold tracking-widest text-lg md:text-xl text-[#1D1D1F] ${isDarkEsthetic ? 'hidden' : 'block'}`}>FLUX</span>
|
||||
)}
|
||||
{!logoDarkError ? (
|
||||
<img
|
||||
src="/flux-logo.svg"
|
||||
alt="FLUX Logo Dark"
|
||||
className={`h-7 md:h-8 w-auto transition-all duration-500 group-hover:scale-105 ${isDarkEsthetic ? 'block' : 'hidden'}`}
|
||||
onError={() => setLogoDarkError(true)}
|
||||
/>
|
||||
) : (
|
||||
<span className={`font-bold tracking-widest text-lg md:text-xl text-white ${isDarkEsthetic ? 'block' : 'hidden'}`}>FLUX</span>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
{/* ENLACES CENTRALES */}
|
||||
<ul className="hidden md:flex items-center relative z-10" onMouseLeave={() => setHoveredIndex(null)}>
|
||||
{NAV_KEYS.map((item, index) => {
|
||||
const isActive = pathname === item.href || (item.href !== "/" && pathname.startsWith(item.href));
|
||||
const textClass = isDarkEsthetic
|
||||
? isActive ? "text-white" : "text-white/80 hover:text-white"
|
||||
: isActive ? "text-[#1D1D1F]" : "text-[#86868B hover:text-[#1D1D1F]";
|
||||
|
||||
return (
|
||||
<li key={item.key} className="relative" onMouseEnter={() => setHoveredIndex(index)}>
|
||||
<Link
|
||||
href={item.href as any}
|
||||
className={`relative px-5 py-2 text-sm font-medium transition-colors z-20 block ${textClass}`}
|
||||
>
|
||||
{t(item.key)}
|
||||
</Link>
|
||||
{hoveredIndex === index && (
|
||||
<motion.div
|
||||
layoutId="nav-hover"
|
||||
className={`absolute inset-0 rounded-full z-10 ${isDarkEsthetic ? "bg-white/[0.08]" : "bg-black/[0.04]"}`}
|
||||
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
||||
transition={{ type: "spring", bounce: 0.2, duration: 0.6 }}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{/* CONTROLES DERECHOS */}
|
||||
<div className={`hidden md:flex items-center gap-4 transition-colors z-10 ${isDarkEsthetic ? "text-white/80" : "text-[#86868B]"}`}>
|
||||
|
||||
{/* 🔥 BOTÓN DEL PORTAL B2B 🔥 */}
|
||||
<button
|
||||
onClick={() => window.dispatchEvent(new CustomEvent('flux:open-auth'))}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold uppercase tracking-widest transition-all ${b2bButtonClass}`}
|
||||
>
|
||||
{hasSession ? <UserCircle size={14} /> : <Lock size={14} />}
|
||||
{hasSession ? "Profile" : "B2B"}
|
||||
</button>
|
||||
|
||||
<div className={`w-px h-4 transition-colors ${isDarkEsthetic ? "bg-white/15" : "bg-black/10"}`}></div>
|
||||
|
||||
{/* BOTÓN DEL CARRITO */}
|
||||
<button
|
||||
onClick={() => useUIStore.getState().toggleCart()}
|
||||
className={`transition-colors relative w-6 h-6 flex items-center justify-center group ${isDarkEsthetic ? "text-white/80 hover:text-white" : "text-[#86868B] hover:text-[#1D1D1F]"}`}
|
||||
>
|
||||
<ShoppingBag size={18} strokeWidth={1.5} className="group-hover:scale-105 transition-transform" />
|
||||
<DynamicCartBadge />
|
||||
</button>
|
||||
|
||||
<div className={`w-px h-4 transition-colors ${isDarkEsthetic ? "bg-white/15" : "bg-black/10"}`}></div>
|
||||
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setIsLangMenuOpen(!isLangMenuOpen)} disabled={isTranslating}
|
||||
className={`flex items-center gap-1.5 transition-colors relative px-3 py-1.5 rounded-full ${isDarkEsthetic ? "bg-white/10 text-white hover:bg-white/20" : "bg-black/5 hover:bg-black/10 hover:text-[#1D1D1F]"}`}
|
||||
>
|
||||
{isTranslating ? (
|
||||
<motion.div animate={{ rotate: 360 }} transition={{ duration: 1.5, repeat: Infinity, ease: "linear" }}>
|
||||
<Sparkles size={14} className="text-[#00F0FF]" />
|
||||
</motion.div>
|
||||
) : (
|
||||
<Globe size={14} strokeWidth={1.5} />
|
||||
)}
|
||||
<span className={`text-xs font-semibold tracking-widest uppercase ${isTranslating ? 'text-[#00F0FF]' : ''}`}>{locale}</span>
|
||||
<ChevronDown size={12} className={`transition-transform duration-300 ${isLangMenuOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{isLangMenuOpen && (
|
||||
<motion.div initial={{ opacity: 0, y: 10, scale: 0.95 }} animate={{ opacity: 1, y: 0, scale: 1 }} exit={{ opacity: 0, y: 10, scale: 0.95 }} transition={{ duration: 0.15 }}
|
||||
className={`absolute top-full right-0 mt-2 w-24 border shadow-xl overflow-hidden flex flex-col p-1 rounded-2xl ${isDarkEsthetic ? "bg-[#111] border-white/10 shadow-[0_8px_32px_rgba(0,0,0,0.4)]" : "bg-white border-black/5"}`}
|
||||
>
|
||||
{LOCALES.map(l => {
|
||||
const isActiveLocale = locale === l.code;
|
||||
const itemClass = isActiveLocale
|
||||
? isDarkEsthetic ? "bg-[#00F0FF]/15 text-[#00F0FF]" : "bg-[#0066CC]/10 text-[#0066CC]"
|
||||
: isDarkEsthetic ? "text-white/70 hover:bg-white/5 hover:text-white" : "text-[#86868B] hover:bg-black/5 hover:text-[#1D1D1F]";
|
||||
|
||||
return (
|
||||
<button key={l.code} onClick={() => switchLanguage(l.code)} className={`text-xs font-medium px-4 py-2 rounded-xl text-left transition-colors ${itemClass}`}>
|
||||
{l.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div className={`w-px h-4 transition-colors ${isDarkEsthetic ? "bg-white/15" : "bg-black/10"}`}></div>
|
||||
|
||||
<button onClick={toggleTheme} className={`transition-colors relative w-5 h-5 flex items-center justify-center group ${isDarkEsthetic ? "text-white/80 hover:text-white" : "text-[#86868B] hover:text-[#1D1D1F]"}`}>
|
||||
<AnimatePresence mode="wait">
|
||||
{isDarkEsthetic ? (
|
||||
<motion.div key="sun" initial={{ opacity: 0, rotate: -90 }} animate={{ opacity: 1, rotate: 0 }} exit={{ opacity: 0, rotate: 90 }} className="absolute">
|
||||
<Sun size={18} strokeWidth={1.5} className="group-hover:text-[#00F0FF] transition-colors" />
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div key="moon" initial={{ opacity: 0, rotate: -90 }} animate={{ opacity: 1, rotate: 0 }} exit={{ opacity: 0, rotate: 90 }} className="absolute">
|
||||
<Moon size={18} strokeWidth={1.5} className="group-hover:text-[#0066CC] transition-colors" />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* BOTÓN DE MENÚ MÓVIL */}
|
||||
<button className={`md:hidden z-50 p-2 transition-colors ${isDarkEsthetic ? "text-white" : "text-[#1D1D1F]"}`} onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}>
|
||||
{isMobileMenuOpen ? <X size={20} /> : <Menu size={20} />}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{/* MENÚ MÓVIL */}
|
||||
<AnimatePresence>
|
||||
{isMobileMenuOpen && (
|
||||
<motion.div initial={{ opacity: 0, y: -20, scale: 0.95 }} animate={{ opacity: 1, y: 0, scale: 1 }} exit={{ opacity: 0, y: -20, scale: 0.95 }} transition={{ duration: 0.2 }}
|
||||
className="absolute top-20 left-4 right-4 bg-[#0A0A0C]/95 backdrop-blur-3xl border border-white/10 rounded-3xl shadow-2xl p-6 flex flex-col gap-6 md:hidden z-40"
|
||||
>
|
||||
<ul className="flex flex-col gap-4">
|
||||
{NAV_KEYS.map((item) => (
|
||||
<li key={item.key}>
|
||||
<Link href={item.href as any} onClick={() => setIsMobileMenuOpen(false)} className="text-lg font-medium text-white block">
|
||||
{t(item.key)}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="h-px w-full bg-white/10" />
|
||||
|
||||
{/* 🔥 BOTÓN B2B MÓVIL */}
|
||||
<button
|
||||
onClick={() => { setIsMobileMenuOpen(false); window.dispatchEvent(new CustomEvent('flux:open-auth')); }}
|
||||
className="flex items-center justify-center gap-2 bg-white/10 hover:bg-white/20 text-white px-4 py-3 rounded-xl text-sm font-semibold transition-colors"
|
||||
>
|
||||
{hasSession ? <UserCircle size={18} /> : <Lock size={18} />}
|
||||
{hasSession ? "B2B Profile" : "Access B2B Portal"}
|
||||
</button>
|
||||
|
||||
<div className="h-px w-full bg-white/10" />
|
||||
|
||||
<div>
|
||||
<span className="text-[10px] uppercase tracking-widest text-white/60 font-semibold mb-3 block">Language</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{LOCALES.map(l => (
|
||||
<button key={l.code} onClick={() => switchLanguage(l.code)} className={`px-4 py-2 rounded-xl text-xs font-semibold transition-all ${locale === l.code ? 'bg-[#00F0FF] text-black' : 'bg-white/5 text-white/70'}`}>
|
||||
{l.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-px w-full bg-white/10" />
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] uppercase tracking-widest text-white/60 font-semibold">Theme</span>
|
||||
<button onClick={toggleTheme} className="flex items-center gap-2 text-sm font-medium text-white/70 bg-white/5 px-4 py-2 rounded-xl">
|
||||
{isDark ? <><Sun size={16} /> Light Mode</> : <><Moon size={16} /> Dark Mode</>}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper para renderizar el contador del carrito
|
||||
function DynamicCartBadge() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const cartItems = useUIStore((state) => state.cartItems);
|
||||
|
||||
useEffect(() => { setMounted(true); }, []);
|
||||
|
||||
if (!mounted || cartItems.length === 0) return null;
|
||||
|
||||
const totalItems = cartItems.reduce((acc, item) => acc + item.quantity, 0);
|
||||
|
||||
return (
|
||||
<span className="absolute -top-1 -right-1 w-3.5 h-3.5 bg-rose-500 text-white text-[8px] font-bold flex items-center justify-center rounded-full border-2 border-transparent">
|
||||
{totalItems}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
|
||||
export default function NavigationManager() {
|
||||
const [isTransitioning, setIsTransitioning] = useState(false);
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// 1. GESTIÓN AL CARGAR UNA NUEVA PÁGINA (Ej. Vienes de "Inside Flux" hacia "Our Story")
|
||||
useEffect(() => {
|
||||
const handlePageLoad = () => {
|
||||
const hash = window.location.hash;
|
||||
|
||||
if (hash) {
|
||||
const targetId = hash.substring(1);
|
||||
const targetElement = document.getElementById(targetId);
|
||||
|
||||
if (targetElement) {
|
||||
// Desactivamos el smooth-scroll para evitar la fatiga visual de Next.js
|
||||
document.documentElement.classList.remove('scroll-smooth');
|
||||
|
||||
// Offset negativo: Frena 120px ANTES de la sección (espacio exacto para el NavBar)
|
||||
const yOffset = -120;
|
||||
const y = targetElement.getBoundingClientRect().top + window.scrollY + yOffset;
|
||||
|
||||
// Salto instantáneo e invisible
|
||||
window.scrollTo({ top: y, behavior: 'instant' as any });
|
||||
|
||||
// Reactivamos el scroll suave
|
||||
setTimeout(() => document.documentElement.classList.add('scroll-smooth'), 100);
|
||||
} else if (targetId === "technology") {
|
||||
// Si es technology, forzamos la posición arriba (Y=0)
|
||||
document.documentElement.classList.remove('scroll-smooth');
|
||||
window.scrollTo({ top: 0, behavior: 'instant' as any });
|
||||
setTimeout(() => document.documentElement.classList.add('scroll-smooth'), 100);
|
||||
}
|
||||
}
|
||||
|
||||
// Levantamos el telón suavemente
|
||||
setIsTransitioning(false);
|
||||
};
|
||||
|
||||
// Le damos un respiro minúsculo al DOM para que pinte las secciones antes de saltar
|
||||
setTimeout(handlePageLoad, 50);
|
||||
}, [pathname, searchParams]);
|
||||
|
||||
// 2. GESTIÓN DE CLICS INTERACTIVOS
|
||||
useEffect(() => {
|
||||
const handleAnchorClick = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const anchor = target.closest('a');
|
||||
if (!anchor) return;
|
||||
|
||||
const href = anchor.getAttribute('href');
|
||||
if (!href) return;
|
||||
|
||||
// Ignorar links externos o rutas internas de sistema
|
||||
if (anchor.target === '_blank' || href.startsWith('/api') || href.startsWith('/_next')) return;
|
||||
|
||||
const url = new URL(anchor.href);
|
||||
const isSamePage = url.pathname === window.location.pathname;
|
||||
const hasHash = url.hash.length > 0;
|
||||
|
||||
// ESCENARIO A: Viaje a otra sección dentro de la MISMA PÁGINA
|
||||
if (isSamePage && hasHash) {
|
||||
e.preventDefault();
|
||||
const targetId = url.hash.substring(1);
|
||||
const targetElement = document.getElementById(targetId);
|
||||
|
||||
if (targetElement) {
|
||||
setIsTransitioning(true); // Bajamos el telón
|
||||
|
||||
setTimeout(() => {
|
||||
document.documentElement.classList.remove('scroll-smooth');
|
||||
|
||||
// 🔥 El Offset Matemático Perfecto (-120px) 🔥
|
||||
const yOffset = -120;
|
||||
const y = targetElement.getBoundingClientRect().top + window.scrollY + yOffset;
|
||||
|
||||
// ¡El salto mágico!
|
||||
window.scrollTo({ top: y, behavior: 'instant' as any });
|
||||
|
||||
setTimeout(() => {
|
||||
setIsTransitioning(false); // Levantamos telón
|
||||
setTimeout(() => document.documentElement.classList.add('scroll-smooth'), 100);
|
||||
}, 50);
|
||||
}, 400);
|
||||
} else if (targetId === "technology") {
|
||||
// Si el elemento no existe pero el destino es el Hero, nos vamos al tope
|
||||
setIsTransitioning(true);
|
||||
setTimeout(() => {
|
||||
document.documentElement.classList.remove('scroll-smooth');
|
||||
window.scrollTo({ top: 0, behavior: 'instant' as any });
|
||||
setTimeout(() => {
|
||||
setIsTransitioning(false);
|
||||
setTimeout(() => document.documentElement.classList.add('scroll-smooth'), 100);
|
||||
}, 50);
|
||||
}, 400);
|
||||
}
|
||||
}
|
||||
// ESCENARIO B: Viaje a OTRA PÁGINA
|
||||
else if (!isSamePage) {
|
||||
// Le quitamos el smooth-scroll a la página vieja para que Next.js no anime la nueva página al cargar
|
||||
document.documentElement.classList.remove('scroll-smooth');
|
||||
setIsTransitioning(true); // Bajamos telón y dejamos que Next haga su magia
|
||||
|
||||
// Seguro de vida
|
||||
setTimeout(() => setIsTransitioning(false), 1500);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('click', handleAnchorClick);
|
||||
return () => document.removeEventListener('click', handleAnchorClick);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed inset-0 bg-[#F5F5F7] dark:bg-[#050505] z-[45] pointer-events-none transition-opacity duration-400 ease-in-out ${
|
||||
isTransitioning ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { ArrowRight, Zap, Scale, ShieldCheck, Cpu } from "lucide-react";
|
||||
// 🔥 Importamos Link de nuestro i18n
|
||||
import { Link } from "@/i18n/routing";
|
||||
import { useTranslations } from "next-intl"; // 🔥
|
||||
|
||||
export default function ApplicationsDashboard({ dbApps = [] }: { dbApps?: any[] }) {
|
||||
const activeApps = dbApps.filter(app => app.isActive);
|
||||
if (!activeApps || activeApps.length === 0) return null;
|
||||
|
||||
const [activeSlug, setActiveSlug] = useState(activeApps[0]?.slug);
|
||||
const activeApp = activeApps.find(app => app.slug === activeSlug) || activeApps[0];
|
||||
|
||||
const t = useTranslations("AppsDashboard"); // 🔥 LLAMAMOS AL DICCIONARIO
|
||||
|
||||
let metrics = [];
|
||||
try { metrics = JSON.parse(activeApp.dashboardMetricsJson || "[]"); } catch (e) {}
|
||||
|
||||
const triggerFluxAI = (prompt: string) => {
|
||||
window.dispatchEvent(new CustomEvent("flux:trigger-ai", { detail: { prompt } }));
|
||||
};
|
||||
|
||||
if (!activeApp) return null;
|
||||
|
||||
return (
|
||||
<section id="applications-dashboard" className="relative w-full max-w-7xl mx-auto px-6 py-32 z-10">
|
||||
|
||||
<div className="mb-16 max-w-2xl">
|
||||
<h2 className="text-sm font-semibold tracking-widest text-[#0066CC] dark:text-[#4DA6FF] uppercase mb-4 flex items-center gap-2">
|
||||
<Cpu size={16} /> {t("subtitle")}
|
||||
</h2>
|
||||
<h3 className="text-4xl md:text-5xl font-light text-[#1D1D1F] dark:text-[#F5F5F7] tracking-tight mb-6 leading-[1.1]">
|
||||
{t("title1")} <br />
|
||||
<span className="text-[#86868B] dark:text-[#A1A1A6]">{t("title2")}</span>
|
||||
</h3>
|
||||
<p className="text-lg text-[#1D1D1F]/70 dark:text-[#F5F5F7]/70 font-light leading-relaxed">
|
||||
{t("desc")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col lg:flex-row gap-8 lg:gap-12 min-h-[500px]">
|
||||
|
||||
<div className="w-full lg:w-1/3 flex flex-col gap-2">
|
||||
{activeApps.map((app) => (
|
||||
<button
|
||||
key={app.slug}
|
||||
onClick={() => setActiveSlug(app.slug)}
|
||||
className={`flex items-center gap-4 px-6 py-5 rounded-2xl transition-all duration-300 text-left border ${
|
||||
activeSlug === app.slug
|
||||
? "bg-white dark:bg-[#1D1D1F] border-black/5 dark:border-white/10 shadow-lg text-[#1D1D1F] dark:text-white"
|
||||
: "bg-transparent border-transparent text-[#86868B] dark:text-[#A1A1A6] hover:bg-black/5 dark:hover:bg-white/5"
|
||||
}`}
|
||||
>
|
||||
<div className={`p-2 rounded-xl ${activeSlug === app.slug ? 'bg-[#0066CC]/10 text-[#0066CC] dark:bg-[#4DA6FF]/10 dark:text-[#4DA6FF]' : 'bg-transparent text-current'}`}>
|
||||
{app.slug.includes("food") ? <ShieldCheck size={20} /> : <Zap size={20} />}
|
||||
</div>
|
||||
<span className="text-base font-medium">{app.title}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="w-full lg:w-2/3">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={activeApp.slug}
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
transition={{ duration: 0.4, ease: "easeOut" }}
|
||||
className="bg-white/60 dark:bg-[#1D1D1F]/40 backdrop-blur-2xl border border-white/60 dark:border-white/10 p-8 md:p-12 rounded-[2.5rem] shadow-xl flex flex-col h-full justify-between"
|
||||
>
|
||||
<div>
|
||||
<h4 className="text-3xl md:text-4xl font-light text-[#1D1D1F] dark:text-white mb-4">
|
||||
{activeApp.title}
|
||||
</h4>
|
||||
|
||||
<p className="text-base md:text-lg text-[#86868B] dark:text-[#A1A1A6] font-light leading-relaxed mb-12">
|
||||
{activeApp.shortDescription}
|
||||
</p>
|
||||
|
||||
{metrics.length > 0 && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-6 mb-12 border-t border-black/5 dark:border-white/5 pt-8">
|
||||
{metrics.map((m: any, i: number) => (
|
||||
<div key={i}>
|
||||
<span className="text-[10px] uppercase tracking-widest text-[#86868B] block mb-2 font-semibold">{m.label}</span>
|
||||
<span className="text-2xl md:text-3xl text-[#0066CC] dark:text-[#4DA6FF] font-light block mb-1">{m.value}</span>
|
||||
<span className="text-xs text-[#1D1D1F] dark:text-[#E5E5EA]">{m.subtext}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row items-center gap-3 pt-8 border-t border-black/5 dark:border-white/5">
|
||||
<button
|
||||
onClick={() => triggerFluxAI(`Calculate energy savings and ROI for ${activeApp.title} compared to traditional methods.`)}
|
||||
className="w-full md:w-auto flex items-center justify-center gap-2 bg-[#1D1D1F] dark:bg-white text-white dark:text-[#1D1D1F] px-5 py-3 rounded-xl text-xs font-semibold hover:scale-105 transition-transform"
|
||||
>
|
||||
<Zap size={14} /> {t("calcROI")}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => triggerFluxAI(`Show me a comparison table between RF and traditional heating for ${activeApp.title}.`)}
|
||||
className="w-full md:w-auto flex items-center justify-center gap-2 bg-white dark:bg-[#1D1D1F] border border-black/10 dark:border-white/10 text-[#1D1D1F] dark:text-white px-5 py-3 rounded-xl text-xs font-semibold hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<Scale size={14} /> {t("compareTech")}
|
||||
</button>
|
||||
|
||||
<Link
|
||||
href={`/applications/${activeApp.slug}` as any}
|
||||
className="w-full md:w-auto flex items-center justify-center gap-2 text-[#0066CC] dark:text-[#4DA6FF] px-5 py-3 rounded-xl text-xs font-semibold hover:underline ml-auto group"
|
||||
>
|
||||
{t("viewSpecs")} <ArrowRight size={14} className="group-hover:translate-x-1 transition-transform" />
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { ArrowRight, Waves, Microscope, Snowflake, ShieldCheck, ThermometerSun, FlaskConical, Box, Droplets, Zap, Leaf } from "lucide-react";
|
||||
import { Link } from "@/i18n/routing";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
const getIconForSlug = (slug: string) => {
|
||||
if (slug.includes("textile")) return Waves;
|
||||
if (slug.includes("lab")) return Microscope;
|
||||
if (slug.includes("temper") || slug.includes("defrost")) return Snowflake;
|
||||
if (slug.includes("pasteuriz")) return ShieldCheck;
|
||||
if (slug.includes("bak")) return ThermometerSun;
|
||||
if (slug.includes("vulcaniz")) return FlaskConical;
|
||||
if (slug.includes("foam")) return Box;
|
||||
if (slug.includes("print")) return Droplets;
|
||||
if (slug.includes("cannabis") || slug.includes("herb")) return Leaf;
|
||||
return Zap;
|
||||
};
|
||||
|
||||
export default function ApplicationsDeep({ dbApps = [] }: { dbApps?: any[] }) {
|
||||
const t = useTranslations("AppsDeep");
|
||||
const activeApps = dbApps.filter(app => app.isActive);
|
||||
|
||||
if (activeApps.length === 0) return null;
|
||||
|
||||
return (
|
||||
// FIX: overflow-hidden en la section evita que las tarjetas con hover
|
||||
// o las animaciones framer-motion desborden el viewport en Safari iOS
|
||||
<section
|
||||
id="applications-deep"
|
||||
className="relative w-full max-w-7xl mx-auto px-4 md:px-6 py-24 z-10 overflow-hidden"
|
||||
>
|
||||
{/* CABECERA — FIX: recortamos el texto al ancho del contenedor */}
|
||||
<div className="text-center mb-16 md:mb-20 relative w-full overflow-hidden">
|
||||
<div className="absolute inset-0 bg-white/40 dark:bg-black/40 backdrop-blur-xl rounded-full scale-150 -z-10 [mask-image:radial-gradient(ellipse_at_center,black_40%,transparent_70%)]" />
|
||||
<h2 className="text-sm font-semibold tracking-widest text-[#0066CC] dark:text-[#4DA6FF] uppercase mb-4 relative z-10 transition-colors">
|
||||
{t("subtitle")}
|
||||
</h2>
|
||||
{/* FIX: text-3xl en móvil (era text-4xl) para que no desborde */}
|
||||
<h3 className="text-3xl md:text-5xl font-light text-[#1D1D1F] dark:text-[#F5F5F7] tracking-tight relative z-10 transition-colors px-2">
|
||||
{t("title1")} <span className="font-medium italic">{t("title2")}</span>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* GRID — FIX: en móvil una sola columna con ancho controlado */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
|
||||
{activeApps.map((app, index) => {
|
||||
const Icon = getIconForSlug(app.slug);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={app.slug}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-50px" }}
|
||||
transition={{ duration: 0.5, delay: index * 0.07 }}
|
||||
// FIX: quitamos whileHover={{ y: -5 }} — ese transform negativo
|
||||
// en Y crea un bounding rect fuera del contenedor en Safari iOS
|
||||
// y reactiva el scroll horizontal. Lo reemplazamos con CSS puro.
|
||||
className="relative bg-white/60 dark:bg-[#1D1D1F]/40 backdrop-blur-2xl border border-white/60 dark:border-white/10 p-6 md:p-8 rounded-[2rem] shadow-[0_8px_32px_rgba(0,0,0,0.04)] dark:shadow-[inset_0_1px_2px_rgba(255,255,255,0.05),0_8px_32px_rgba(0,0,0,0.4)] flex flex-col group transition-all duration-500 overflow-hidden hover:-translate-y-1"
|
||||
>
|
||||
<div className="absolute top-0 left-0 w-full h-1/2 bg-gradient-to-b from-white/[0.03] to-transparent pointer-events-none hidden dark:block" />
|
||||
|
||||
<div className="p-4 bg-[#0066CC]/10 dark:bg-[#0066CC]/20 w-fit rounded-2xl mb-6 text-[#0066CC] dark:text-[#4DA6FF] relative z-10 transition-colors">
|
||||
<Icon size={24} strokeWidth={1.5} />
|
||||
</div>
|
||||
|
||||
<h3 className="text-xl font-medium text-[#1D1D1F] dark:text-[#F5F5F7] mb-1 transition-colors relative z-10">
|
||||
{app.title}
|
||||
</h3>
|
||||
<h4 className="text-[10px] font-semibold uppercase tracking-widest text-[#86868B] dark:text-[#A1A1A6] mb-4 relative z-10 transition-colors">
|
||||
{app.subtitle}
|
||||
</h4>
|
||||
|
||||
<p className="text-[#86868B] dark:text-[#A1A1A6] text-sm leading-relaxed font-light mb-8 flex-grow transition-colors relative z-10">
|
||||
{app.shortDescription}
|
||||
</p>
|
||||
|
||||
<div className="mt-auto pt-6 border-t border-black/5 dark:border-white/5 transition-colors relative z-10">
|
||||
<Link
|
||||
href={`/applications/${app.slug}` as any}
|
||||
className="flex items-center justify-between text-sm font-medium text-[#1D1D1F] dark:text-[#F5F5F7] hover:text-[#0066CC] dark:hover:text-[#4DA6FF] transition-colors group/link"
|
||||
>
|
||||
{t("moreInfo")}
|
||||
<span className="w-8 h-8 rounded-full bg-black/5 dark:bg-white/10 flex items-center justify-center group-hover/link:bg-[#0066CC] group-hover/link:text-white transition-all shrink-0">
|
||||
<ArrowRight size={14} />
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,821 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, Suspense, useEffect } from "react";
|
||||
import { Canvas, useFrame, useLoader, useThree } from "@react-three/fiber";
|
||||
import { OrbitControls, QuadraticBezierLine } from "@react-three/drei";
|
||||
import * as THREE from "three";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { MapPin, Calendar, X, ArrowUpRight, Globe, Package, Building2, Layers } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import CaseStudyModal, { CaseStudyData } from "@/components/ui/CaseStudyModal";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
const RADIUS = 2;
|
||||
const CAM_FOV = 50;
|
||||
|
||||
function latLonToVec3(lat: number, lon: number, r: number) {
|
||||
const phi = (90 - lat) * (Math.PI / 180);
|
||||
const theta = (lon + 180) * (Math.PI / 180);
|
||||
return new THREE.Vector3(
|
||||
-(r * Math.sin(phi) * Math.cos(theta)),
|
||||
r * Math.cos(phi),
|
||||
r * Math.sin(phi) * Math.sin(theta)
|
||||
);
|
||||
}
|
||||
|
||||
// ─── ANIMATED COUNTER ───────────────────────────────────────
|
||||
function AnimatedCounter({ target }: { target: number }) {
|
||||
const [n, setN] = useState(0);
|
||||
useEffect(() => {
|
||||
if (target === 0) { setN(0); return; }
|
||||
let v = 0;
|
||||
const step = target / 75;
|
||||
const id = setInterval(() => {
|
||||
v = Math.min(v + step, target);
|
||||
setN(Math.floor(v));
|
||||
if (v >= target) clearInterval(id);
|
||||
}, 16);
|
||||
return () => clearInterval(id);
|
||||
}, [target]);
|
||||
return <>{n}</>;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// GLOBE MODE "classic": original style from the old script
|
||||
// earth-water.png with tinted blending — minimal/holographic look
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function EarthMeshClassic({ isDark }: { isDark: boolean }) {
|
||||
const waterTex = useLoader(THREE.TextureLoader, "https://unpkg.com/three-globe/example/img/earth-water.png");
|
||||
const { gl } = useThree();
|
||||
useEffect(() => {
|
||||
if (!waterTex) return;
|
||||
waterTex.anisotropy = gl.capabilities.getMaxAnisotropy();
|
||||
waterTex.minFilter = THREE.LinearMipmapLinearFilter;
|
||||
waterTex.magFilter = THREE.LinearFilter;
|
||||
waterTex.colorSpace = THREE.SRGBColorSpace;
|
||||
waterTex.generateMipmaps = true;
|
||||
waterTex.needsUpdate = true;
|
||||
}, [waterTex, gl]);
|
||||
return (
|
||||
<mesh>
|
||||
<sphereGeometry args={[RADIUS * 0.99, 64, 64]} />
|
||||
<meshBasicMaterial
|
||||
map={waterTex}
|
||||
// Dark: teal/cyan glow like original; Light: slate blue-grey
|
||||
color={isDark ? "#06F5E1" : "#5A82A8"}
|
||||
transparent
|
||||
opacity={isDark ? 0.42 : 0.32}
|
||||
blending={isDark ? THREE.AdditiveBlending : THREE.NormalBlending}
|
||||
/>
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// GLOBE MODE "photo": realistic NASA day/night textures
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function EarthMeshPhoto({ isDark }: { isDark: boolean }) {
|
||||
const dayTex = useLoader(THREE.TextureLoader, "https://cdn.jsdelivr.net/npm/three-globe/example/img/earth-day.jpg");
|
||||
const nightTex = useLoader(THREE.TextureLoader, "https://cdn.jsdelivr.net/npm/three-globe/example/img/earth-night.jpg");
|
||||
const topoTex = useLoader(THREE.TextureLoader, "https://cdn.jsdelivr.net/npm/three-globe/example/img/earth-blue-marble.jpg");
|
||||
const { gl } = useThree();
|
||||
useEffect(() => {
|
||||
[dayTex, nightTex, topoTex].forEach(t => {
|
||||
if (!t) return;
|
||||
t.anisotropy = gl.capabilities.getMaxAnisotropy();
|
||||
t.minFilter = THREE.LinearMipmapLinearFilter;
|
||||
t.magFilter = THREE.LinearFilter;
|
||||
t.colorSpace = THREE.SRGBColorSpace;
|
||||
t.generateMipmaps = true;
|
||||
t.wrapS = THREE.ClampToEdgeWrapping;
|
||||
t.wrapT = THREE.ClampToEdgeWrapping;
|
||||
t.needsUpdate = true;
|
||||
});
|
||||
}, [dayTex, nightTex, topoTex, gl]);
|
||||
if (isDark) {
|
||||
return (
|
||||
<>
|
||||
<mesh><sphereGeometry args={[RADIUS * 0.990, 128, 128]} /><meshBasicMaterial map={topoTex} transparent opacity={0.55} /></mesh>
|
||||
<mesh><sphereGeometry args={[RADIUS * 0.992, 128, 128]} /><meshBasicMaterial map={nightTex} transparent opacity={0.80} blending={THREE.AdditiveBlending} /></mesh>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<mesh><sphereGeometry args={[RADIUS * 0.991, 128, 128]} /><meshBasicMaterial map={dayTex} transparent opacity={0.95} /></mesh>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// PULSE RING — radar indicator on selected node
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function PulseRing({ pos, color, camDist }: { pos: THREE.Vector3; color: string; camDist: React.MutableRefObject<number> }) {
|
||||
const ref = useRef<THREE.Mesh>(null);
|
||||
const q = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 0, 1), pos.clone().normalize());
|
||||
useFrame(({ clock }) => {
|
||||
if (!ref.current) return;
|
||||
const t = clock.getElapsedTime();
|
||||
const pulse = 1 + Math.sin(t * 2.6) * 0.30;
|
||||
const zf = Math.max(0.15, Math.min(1.0, (camDist.current - 2.8) / 4.5));
|
||||
ref.current.scale.setScalar(pulse * zf);
|
||||
(ref.current.material as THREE.MeshBasicMaterial).opacity = (0.70 - Math.sin(t * 2.6) * 0.28) * Math.min(1, zf * 1.5);
|
||||
});
|
||||
return (
|
||||
<mesh ref={ref} position={pos} quaternion={q}>
|
||||
<ringGeometry args={[0.046, 0.057, 64]} />
|
||||
<meshBasicMaterial color={color} transparent opacity={0.70} depthWrite={false} side={THREE.DoubleSide} />
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// MAP NODE — zoom-responsive size + per-mode line colors
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function MapNode({ marker, isSelected, hqPos, onSelect, isDark, globeMode, camDist }: any) {
|
||||
const gRef = useRef<THREE.Group>(null);
|
||||
const hitRef = useRef<THREE.Mesh>(null);
|
||||
const pos = latLonToVec3(marker.lat, marker.lon, RADIUS);
|
||||
const isHQ = marker.nodeType === "hq";
|
||||
const isEvent = marker.nodeType === "event";
|
||||
|
||||
// ── NODE COLORS ──
|
||||
// Classic mode replicates original script exactly
|
||||
// Photo mode uses HIGH CONTRAST colors (orange/yellow for installations) to stand out on blue ocean
|
||||
const color = isHQ
|
||||
? (isDark ? "#FFFFFF" : "#1D1D1F")
|
||||
: isEvent
|
||||
? (globeMode === "classic"
|
||||
? "#A855F7" // original purple
|
||||
: (isDark ? "#E879F9" : "#9333EA")) // vivid purple on photo
|
||||
: (globeMode === "classic"
|
||||
? "#0066CC" // original blue
|
||||
: (isDark ? "#FACC15" : "#F97316")); // YELLOW/ORANGE — visible on blue ocean
|
||||
|
||||
// Smaller base sizes for better zoom behavior
|
||||
const baseSize = isHQ ? 0.035 : isEvent ? 0.026 : 0.018;
|
||||
|
||||
useFrame(({ camera }) => {
|
||||
if (!gRef.current) return;
|
||||
const d = camera.position.length();
|
||||
camDist.current = d;
|
||||
// Scale DOWN more aggressively when zooming IN
|
||||
// At max zoom out (d=10), scale ≈ 0.65; At zoom in (d=3), scale ≈ 0.15
|
||||
const scaleFactor = Math.max(0.15, (d - 1) / 14);
|
||||
gRef.current.scale.setScalar(scaleFactor * (isSelected ? 1.5 : 1.0));
|
||||
});
|
||||
|
||||
const dist = hqPos.distanceTo(pos);
|
||||
const apex = hqPos.clone().lerp(pos, 0.5).normalize()
|
||||
.multiplyScalar(RADIUS + dist * 0.28 + 0.14);
|
||||
|
||||
// ── ARC LINE COLORS & OPACITY ──
|
||||
// Photo mode uses high-contrast colors (orange/yellow) instead of blue
|
||||
let arcColor: string;
|
||||
let arcOpacity: number;
|
||||
let arcWidth: number;
|
||||
|
||||
if (isSelected) {
|
||||
arcColor = color;
|
||||
arcOpacity = 0.95;
|
||||
arcWidth = 2.0;
|
||||
} else if (globeMode === "classic") {
|
||||
// Classic sphere is dark/slate — cyan/white lines work perfectly
|
||||
arcColor = isDark ? "#40FFEE" : "#FFFFFF";
|
||||
arcOpacity = isDark ? 0.28 : 0.55;
|
||||
arcWidth = 1.2;
|
||||
} else {
|
||||
// Photo mode — HIGH CONTRAST lines (orange/yellow) visible over blue ocean
|
||||
if (isDark) {
|
||||
arcColor = isEvent ? "#E879F9" : "#FACC15"; // Yellow for installations
|
||||
arcOpacity = 0.50;
|
||||
arcWidth = 1.0;
|
||||
} else {
|
||||
// Light + photo: orange/magenta lines visible on blue/green map
|
||||
arcColor = isEvent ? "#9333EA" : "#F97316"; // Orange for installations
|
||||
arcOpacity = 0.70;
|
||||
arcWidth = 1.3;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<group>
|
||||
<group ref={gRef} position={pos}>
|
||||
{/* Glow halo */}
|
||||
<mesh>
|
||||
<sphereGeometry args={[baseSize * 2.0, 16, 16]} />
|
||||
<meshBasicMaterial color={color} transparent opacity={isSelected ? 0.20 : 0.07} depthWrite={false} />
|
||||
</mesh>
|
||||
{/* Core */}
|
||||
<mesh>
|
||||
<sphereGeometry args={[baseSize, 28, 28]} />
|
||||
<meshBasicMaterial color={color} />
|
||||
</mesh>
|
||||
{/* Hit area */}
|
||||
<mesh ref={hitRef} visible={false}
|
||||
onClick={e => { e.stopPropagation(); onSelect(isSelected ? null : marker.id); }}
|
||||
onPointerOver={e => { e.stopPropagation(); document.body.style.cursor = "pointer"; }}
|
||||
onPointerOut={e => { e.stopPropagation(); document.body.style.cursor = "auto"; }}>
|
||||
<sphereGeometry args={[baseSize * 5, 10, 10]} />
|
||||
<meshBasicMaterial transparent opacity={0} />
|
||||
</mesh>
|
||||
</group>
|
||||
|
||||
{isSelected && <PulseRing pos={pos} color={color} camDist={camDist} />}
|
||||
|
||||
{!isHQ && (
|
||||
<QuadraticBezierLine
|
||||
start={hqPos} end={pos} mid={apex}
|
||||
color={arcColor}
|
||||
lineWidth={arcWidth}
|
||||
transparent
|
||||
opacity={arcOpacity}
|
||||
/>
|
||||
)}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// GLOBE 3D — two visual modes assembled here
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function Globe3D({ filter, subFilter, selected, onSelect, isDark, nodes, hqPos, globeMode, camDist }: any) {
|
||||
const gRef = useRef<THREE.Group>(null);
|
||||
|
||||
// 🔥 SMART AUTO-ROTATION 🔥
|
||||
// Rotates when: no node selected AND camera is zoomed out (distance >= 6.0)
|
||||
// Stops when: user zooms in closer OR selects a node
|
||||
useFrame(({ camera }) => {
|
||||
if (!gRef.current) return;
|
||||
const distance = camera.position.length();
|
||||
|
||||
// Auto-rotate only when zoomed out and no selection
|
||||
// Camera starts at 6.5, so >= 6.0 ensures it rotates by default
|
||||
if (!selected && distance >= 6.0) {
|
||||
gRef.current.rotation.y += 0.0005; // Same speed as original
|
||||
}
|
||||
});
|
||||
|
||||
if (globeMode === "classic") {
|
||||
// ── CLASSIC: replicates original script exactly ──────────
|
||||
return (
|
||||
<group ref={gRef}>
|
||||
{/* Atmosphere shell */}
|
||||
<mesh>
|
||||
<sphereGeometry args={[RADIUS * 1.04, 64, 64]} />
|
||||
<meshBasicMaterial
|
||||
color={isDark ? "#00F0FF" : "#0066CC"}
|
||||
transparent opacity={isDark ? 0.10 : 0.05}
|
||||
side={THREE.BackSide} blending={THREE.AdditiveBlending} depthWrite={false}
|
||||
/>
|
||||
</mesh>
|
||||
{/* Base sphere */}
|
||||
<mesh>
|
||||
<sphereGeometry args={[RADIUS * 0.98, 64, 64]} />
|
||||
<meshBasicMaterial color={isDark ? "#050505" : "#D8E8F0"} />
|
||||
</mesh>
|
||||
{/* Earth texture — original water.png style */}
|
||||
<EarthMeshClassic isDark={isDark} />
|
||||
{/* Wireframe grid */}
|
||||
<mesh>
|
||||
<sphereGeometry args={[RADIUS, 64, 64]} />
|
||||
<meshBasicMaterial
|
||||
color={isDark ? "#0066CC" : "#7AAECC"}
|
||||
wireframe transparent opacity={isDark ? 0.06 : 0.08}
|
||||
/>
|
||||
</mesh>
|
||||
{nodes.map((m: any) => {
|
||||
const isHQ = m.nodeType === "hq";
|
||||
const isEv = m.nodeType === "event";
|
||||
const ok = filter === "all" || (filter === "installation" && !isEv && !isHQ) || (filter === "event" && isEv) || (filter === "legacy" && isHQ);
|
||||
const okSub = !subFilter || m.application === subFilter || isHQ;
|
||||
if (!ok || !okSub) return null;
|
||||
return <MapNode key={m.id} marker={m} isSelected={selected === m.id}
|
||||
hqPos={hqPos} onSelect={onSelect} isDark={isDark} globeMode="classic" camDist={camDist} />;
|
||||
})}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
// ── PHOTO: realistic satellite imagery ──────────────────────
|
||||
return (
|
||||
<group ref={gRef}>
|
||||
{/* Outer atmosphere */}
|
||||
<mesh>
|
||||
<sphereGeometry args={[RADIUS * 1.075, 64, 64]} />
|
||||
<meshBasicMaterial color={isDark ? "#0D4A8A" : "#93C5FD"}
|
||||
transparent opacity={isDark ? 0.13 : 0.20}
|
||||
side={THREE.BackSide} depthWrite={false}
|
||||
blending={isDark ? THREE.AdditiveBlending : THREE.NormalBlending} />
|
||||
</mesh>
|
||||
{/* Inner limb */}
|
||||
<mesh>
|
||||
<sphereGeometry args={[RADIUS * 1.024, 64, 64]} />
|
||||
<meshBasicMaterial color={isDark ? "#2280CC" : "#BFDBFE"}
|
||||
transparent opacity={isDark ? 0.09 : 0.13}
|
||||
side={THREE.BackSide} depthWrite={false} />
|
||||
</mesh>
|
||||
{/* Ocean base */}
|
||||
<mesh>
|
||||
<sphereGeometry args={[RADIUS * 0.987, 64, 64]} />
|
||||
<meshBasicMaterial color={isDark ? "#040E22" : "#B8D8EE"} />
|
||||
</mesh>
|
||||
<EarthMeshPhoto isDark={isDark} />
|
||||
{/* Wireframe grid — very subtle */}
|
||||
<mesh>
|
||||
<sphereGeometry args={[RADIUS * 1.001, 48, 24]} />
|
||||
<meshBasicMaterial color={isDark ? "#1E72B8" : "#6BA8CC"}
|
||||
wireframe transparent opacity={isDark ? 0.05 : 0.06} depthWrite={false} />
|
||||
</mesh>
|
||||
{/* Equator ring */}
|
||||
<mesh rotation={[Math.PI / 2, 0, 0]}>
|
||||
<ringGeometry args={[RADIUS * 1.007, RADIUS * 1.012, 128]} />
|
||||
<meshBasicMaterial color={isDark ? "#2280CC" : "#93C5FD"}
|
||||
transparent opacity={isDark ? 0.20 : 0.22}
|
||||
side={THREE.DoubleSide} depthWrite={false} />
|
||||
</mesh>
|
||||
{nodes.map((m: any) => {
|
||||
const isHQ = m.nodeType === "hq";
|
||||
const isEv = m.nodeType === "event";
|
||||
const ok = filter === "all" || (filter === "installation" && !isEv && !isHQ) || (filter === "event" && isEv) || (filter === "legacy" && isHQ);
|
||||
const okSub = !subFilter || m.application === subFilter || isHQ;
|
||||
if (!ok || !okSub) return null;
|
||||
return <MapNode key={m.id} marker={m} isSelected={selected === m.id}
|
||||
hqPos={hqPos} onSelect={onSelect} isDark={isDark} globeMode="photo" camDist={camDist} />;
|
||||
})}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// MODE TOGGLE BUTTON — inside canvas, top-left
|
||||
// Beautiful pill with icon and label
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function ModeToggle({ mode, isDark, onToggle }: { mode: "classic" | "photo"; isDark: boolean; onToggle: () => void }) {
|
||||
const isPhoto = mode === "photo";
|
||||
return (
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="flex items-center gap-2 px-3 sm:px-3.5 py-1.5 sm:py-2 rounded-full transition-all duration-300 hover:scale-[1.03] active:scale-[0.97] select-none flex-shrink-0"
|
||||
style={{
|
||||
background: isDark
|
||||
? isPhoto ? "rgba(16,60,130,0.75)" : "rgba(0,0,0,0.55)"
|
||||
: isPhoto ? "rgba(255,255,255,0.80)" : "rgba(255,255,255,0.72)",
|
||||
border: `1px solid ${isDark
|
||||
? isPhoto ? "rgba(56,189,248,0.35)" : "rgba(0,240,255,0.22)"
|
||||
: isPhoto ? "rgba(0,102,204,0.22)" : "rgba(0,0,0,0.10)"}`,
|
||||
backdropFilter: "blur(12px)",
|
||||
WebkitBackdropFilter: "blur(12px)",
|
||||
boxShadow: isDark
|
||||
? isPhoto ? "0 0 16px rgba(56,189,248,0.18)" : "0 0 12px rgba(0,240,255,0.12)"
|
||||
: "0 2px 12px rgba(0,0,0,0.08)",
|
||||
}}
|
||||
>
|
||||
{/* Icon — swap between satellite and hologram */}
|
||||
<div className="relative w-4 h-4 flex-shrink-0">
|
||||
<AnimatePresence mode="wait">
|
||||
{isPhoto ? (
|
||||
<motion.span key="globe" initial={{ opacity: 0, rotate: -30 }} animate={{ opacity: 1, rotate: 0 }} exit={{ opacity: 0, rotate: 30 }} className="absolute inset-0 flex items-center justify-center">
|
||||
<Globe size={14} style={{ color: isDark ? "#38BDF8" : "#0066CC" }} />
|
||||
</motion.span>
|
||||
) : (
|
||||
<motion.span key="layers" initial={{ opacity: 0, rotate: 30 }} animate={{ opacity: 1, rotate: 0 }} exit={{ opacity: 0, rotate: -30 }} className="absolute inset-0 flex items-center justify-center">
|
||||
<Layers size={14} style={{ color: isDark ? "#06F5E1" : "#555" }} />
|
||||
</motion.span>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Label */}
|
||||
<span className="text-[11px] font-semibold tracking-wide"
|
||||
style={{ color: isDark ? (isPhoto ? "#38BDF8" : "#06F5E1") : (isPhoto ? "#0066CC" : "#555") }}>
|
||||
{isPhoto ? "Satellite" : "Classic"}
|
||||
</span>
|
||||
|
||||
{/* Dot indicator */}
|
||||
<div className="w-1.5 h-1.5 rounded-full ml-0.5 flex-shrink-0"
|
||||
style={{
|
||||
background: isDark ? (isPhoto ? "#38BDF8" : "#06F5E1") : (isPhoto ? "#0066CC" : "#86868B"),
|
||||
boxShadow: `0 0 5px ${isDark ? (isPhoto ? "#38BDF8" : "#06F5E1") : "transparent"}`,
|
||||
}} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// NODE DETAIL CARD
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function NodeCard({ node, isDark, onClose, onViewCase }: {
|
||||
node: any; isDark: boolean; onClose: () => void; onViewCase: () => void;
|
||||
}) {
|
||||
const isEvent = node.nodeType === "event";
|
||||
const isHQ = node.nodeType === "hq";
|
||||
const accent = isHQ ? (isDark ? "#FFFFFF" : "#111111")
|
||||
: isEvent ? (isDark ? "#E879F9" : "#9333EA")
|
||||
: (isDark ? "#38BDF8" : "#0066CC");
|
||||
const label = isEvent ? "Event / Exhibition" : isHQ ? "FLUX HQ" : "Field Installation";
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key="card"
|
||||
initial={{ opacity: 0, y: 14 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 8 }}
|
||||
transition={{ duration: 0.22, ease: [0.4, 0, 0.2, 1] }}
|
||||
className="rounded-2xl"
|
||||
style={{
|
||||
background: isDark ? "rgba(6,10,22,0.94)" : "rgba(255,255,255,0.92)",
|
||||
border: `1px solid ${isDark ? "rgba(255,255,255,0.09)" : "rgba(0,0,0,0.07)"}`,
|
||||
backdropFilter: "blur(24px)",
|
||||
WebkitBackdropFilter: "blur(24px)",
|
||||
boxShadow: isDark ? "0 20px 60px rgba(0,0,0,0.60)" : "0 8px 40px rgba(0,0,0,0.09)",
|
||||
}}
|
||||
>
|
||||
<div className="relative w-full rounded-t-2xl overflow-hidden" style={{ height: 148 }}>
|
||||
{node.mediaFileName ? (
|
||||
<>
|
||||
<Image src={`/cases/${node.mediaFileName}`} alt={node.title} fill sizes="380px" className="object-cover object-center" />
|
||||
<div className="absolute inset-0 pointer-events-none" style={{
|
||||
background: isDark
|
||||
? "linear-gradient(to bottom, transparent 35%, rgba(6,10,22,0.94) 100%)"
|
||||
: "linear-gradient(to bottom, transparent 35%, rgba(255,255,255,0.92) 100%)",
|
||||
}} />
|
||||
</>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center"
|
||||
style={{ background: isDark ? "rgba(255,255,255,0.03)" : "rgba(0,0,0,0.04)" }}>
|
||||
<MapPin size={26} style={{ color: accent, opacity: 0.35 }} />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute top-3 left-3 flex items-center gap-1.5 px-2.5 py-1 rounded-full z-10"
|
||||
style={{
|
||||
background: isDark ? "rgba(0,0,0,0.70)" : "rgba(255,255,255,0.88)",
|
||||
backdropFilter: "blur(8px)",
|
||||
border: `1px solid ${isDark ? "rgba(255,255,255,0.12)" : "rgba(0,0,0,0.08)"}`,
|
||||
}}>
|
||||
<div className="w-1.5 h-1.5 rounded-full" style={{ background: accent }} />
|
||||
<span className="text-[9px] font-bold uppercase tracking-[0.12em]"
|
||||
style={{ color: isDark ? "#E0EFFF" : "#1D1D1F" }}>{label}</span>
|
||||
</div>
|
||||
<button onClick={onClose}
|
||||
className="absolute top-3 right-3 w-7 h-7 rounded-full flex items-center justify-center hover:opacity-70 transition-opacity z-10"
|
||||
style={{
|
||||
background: isDark ? "rgba(0,0,0,0.70)" : "rgba(255,255,255,0.88)",
|
||||
backdropFilter: "blur(8px)",
|
||||
border: `1px solid ${isDark ? "rgba(255,255,255,0.12)" : "rgba(0,0,0,0.08)"}`,
|
||||
}}>
|
||||
<X size={12} color={isDark ? "#E0EFFF" : "#1D1D1F"} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-5 pt-4 pb-5">
|
||||
<h4 className="text-base font-semibold leading-tight mb-1.5"
|
||||
style={{ color: isDark ? "#EEF4FF" : "#1D1D1F" }}>{node.title}</h4>
|
||||
<p className="text-[11px] leading-relaxed mb-4 flex items-start gap-1.5"
|
||||
style={{ color: isDark ? "#5A7090" : "#86868B" }}>
|
||||
<MapPin size={11} className="mt-0.5 flex-shrink-0" />
|
||||
<span className="line-clamp-2">{node.location}</span>
|
||||
</p>
|
||||
{node.stats && (
|
||||
<div className="rounded-xl px-4 py-3 mb-4"
|
||||
style={{
|
||||
background: isDark ? "rgba(255,255,255,0.04)" : "rgba(0,0,0,0.04)",
|
||||
border: `1px solid ${isDark ? "rgba(255,255,255,0.06)" : "rgba(0,0,0,0.05)"}`,
|
||||
}}>
|
||||
<span className="text-[9px] uppercase tracking-widest font-bold block mb-0.5"
|
||||
style={{ color: isDark ? "#4A6080" : "#86868B" }}>Status / Details</span>
|
||||
<span className="text-sm font-semibold" style={{ color: isDark ? "#EEF4FF" : "#1D1D1F" }}>
|
||||
{node.stats}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<button onClick={onViewCase}
|
||||
className="w-full flex items-center justify-center gap-2 py-3 rounded-xl text-sm font-semibold transition-all duration-150 hover:opacity-90 active:scale-[0.98]"
|
||||
style={{
|
||||
background: isDark ? accent : "#111111",
|
||||
color: (isDark && !isHQ) ? "#050A18" : "#FFFFFF",
|
||||
boxShadow: isDark ? `0 0 24px ${accent}33` : "none",
|
||||
}}>
|
||||
View Case Study <ArrowUpRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// MAIN COMPONENT
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
export default function GlobalOperations({ dbNodes = [], dbApps = [] }: { dbNodes?: any[]; dbApps?: any[] }) {
|
||||
const [filter, setFilter] = useState("all");
|
||||
const [subFilter, setSubFilter] = useState<string | null>(null);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [isDark, setIsDark] = useState(false);
|
||||
const [globeReady, setGlobeReady] = useState(false);
|
||||
// Globe visual mode: "classic" (original holographic) | "photo" (satellite)
|
||||
const [globeMode, setGlobeMode] = useState<"classic" | "photo">("classic");
|
||||
const camDist = useRef<number>(6.5);
|
||||
const t = useTranslations("GlobalOperations");
|
||||
|
||||
useEffect(() => {
|
||||
const check = () => setIsDark(document.documentElement.classList.contains("dark"));
|
||||
check();
|
||||
const obs = new MutationObserver(check);
|
||||
obs.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
|
||||
return () => obs.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => setGlobeReady(true), 600);
|
||||
return () => clearTimeout(id);
|
||||
}, []);
|
||||
|
||||
const subFilters = dbApps.filter(a => a.isActive).map(a => ({ id: a.slug, label: a.title }));
|
||||
const selected = dbNodes.find(d => d.id === selectedId);
|
||||
const hqNode = dbNodes.find(d => d.application === "hq");
|
||||
const hqPos = latLonToVec3(hqNode?.lat ?? 45.78, hqNode?.lon ?? 11.76, RADIUS);
|
||||
const handleFilter = (id: string) => { setFilter(id); setSubFilter(null); setSelectedId(null); };
|
||||
|
||||
const nodeCount = dbNodes.filter(n => n.nodeType !== "hq").length;
|
||||
const eventCount = dbNodes.filter(n => n.nodeType === "event").length;
|
||||
const countryCount = new Set(dbNodes.map(n => (n.location || "").split(",").pop()?.trim()).filter(Boolean)).size;
|
||||
const nonHQ = dbNodes.filter(n => n.nodeType !== "hq");
|
||||
const visibleCount = nonHQ.filter(n => {
|
||||
const isEv = n.nodeType === "event";
|
||||
return (filter === "all" || (filter === "installation" && !isEv) || (filter === "event" && isEv)) && (!subFilter || n.application === subFilter);
|
||||
}).length;
|
||||
|
||||
const filterDefs = [
|
||||
{ id: "all", label: t("filterAll"), icon: Globe },
|
||||
{ id: "installation", label: t("filterInstallations"), icon: Package },
|
||||
{ id: "event", label: t("filterEvents"), icon: Calendar },
|
||||
{ id: "legacy", label: t("filterHQ"), icon: Building2 },
|
||||
];
|
||||
|
||||
// Globe container background — more translucent to see nodes better
|
||||
const globeBg = globeMode === "classic"
|
||||
? (isDark
|
||||
? "radial-gradient(ellipse at 48% 40%, rgba(7,20,40,0.85) 0%, rgba(2,8,16,0.90) 60%, rgba(1,4,8,0.95) 100%)"
|
||||
: "rgba(232, 242, 252, 0.35)")
|
||||
: (isDark
|
||||
? "radial-gradient(ellipse at 48% 40%, rgba(7,20,40,0.85) 0%, rgba(2,8,16,0.90) 60%, rgba(1,4,8,0.95) 100%)"
|
||||
: "rgba(232, 242, 252, 0.35)");
|
||||
|
||||
const cardStyle = (dark: boolean): React.CSSProperties => ({
|
||||
background: dark ? "rgba(4,8,20,0.85)" : "rgba(255,255,255,0.82)",
|
||||
border: `1px solid ${dark ? "rgba(40,90,160,0.20)" : "rgba(0,0,0,0.07)"}`,
|
||||
backdropFilter: "blur(22px)",
|
||||
WebkitBackdropFilter: "blur(22px)",
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<section id="global" className="relative w-full max-w-7xl mx-auto px-4 md:px-6 py-20 md:py-28" style={{ zIndex: 1 }}>
|
||||
<div className="flex flex-col lg:flex-row gap-5 lg:gap-8 items-stretch lg:min-h-[680px]">
|
||||
|
||||
{/* ══ LEFT PANEL ══════════════════════════════════════ */}
|
||||
<div className="w-full lg:w-[340px] xl:w-[355px] flex-shrink-0 flex flex-col gap-4" style={{ position: "relative", zIndex: 2 }}>
|
||||
|
||||
{/* Header card */}
|
||||
<div className="rounded-2xl px-6 py-6" style={cardStyle(isDark)}>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<span className="w-1.5 h-1.5 rounded-full animate-pulse"
|
||||
style={{ background: isDark ? "#38BDF8" : "#0066CC" }} />
|
||||
<span className="text-[10px] font-bold tracking-[0.18em] uppercase"
|
||||
style={{ color: isDark ? "#38BDF8" : "#0066CC" }}>
|
||||
{t("subtitle")}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-[2rem] xl:text-[2.1rem] font-light leading-[1.08] tracking-tight mb-6"
|
||||
style={{ color: isDark ? "#EEF4FF" : "#1D1D1F" }}>
|
||||
{t("title1")}<br /><span className="font-semibold">{t("title2")}</span>
|
||||
</h3>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-3 divide-x pb-5 mb-5 border-b"
|
||||
style={{ borderColor: isDark ? "rgba(40,90,160,0.18)" : "rgba(0,0,0,0.06)" }}>
|
||||
{[
|
||||
{ val: nodeCount, lbl: "Nodes", clr: isDark ? "#38BDF8" : "#0066CC" },
|
||||
{ val: eventCount, lbl: "Events", clr: isDark ? "#E879F9" : "#9333EA" },
|
||||
{ val: countryCount, lbl: "Countries", clr: isDark ? "#34D399" : "#059669" },
|
||||
].map(s => (
|
||||
<div key={s.lbl} className="flex flex-col items-center py-1"
|
||||
style={{ borderColor: isDark ? "rgba(40,90,160,0.18)" : "rgba(0,0,0,0.06)" }}>
|
||||
<span className="text-[1.65rem] font-semibold tabular-nums leading-none" style={{ color: s.clr }}>
|
||||
<AnimatedCounter target={s.val} />
|
||||
</span>
|
||||
<span className="text-[9px] font-bold tracking-[0.14em] uppercase mt-1"
|
||||
style={{ color: isDark ? "#4A6080" : "#86868B" }}>{s.lbl}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{filterDefs.map(f => {
|
||||
const Icon = f.icon;
|
||||
const on = filter === f.id;
|
||||
return (
|
||||
<button key={f.id} onClick={() => handleFilter(f.id)}
|
||||
className="flex items-center gap-1.5 px-3.5 py-2 rounded-full text-[11px] font-medium transition-all duration-200"
|
||||
style={{
|
||||
background: on ? (isDark ? "#1060CC" : "#111111") : (isDark ? "rgba(255,255,255,0.05)" : "rgba(0,0,0,0.05)"),
|
||||
color: on ? "#fff" : (isDark ? "#7A9ABF" : "#6B7280"),
|
||||
border: `1px solid ${on ? (isDark ? "#1060CC" : "#111111") : (isDark ? "rgba(40,90,160,0.22)" : "rgba(0,0,0,0.09)")}`,
|
||||
}}>
|
||||
<Icon size={11} />{f.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Sub-filters */}
|
||||
<AnimatePresence>
|
||||
{filter === "installation" && (
|
||||
<motion.div initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: "auto" }} exit={{ opacity: 0, height: 0 }} className="overflow-hidden">
|
||||
<p className="text-[9px] font-bold uppercase tracking-[0.16em] mt-3 mb-2"
|
||||
style={{ color: isDark ? "#4A6080" : "#86868B" }}>{t("filterByApp")}</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{subFilters.map(s => {
|
||||
const on = subFilter === s.id;
|
||||
return (
|
||||
<button key={s.id} onClick={() => { setSubFilter(on ? null : s.id); setSelectedId(null); }}
|
||||
className="px-2.5 py-1 rounded-full text-[11px] transition-all duration-200"
|
||||
style={{
|
||||
background: on ? (isDark ? "rgba(16,96,204,0.22)" : "rgba(0,102,204,0.08)") : "transparent",
|
||||
color: on ? (isDark ? "#38BDF8" : "#0066CC") : (isDark ? "#4A6080" : "#86868B"),
|
||||
border: `1px solid ${on ? (isDark ? "rgba(56,189,248,0.32)" : "rgba(0,102,204,0.22)") : "transparent"}`,
|
||||
fontWeight: on ? 600 : 400,
|
||||
}}>{s.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Legend — colors change based on globe mode */}
|
||||
<div className="mt-5 pt-4 border-t flex items-center gap-4 flex-wrap"
|
||||
style={{ borderColor: isDark ? "rgba(40,90,160,0.18)" : "rgba(0,0,0,0.06)" }}>
|
||||
{[
|
||||
{
|
||||
dot: globeMode === "classic"
|
||||
? (isDark ? "#38BDF8" : "#0066CC")
|
||||
: (isDark ? "#FACC15" : "#F97316"), // Orange/Yellow for photo mode
|
||||
lbl: "Installation"
|
||||
},
|
||||
{ dot: isDark ? "#E879F9" : "#9333EA", lbl: "Event" },
|
||||
{ dot: isDark ? "#FFFFFF" : "#111", lbl: "HQ", diamond: true },
|
||||
].map(item => (
|
||||
<div key={item.lbl} className="flex items-center gap-1.5">
|
||||
<div className={`w-2 h-2 flex-shrink-0 ${item.diamond ? "rotate-45" : "rounded-full"}`}
|
||||
style={{ background: item.dot }} />
|
||||
<span className="text-[10px] font-medium tracking-widest uppercase"
|
||||
style={{ color: isDark ? "#4A6080" : "#86868B" }}>{item.lbl}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dynamic lower card */}
|
||||
<AnimatePresence mode="wait">
|
||||
{selected ? (
|
||||
<NodeCard key="detail" node={selected} isDark={isDark}
|
||||
onClose={() => setSelectedId(null)} onViewCase={() => setModalOpen(true)} />
|
||||
) : (
|
||||
<motion.div key={filter + (subFilter || "")}
|
||||
initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.18 }}
|
||||
className="rounded-2xl px-6 py-5" style={cardStyle(isDark)}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full animate-pulse" style={{ background: isDark ? "#38BDF8" : "#0066CC" }} />
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest"
|
||||
style={{ color: isDark ? "#4A6080" : "#86868B" }}>{t("networkStatus")}</span>
|
||||
</div>
|
||||
<p className="text-[1rem] font-light leading-snug mb-4"
|
||||
style={{ color: isDark ? "#A0BCDA" : "#1D1D1F" }}>
|
||||
{subFilter
|
||||
? t("statusShowing", { app: subFilter.replace(/-/g, " ") })
|
||||
: t("statusTracking", { count: visibleCount })}
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex justify-between text-[10px]" style={{ color: isDark ? "#4A6080" : "#86868B" }}>
|
||||
<span className="uppercase tracking-widest">Visible</span>
|
||||
<span className="font-mono font-semibold" style={{ color: isDark ? "#EEF4FF" : "#1D1D1F" }}>
|
||||
{visibleCount} / {nonHQ.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-px rounded-full overflow-hidden"
|
||||
style={{ background: isDark ? "rgba(40,90,160,0.22)" : "rgba(0,0,0,0.07)" }}>
|
||||
<motion.div className="h-full rounded-full"
|
||||
style={{ background: isDark ? "#38BDF8" : "#0066CC" }}
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: nonHQ.length > 0 ? `${(visibleCount / nonHQ.length) * 100}%` : "0%" }}
|
||||
transition={{ duration: 0.65, ease: "easeOut" }} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[11px] mt-3" style={{ color: isDark ? "#2A4060" : "#9CA3AF" }}>
|
||||
Click a node on the globe to explore its case study.
|
||||
</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* ══ GLOBE CANVAS ══════════════════════════════════════
|
||||
Taller canvas: desktop min-height 680px so node card
|
||||
doesn't cause the globe to feel cramped
|
||||
══════════════════════════════════════════════════════ */}
|
||||
<div
|
||||
id="globe-wrap"
|
||||
className="relative w-full lg:flex-1 rounded-2xl"
|
||||
style={{
|
||||
// Mobile: responsive square; Desktop: fills flex container
|
||||
height: "min(100vw, 540px)",
|
||||
minHeight: 520,
|
||||
zIndex: 1,
|
||||
background: globeBg,
|
||||
border: `1px solid ${isDark ? "rgba(20,70,150,0.18)" : "rgba(147,197,253,0.35)"}`,
|
||||
backdropFilter: "blur(8px)",
|
||||
WebkitBackdropFilter: "blur(8px)",
|
||||
boxShadow: isDark
|
||||
? "inset 0 0 80px rgba(0,20,70,0.40)"
|
||||
: "inset 0 0 50px rgba(147,197,253,0.12), 0 2px 20px rgba(0,0,0,0.03)",
|
||||
}}
|
||||
>
|
||||
{/* lg+ override: use full height of flex parent, min 680px */}
|
||||
<style>{`@media (min-width: 1024px) { #globe-wrap { height: auto !important; min-height: 680px !important; flex: 1 1 0% !important; align-self: stretch !important; } }`}</style>
|
||||
|
||||
{/* Radial glow — subtle */}
|
||||
<div className="absolute inset-0 pointer-events-none flex items-center justify-center overflow-hidden rounded-2xl">
|
||||
<div style={{
|
||||
width: "60%", aspectRatio: "1", borderRadius: "50%",
|
||||
background: isDark
|
||||
? "radial-gradient(circle, rgba(10,60,160,0.35) 0%, transparent 65%)"
|
||||
: "radial-gradient(circle, rgba(147,197,253,0.30) 0%, transparent 65%)",
|
||||
filter: "blur(30px)",
|
||||
}} />
|
||||
</div>
|
||||
|
||||
{/* Controls bar — toggle left, hint right, responsive layout */}
|
||||
<div className="absolute top-3 left-3 right-3 z-10 flex items-center justify-between gap-2 flex-wrap sm:flex-nowrap">
|
||||
{/* Mode toggle */}
|
||||
<ModeToggle mode={globeMode} isDark={isDark} onToggle={() => setGlobeMode(m => m === "classic" ? "photo" : "classic")} />
|
||||
|
||||
{/* Help hint */}
|
||||
<span className="text-[9px] sm:text-[10px] font-mono tracking-widest uppercase px-2.5 sm:px-3 py-1.5 rounded-full pointer-events-none select-none whitespace-nowrap"
|
||||
style={{
|
||||
background: isDark ? "rgba(6,14,30,0.70)" : "rgba(255,255,255,0.65)",
|
||||
color: isDark ? "#2A4A70" : "#9CA3AF",
|
||||
border: `1px solid ${isDark ? "rgba(20,70,150,0.22)" : "rgba(0,0,0,0.06)"}`,
|
||||
backdropFilter: "blur(8px)",
|
||||
}}>
|
||||
{t("helpText")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Skeleton */}
|
||||
{!globeReady && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div className="rounded-full animate-pulse" style={{
|
||||
width: "58%", aspectRatio: "1",
|
||||
background: isDark
|
||||
? "radial-gradient(circle at 38% 38%, #0c2040 0%, #020810 100%)"
|
||||
: "radial-gradient(circle at 38% 38%, #bdd7ee 0%, #dbeafe 100%)",
|
||||
boxShadow: isDark ? "0 0 80px rgba(0,80,200,0.20)" : "0 0 60px rgba(147,197,253,0.35)",
|
||||
}} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="absolute inset-0 overflow-hidden rounded-2xl">
|
||||
<Canvas
|
||||
camera={{ position: [0, 0, 6.5], fov: CAM_FOV }}
|
||||
dpr={[1, 2]}
|
||||
gl={{ antialias: true, alpha: true, powerPreference: "high-performance" }}
|
||||
className="absolute inset-0"
|
||||
style={{ width: "100%", height: "100%", touchAction: "none", background: "transparent", opacity: globeReady ? 1 : 0, transition: "opacity 0.8s ease" }}
|
||||
onCreated={() => setGlobeReady(true)}
|
||||
>
|
||||
<ambientLight intensity={isDark ? 0.9 : 1.8} />
|
||||
<directionalLight position={[4, 6, 4]} intensity={isDark ? 1.1 : 2.0} />
|
||||
<directionalLight position={[-4, -2, -4]} intensity={isDark ? 0.3 : 0.4} color={isDark ? "#002A88" : "#93C5FD"} />
|
||||
<OrbitControls enableZoom enablePan={false} autoRotate={false}
|
||||
minDistance={3.0} maxDistance={10} dampingFactor={0.07} enableDamping />
|
||||
<Suspense fallback={null}>
|
||||
<Globe3D
|
||||
filter={filter} subFilter={subFilter}
|
||||
selected={selectedId} onSelect={setSelectedId}
|
||||
isDark={isDark} nodes={dbNodes} hqPos={hqPos}
|
||||
globeMode={globeMode} camDist={camDist}
|
||||
/>
|
||||
</Suspense>
|
||||
</Canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<CaseStudyModal isOpen={modalOpen} onClose={() => setModalOpen(false)} data={selected as CaseStudyData || null} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, Suspense, useEffect } from "react";
|
||||
import { Canvas, useFrame, useLoader, useThree } from "@react-three/fiber";
|
||||
import { OrbitControls, QuadraticBezierLine } from "@react-three/drei";
|
||||
import * as THREE from "three";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { MapPin, Calendar, History, X, ArrowUpRight } from "lucide-react";
|
||||
import CaseStudyModal, { CaseStudyData } from "@/components/ui/CaseStudyModal";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
|
||||
const RADIUS = 2;
|
||||
|
||||
function latLongToVector3(lat: number, lon: number, radius: number) {
|
||||
const phi = (90 - lat) * (Math.PI / 180);
|
||||
const theta = (lon + 180) * (Math.PI / 180);
|
||||
const x = -(radius * Math.sin(phi) * Math.cos(theta));
|
||||
const z = (radius * Math.sin(phi) * Math.sin(theta));
|
||||
const y = (radius * Math.cos(phi));
|
||||
return new THREE.Vector3(x, y, z);
|
||||
}
|
||||
|
||||
// ── COMPONENTE OPTIMIZADO 1: TEXTURA DE LA TIERRA CON ALTA RESOLUCIÓN ──
|
||||
function EarthMesh({ isDark }: { isDark: boolean }) {
|
||||
const earthTexture = useLoader(THREE.TextureLoader, "https://unpkg.com/three-globe/example/img/earth-water.png");
|
||||
const { gl } = useThree();
|
||||
|
||||
// 🔥 Filtro de hardware para forzar nitidez al hacer Zoom
|
||||
useEffect(() => {
|
||||
if (earthTexture) {
|
||||
earthTexture.anisotropy = gl.capabilities.getMaxAnisotropy(); // Máximo detalle en ángulos inclinados
|
||||
earthTexture.minFilter = THREE.LinearMipmapLinearFilter;
|
||||
earthTexture.magFilter = THREE.LinearFilter;
|
||||
earthTexture.colorSpace = THREE.SRGBColorSpace; // Colores más vibrantes y definidos
|
||||
earthTexture.generateMipmaps = true;
|
||||
earthTexture.needsUpdate = true;
|
||||
}
|
||||
}, [earthTexture, gl]);
|
||||
|
||||
return (
|
||||
<mesh>
|
||||
{/* Regresamos a 64 segmentos para optimizar rendimiento (la nitidez ahora viene de la textura) */}
|
||||
<sphereGeometry args={[RADIUS * 0.99, 64, 64]} />
|
||||
<meshBasicMaterial
|
||||
map={earthTexture}
|
||||
color={isDark ? "#06F5E1" : "#86868B"}
|
||||
transparent
|
||||
opacity={isDark ? 0.4 : 0.3}
|
||||
blending={isDark ? THREE.AdditiveBlending : THREE.NormalBlending}
|
||||
/>
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
|
||||
// ── COMPONENTE OPTIMIZADO 2: EL NODO INTELIGENTE QUE RESPONDE AL ZOOM ──
|
||||
function MapNode({ marker, isSelected, hqPosition, onSelectMarker, isDark }: any) {
|
||||
const meshRef = useRef<THREE.Group>(null);
|
||||
const pos = latLongToVector3(marker.lat, marker.lon, RADIUS);
|
||||
|
||||
const isHQ = marker.nodeType === "hq";
|
||||
const isEvent = marker.nodeType === "event";
|
||||
const nodeColor = isHQ ? (isDark ? "#FFFFFF" : "#1D1D1F") : isEvent ? "#A855F7" : "#0066CC";
|
||||
|
||||
const baseSize = isHQ ? 0.04 : isEvent ? 0.035 : 0.025;
|
||||
|
||||
useFrame(({ camera }) => {
|
||||
if (!meshRef.current) return;
|
||||
const dist = camera.position.length();
|
||||
const scaleFactor = Math.max(0.2, dist / 12);
|
||||
const finalScale = isSelected ? scaleFactor * 1.8 : scaleFactor;
|
||||
meshRef.current.scale.set(finalScale, finalScale, finalScale);
|
||||
});
|
||||
|
||||
const distance = hqPosition.distanceTo(pos);
|
||||
const arcElevation = RADIUS + (distance * 0.25) + 0.1;
|
||||
const midPoint = hqPosition.clone().lerp(pos, 0.5).normalize().multiplyScalar(arcElevation);
|
||||
|
||||
return (
|
||||
<group>
|
||||
<group ref={meshRef} position={pos}>
|
||||
<mesh>
|
||||
<sphereGeometry args={[baseSize, 32, 32]} />
|
||||
<meshBasicMaterial color={nodeColor} />
|
||||
</mesh>
|
||||
|
||||
{/* CAJA DE COLISIÓN AMPLIADA */}
|
||||
<mesh
|
||||
visible={false}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelectMarker(isSelected ? null : marker.id);
|
||||
}}
|
||||
onPointerOver={(e) => { e.stopPropagation(); document.body.style.cursor = 'pointer'; }}
|
||||
onPointerOut={(e) => { e.stopPropagation(); document.body.style.cursor = 'auto'; }}
|
||||
>
|
||||
<sphereGeometry args={[baseSize * 4, 16, 16]} />
|
||||
<meshBasicMaterial transparent opacity={0} />
|
||||
</mesh>
|
||||
</group>
|
||||
|
||||
{!isHQ && (
|
||||
<QuadraticBezierLine
|
||||
start={hqPosition}
|
||||
end={pos}
|
||||
mid={midPoint}
|
||||
color={nodeColor}
|
||||
lineWidth={isSelected ? 2.5 : 1.5}
|
||||
transparent
|
||||
opacity={isSelected ? 0.9 : 0.25}
|
||||
/>
|
||||
)}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
// ── COMPONENTE DE ENSAMBLAJE DE LA ESFERA ──
|
||||
function HologramSphere({ activeFilter, activeSubFilter, selectedMarker, onSelectMarker, isDark, dbNodes, hqPosition }: any) {
|
||||
const globeRef = useRef<THREE.Group>(null);
|
||||
|
||||
// 🔥 MAGIA DE ROTACIÓN INTELIGENTE 🔥
|
||||
useFrame(({ camera }) => {
|
||||
// La cámara inicia en Z=7. Si el usuario hace zoom in (distancia < 6.5), detenemos la rotación.
|
||||
// Si vuelve a alejar el mapa a su estado normal (distancia > 6.5), retoma la rotación.
|
||||
const distance = camera.position.length();
|
||||
|
||||
if (globeRef.current && !selectedMarker && distance > 6.5) {
|
||||
globeRef.current.rotation.y += 0.0005;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<group ref={globeRef}>
|
||||
<mesh><sphereGeometry args={[RADIUS * 1.04, 64, 64]} /><meshBasicMaterial color={isDark ? "#00F0FF" : "#0066CC"} transparent opacity={isDark ? 0.1 : 0.05} side={THREE.BackSide} blending={THREE.AdditiveBlending} depthWrite={false} /></mesh>
|
||||
<mesh><sphereGeometry args={[RADIUS * 0.98, 64, 64]} /><meshBasicMaterial color={isDark ? "#050505" : "#E5E5EA"} /></mesh>
|
||||
|
||||
{/* Esfera Terrestre mejorada con texturas nítidas */}
|
||||
<EarthMesh isDark={isDark} />
|
||||
|
||||
<mesh><sphereGeometry args={[RADIUS, 64, 64]} /><meshBasicMaterial color={isDark ? "#0066CC" : "#86868B"} wireframe transparent opacity={0.06} /></mesh>
|
||||
|
||||
{dbNodes.map((marker: any) => {
|
||||
const isHQ = marker.nodeType === "hq";
|
||||
const isEvent = marker.nodeType === "event";
|
||||
|
||||
const matchesMain = activeFilter === "all" || (activeFilter === "installation" && !isEvent && !isHQ) || (activeFilter === "event" && isEvent) || (activeFilter === "legacy" && isHQ);
|
||||
const matchesSub = !activeSubFilter || marker.application === activeSubFilter || isHQ;
|
||||
const isVisible = matchesMain && matchesSub;
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<MapNode
|
||||
key={marker.id}
|
||||
marker={marker}
|
||||
isSelected={selectedMarker === marker.id}
|
||||
hqPosition={hqPosition}
|
||||
onSelectMarker={onSelectMarker}
|
||||
isDark={isDark}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
// ── INTERFAZ GRÁFICA PRINCIPAL ──
|
||||
export default function GlobalOperations({ dbNodes = [], dbApps = [] }: { dbNodes?: any[], dbApps?: any[] }) {
|
||||
const [activeFilter, setActiveFilter] = useState("all");
|
||||
const [activeSubFilter, setActiveSubFilter] = useState<string | null>(null);
|
||||
const [selectedMarkerId, setSelectedMarkerId] = useState<string | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isDark, setIsDark] = useState(false);
|
||||
|
||||
const t = useTranslations("GlobalOperations");
|
||||
|
||||
const dynamicSubFilters = dbApps
|
||||
.filter(app => app.isActive)
|
||||
.map(app => ({ id: app.slug, label: app.title }));
|
||||
|
||||
useEffect(() => {
|
||||
const checkTheme = () => setIsDark(document.documentElement.classList.contains("dark"));
|
||||
checkTheme();
|
||||
const observer = new MutationObserver(checkTheme);
|
||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const filters = [
|
||||
{ id: "all", label: t("filterAll"), icon: MapPin },
|
||||
{ id: "installation", label: t("filterInstallations"), icon: MapPin },
|
||||
{ id: "event", label: t("filterEvents"), icon: Calendar },
|
||||
{ id: "legacy", label: t("filterHQ"), icon: History }
|
||||
];
|
||||
|
||||
const selectedData = dbNodes.find(d => d.id === selectedMarkerId);
|
||||
|
||||
const hqNode = dbNodes.find(d => d.application === "hq");
|
||||
const hqLat = hqNode ? hqNode.lat : 45.78;
|
||||
const hqLon = hqNode ? hqNode.lon : 11.76;
|
||||
const hqPosition = latLongToVector3(hqLat, hqLon, RADIUS);
|
||||
|
||||
const handleMainFilter = (id: string) => {
|
||||
setActiveFilter(id);
|
||||
setActiveSubFilter(null);
|
||||
setSelectedMarkerId(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<section id="global" className="relative w-full max-w-7xl mx-auto px-6 py-32 z-10">
|
||||
<div className="flex flex-col lg:flex-row gap-12 items-center h-auto lg:h-[700px]">
|
||||
|
||||
<div className="w-full lg:w-1/3 flex flex-col h-full justify-center mb-12 lg:mb-0">
|
||||
<div className="p-6 md:p-8 -ml-6 md:-ml-8 rounded-3xl bg-white/40 dark:bg-[#0A0A0C]/50 backdrop-blur-md border border-white/50 dark:border-white/5 shadow-sm transition-colors">
|
||||
<h2 className="text-sm font-semibold tracking-widest text-[#0066CC] dark:text-[#4DA6FF] uppercase mb-4">{t("subtitle")}</h2>
|
||||
<h3 className="text-4xl md:text-5xl font-light text-[#1D1D1F] dark:text-[#F5F5F7] tracking-tight mb-8 leading-[1.1] transition-colors">
|
||||
{t("title1")} <br /><span className="font-medium">{t("title2")}</span>
|
||||
</h3>
|
||||
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{filters.map((f) => (
|
||||
<button key={f.id} onClick={() => handleMainFilter(f.id)} className={`px-4 py-2 rounded-full text-sm font-medium transition-all duration-300 border ${ activeFilter === f.id ? "bg-[#1D1D1F] dark:bg-white text-white dark:text-[#1D1D1F] border-[#1D1D1F] dark:border-white shadow-md" : "bg-white/60 dark:bg-white/5 text-[#86868B] dark:text-[#A1A1A6] border-white/60 dark:border-white/10 hover:text-[#1D1D1F] dark:hover:text-white" } backdrop-blur-md`}>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{activeFilter === "installation" && (
|
||||
<motion.div initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: "auto" }} exit={{ opacity: 0, height: 0 }} className="flex flex-wrap gap-2 mb-4 overflow-hidden">
|
||||
<span className="w-full text-xs font-semibold uppercase tracking-widest text-[#86868B] mb-1">{t("filterByApp")}</span>
|
||||
{dynamicSubFilters.map((sub) => (
|
||||
<button key={sub.id} onClick={() => { setActiveSubFilter(activeSubFilter === sub.id ? null : sub.id); setSelectedMarkerId(null); }} className={`px-3 py-1.5 rounded-full text-xs transition-all duration-200 border ${ activeSubFilter === sub.id ? "bg-[#0066CC]/10 dark:bg-[#0066CC]/20 text-[#0066CC] dark:text-[#4DA6FF] border-[#0066CC]/30 font-medium" : "bg-white/40 dark:bg-transparent text-[#86868B] border-transparent hover:text-[#1D1D1F] dark:hover:text-white" }`}>
|
||||
{sub.label}
|
||||
</button>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{!selectedMarkerId && (
|
||||
<motion.div key={activeFilter + (activeSubFilter || "")} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }} className="bg-white/60 dark:bg-[#1D1D1F]/60 backdrop-blur-2xl border border-white/40 dark:border-white/10 shadow-[0_8px_32px_rgba(0,0,0,0.04)] dark:shadow-[0_8px_32px_rgba(0,0,0,0.4)] rounded-3xl p-8 mt-4 transition-colors">
|
||||
<h4 className="text-[#86868B] text-xs font-semibold uppercase tracking-wider mb-2 flex items-center gap-2"><span className="w-2 h-2 rounded-full bg-[#0066CC] animate-pulse"></span>{t("networkStatus")}</h4>
|
||||
<p className="text-xl font-light text-[#1D1D1F] dark:text-[#F5F5F7] mb-6 leading-relaxed transition-colors">
|
||||
{activeSubFilter
|
||||
? t("statusShowing", { app: activeSubFilter.replace("-", " ") })
|
||||
: t("statusTracking", { count: dbNodes.filter(n =>
|
||||
(activeFilter === "all") ||
|
||||
(activeFilter === "installation" && n.nodeType === "installation") ||
|
||||
(activeFilter === "event" && n.nodeType === "event") ||
|
||||
(activeFilter === "legacy" && n.nodeType === "hq")
|
||||
).length })}
|
||||
</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div className="w-full lg:w-2/3 h-[500px] lg:h-full relative rounded-[2.5rem] overflow-hidden bg-white/30 dark:bg-transparent backdrop-blur-sm dark:backdrop-blur-none border border-white/40 dark:border-transparent transition-all duration-700">
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[150%] h-[150%] bg-[radial-gradient(circle_at_center,rgba(0,102,204,0.15)_0%,transparent_60%)] hidden dark:block pointer-events-none blur-3xl" />
|
||||
|
||||
<div className="absolute top-4 right-4 z-10 text-[10px] font-mono text-[#86868B] dark:text-[#A1A1A6] tracking-widest uppercase bg-white/50 dark:bg-white/5 px-3 py-1 rounded-full backdrop-blur-md border border-white/50 dark:border-white/10 transition-colors pointer-events-none">
|
||||
{t("helpText")}
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{selectedData && (
|
||||
<motion.div initial={{ opacity: 0, x: 20, y: 20 }} animate={{ opacity: 1, x: 0, y: 0 }} exit={{ opacity: 0, x: 20, y: 20 }} className="absolute bottom-4 right-4 z-20 w-[calc(100%-2rem)] md:w-80 bg-white/80 dark:bg-[#1D1D1F]/80 backdrop-blur-2xl border border-white/60 dark:border-white/10 shadow-lg dark:shadow-[0_10px_40px_rgba(0,0,0,0.5)] rounded-2xl p-6 transition-colors">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div className="flex items-center gap-2 text-[#0066CC] dark:text-[#4DA6FF]">
|
||||
<MapPin size={14} />
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider">
|
||||
{selectedData.nodeType === 'event' ? t("typeEvent") : selectedData.nodeType === 'hq' ? t("typeHQ") : t("typeInstall")}
|
||||
</span>
|
||||
</div>
|
||||
<button onClick={() => setSelectedMarkerId(null)} className="text-[#86868B] hover:text-[#1D1D1F] dark:hover:text-white transition-colors">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<h4 className="text-xl font-medium text-[#1D1D1F] dark:text-[#F5F5F7] mb-1">{selectedData.title}</h4>
|
||||
<p className="text-sm text-[#86868B] dark:text-[#A1A1A6] mb-4">{selectedData.location}</p>
|
||||
<div className="bg-[#F5F5F7] dark:bg-white/5 rounded-lg p-3 mb-4 border border-transparent dark:border-white/5">
|
||||
<span className="text-[10px] uppercase tracking-wider text-[#86868B] dark:text-[#A1A1A6] block mb-1">{t("statusDetails")}</span>
|
||||
<span className="text-sm font-medium text-[#1D1D1F] dark:text-[#F5F5F7]">{selectedData.stats}</span>
|
||||
</div>
|
||||
<button onClick={() => setIsModalOpen(true)} className="w-full flex justify-center items-center gap-2 bg-[#1D1D1F] dark:bg-[#0066CC] text-white py-2.5 rounded-xl text-sm font-medium hover:bg-[#0066CC] dark:hover:bg-[#4DA6FF] transition-colors">
|
||||
{t("viewCaseStudy")} <ArrowUpRight size={14} />
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<Canvas camera={{ position: [0, 0, 7], fov: 55 }} dpr={[1, 2]} style={{ touchAction: "none" }}>
|
||||
<ambientLight intensity={1.5} />
|
||||
<directionalLight position={[10, 10, 5]} intensity={2} />
|
||||
<OrbitControls enableZoom={true} minDistance={3} maxDistance={12} enablePan={false} autoRotate={false} />
|
||||
<Suspense fallback={null}>
|
||||
<HologramSphere activeFilter={activeFilter} activeSubFilter={activeSubFilter} selectedMarker={selectedMarkerId} onSelectMarker={setSelectedMarkerId} isDark={isDark} dbNodes={dbNodes} hqPosition={hqPosition} />
|
||||
</Suspense>
|
||||
</Canvas>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<CaseStudyModal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)} data={selectedData as CaseStudyData || null} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Image from "next/image";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
// 🔥 IMPORTAMOS LA FUENTE FUTURISTA/EXTENDIDA SÓLO PARA EL HERO 🔥
|
||||
import { Syncopate } from "next/font/google";
|
||||
const syncopate = Syncopate({ weight: ['400', '700'], subsets: ['latin'] });
|
||||
|
||||
interface HeroReelProps {
|
||||
images: string[];
|
||||
}
|
||||
|
||||
export default function HeroReel({ images }: HeroReelProps) {
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const t = useTranslations("HeroReel");
|
||||
|
||||
useEffect(() => {
|
||||
if (!images || images.length <= 1) return;
|
||||
const timer = setInterval(() => {
|
||||
setCurrentIndex((prevIndex) => (prevIndex + 1) % images.length);
|
||||
}, 3600);
|
||||
return () => clearInterval(timer);
|
||||
}, [images]);
|
||||
|
||||
return (
|
||||
<div
|
||||
id="technology"
|
||||
className="relative w-screen h-[100vh] mt-0 mb-0 overflow-hidden z-0 bg-[#050505]"
|
||||
>
|
||||
|
||||
<AnimatePresence mode="popLayout">
|
||||
{images.length > 0 ? (
|
||||
<motion.div
|
||||
key={currentIndex}
|
||||
initial={{ opacity: 0, scale: 1.05 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 1.03 }}
|
||||
transition={{ duration: 1.5, ease: [0.25, 0.1, 0.25, 1] }}
|
||||
className="absolute inset-0 w-full h-full"
|
||||
>
|
||||
<Image
|
||||
src={images[currentIndex]}
|
||||
alt={`FLUX Vision ${currentIndex}`}
|
||||
fill
|
||||
quality={100}
|
||||
sizes="100vw"
|
||||
className="object-cover"
|
||||
priority={currentIndex === 0}
|
||||
/>
|
||||
</motion.div>
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-[#111] to-[#0A0A0C]" />
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Gradientes sutiles en los bordes para garantizar que el texto siempre sea legible sin importar la foto */}
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-black/50 via-black/10 to-transparent pointer-events-none z-10" />
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1/2 bg-gradient-to-t from-black/80 via-black/20 to-transparent pointer-events-none z-10" />
|
||||
|
||||
{/* ── SUPERPOSICIÓN DE TEXTO MINIMALISTA Y BAJA ── */}
|
||||
{/* 🔥 Usamos items-end y pb-24/pb-32 para tirarlo hacia la mitad inferior 🔥 */}
|
||||
<div className="absolute inset-0 z-20 flex items-end justify-start px-6 md:px-12 lg:px-24 pb-24 md:pb-32 pointer-events-none">
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.4, duration: 1, ease: "easeOut" }}
|
||||
// Estructura en columna, alineado a la izquierda
|
||||
className="w-full max-w-5xl flex flex-col items-start gap-4 md:gap-6"
|
||||
>
|
||||
{/* BLOQUE DE TÍTULOS */}
|
||||
<div className="flex flex-col gap-1 md:gap-3">
|
||||
{/* LEMA PRINCIPAL (Fuente Syncopate) */}
|
||||
<h1 className={`${syncopate.className} text-4xl md:text-5xl lg:text-[5.5rem] font-bold text-white uppercase tracking-widest leading-[1.1] drop-shadow-2xl`}>
|
||||
LET THE POWER FLUX
|
||||
</h1>
|
||||
|
||||
{/* FRASE SECUNDARIA (Fuente limpia y elegante) */}
|
||||
<h2 className="text-lg md:text-2xl lg:text-[2rem] font-light text-white uppercase tracking-[0.2em] drop-shadow-lg">
|
||||
INNOVATION NOT IMITATION
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* ESPACIADOR INVISIBLE */}
|
||||
<div className="h-2 md:h-4"></div>
|
||||
|
||||
{/* BLOQUE DE DESCRIPCIÓN TIPO "CONSOLA" */}
|
||||
<div className="flex flex-col gap-2 md:gap-3">
|
||||
<p className="font-mono text-[10px] md:text-xs lg:text-sm text-white/90 uppercase tracking-widest drop-shadow-md">
|
||||
{t("description1")}
|
||||
</p>
|
||||
<p className="font-mono text-[10px] md:text-xs lg:text-sm text-white/90 uppercase tracking-widest drop-shadow-md">
|
||||
{t("description2")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { Clock } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
// ── SÚPER PARSER MARKDOWN PARA LA LÍNEA DE TIEMPO ──
|
||||
const renderMarkdown = (text: string) => {
|
||||
if (!text) return null;
|
||||
const lines = text.split('\n');
|
||||
const elements: React.ReactNode[] = [];
|
||||
|
||||
let listItems: React.ReactNode[] = [];
|
||||
let isOrderedList = false;
|
||||
|
||||
const pushList = () => {
|
||||
if (listItems.length > 0) {
|
||||
elements.push(
|
||||
isOrderedList ? (
|
||||
<ol key={`ol-${elements.length}`} className="list-decimal ml-5 mb-4 text-[#86868B] dark:text-[#A1A1A6] space-y-1.5 text-sm md:text-base font-light marker:text-[#0066CC] dark:marker:text-[#4DA6FF]">
|
||||
{listItems}
|
||||
</ol>
|
||||
) : (
|
||||
<ul key={`ul-${elements.length}`} className="list-disc ml-5 mb-4 text-[#86868B] dark:text-[#A1A1A6] space-y-1.5 text-sm md:text-base font-light marker:text-[#0066CC] dark:marker:text-[#4DA6FF]">
|
||||
{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-medium 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(); return; }
|
||||
|
||||
const quoteMatch = trimmed.match(/^>\s*(.*)/);
|
||||
if (quoteMatch) {
|
||||
pushList();
|
||||
elements.push(
|
||||
<blockquote key={idx} className="border-l-4 border-[#0066CC] dark:border-[#4DA6FF] pl-4 py-1.5 my-4 text-base font-light italic text-[#1D1D1F]/70 dark:text-[#A1A1A6] bg-[#0066CC]/5 dark:bg-[#4DA6FF]/5 rounded-r-lg">
|
||||
{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; }
|
||||
|
||||
pushList();
|
||||
elements.push(<p key={idx} className="text-[#86868B] dark:text-[#A1A1A6] text-sm md:text-base leading-relaxed font-light mb-3 last:mb-0">{parseInline(trimmed)}</p>);
|
||||
});
|
||||
|
||||
pushList();
|
||||
return <>{elements}</>;
|
||||
};
|
||||
|
||||
export default function OurStory({ dbTimeline = [] }: { dbTimeline?: any[] }) {
|
||||
const t = useTranslations("OurStory");
|
||||
|
||||
if (!dbTimeline || dbTimeline.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section id="our-story" className="relative w-full max-w-7xl mx-auto px-6 py-32 z-10">
|
||||
|
||||
{/* CABECERA */}
|
||||
<div className="text-center mb-20">
|
||||
<h2 className="text-sm font-semibold tracking-widest text-[#0066CC] dark:text-[#4DA6FF] uppercase mb-4 flex items-center justify-center gap-2">
|
||||
<Clock size={16} /> {t("subtitle")}
|
||||
</h2>
|
||||
<h3 className="text-4xl md:text-5xl font-light text-[#1D1D1F] dark:text-[#F5F5F7] tracking-tight">
|
||||
{t("title")}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* LÍNEA DE TIEMPO CENTRAL */}
|
||||
<div className="relative before:absolute before:inset-0 before:ml-5 before:-translate-x-px md:before:mx-auto md:before:translate-x-0 before:h-full before:w-0.5 before:bg-gradient-to-b before:from-transparent before:via-black/10 dark:before:via-white/10 before:to-transparent">
|
||||
{dbTimeline.map((item, index) => (
|
||||
<motion.div
|
||||
key={item.id}
|
||||
initial={{ opacity: 0, y: 50 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-100px" }}
|
||||
transition={{ duration: 0.6, delay: index * 0.1 }}
|
||||
className="relative flex items-center justify-between md:justify-normal md:odd:flex-row-reverse group is-active mb-12 last:mb-0"
|
||||
>
|
||||
{/* EL PUNTO (DOT) DE LA LÍNEA DE TIEMPO */}
|
||||
<div className="flex items-center justify-center w-10 h-10 rounded-full border border-black/10 dark:border-white/20 bg-white dark:bg-[#111] shadow-sm shrink-0 md:order-1 md:group-odd:-translate-x-1/2 md:group-even:translate-x-1/2 relative z-10">
|
||||
<div className="w-3 h-3 bg-[#0066CC] dark:bg-[#4DA6FF] rounded-full transition-transform group-hover:scale-150 duration-500"></div>
|
||||
</div>
|
||||
|
||||
{/* TARJETA DE CONTENIDO */}
|
||||
<div className="w-[calc(100%-4rem)] md:w-[calc(50%-3rem)] bg-white/60 dark:bg-[#1D1D1F]/40 backdrop-blur-md border border-black/5 dark:border-white/10 p-6 md:p-8 rounded-[2rem] shadow-sm hover:shadow-md transition-shadow group-hover:-translate-y-1 duration-500">
|
||||
<span className="text-[#0066CC] dark:text-[#4DA6FF] font-mono text-sm tracking-widest bg-[#0066CC]/10 dark:bg-[#4DA6FF]/10 px-3 py-1 rounded-full inline-block mb-4">
|
||||
{item.year}
|
||||
</span>
|
||||
<h4 className="text-xl md:text-2xl text-[#1D1D1F] dark:text-white font-medium mb-3">
|
||||
{item.title}
|
||||
</h4>
|
||||
|
||||
{/* 🔥 AQUÍ USAMOS EL RENDERIZADOR DE MARKDOWN 🔥 */}
|
||||
<div className="max-w-none">
|
||||
{renderMarkdown(item.description)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { Link } from "@/i18n/routing";
|
||||
// 🔥 IMPORTAMOS LA VERSIÓN DE SERVIDOR
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
export default async function PatrizioLegacy() {
|
||||
const t = await getTranslations("PatrizioLegacy");
|
||||
|
||||
return (
|
||||
<section id="legacy" className="relative w-full max-w-7xl mx-auto px-6 py-32 md:py-48 z-10">
|
||||
|
||||
<div className="absolute inset-0 bg-white/50 dark:bg-[#0A0A0C]/70 backdrop-blur-2xl rounded-[4rem] -z-10 [mask-image:radial-gradient(ellipse_at_center,black_40%,transparent_70%)] transition-colors duration-700" />
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 md:gap-24 items-center relative z-10">
|
||||
|
||||
<div className="animate-fade-in-up">
|
||||
<h2 className="text-xs font-semibold tracking-widest text-[#0066CC] dark:text-[#4DA6FF] uppercase mb-6 transition-colors">
|
||||
{t("subtitle")}
|
||||
</h2>
|
||||
<h3 className="text-5xl md:text-6xl lg:text-7xl font-light text-[#1D1D1F] dark:text-[#F5F5F7] tracking-tight leading-[1.05] transition-colors">
|
||||
{t("title1")} <br />
|
||||
<span className="font-medium italic text-black/40 dark:text-white/40 transition-colors">
|
||||
{t("title2")}
|
||||
</span>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col justify-center animate-fade-in-up delay-200">
|
||||
<p className="text-lg md:text-xl font-light text-[#86868B] dark:text-[#A1A1A6] mb-6 leading-relaxed transition-colors">
|
||||
{t("p1_1")}<span className="font-medium text-[#1D1D1F] dark:text-[#F5F5F7] transition-colors">{t("p1_2")}</span>{t("p1_3")}
|
||||
</p>
|
||||
<p className="text-lg md:text-xl font-light text-[#86868B] dark:text-[#A1A1A6] mb-10 leading-relaxed transition-colors">
|
||||
{t("p2")}
|
||||
</p>
|
||||
|
||||
<Link href="/heritage" className="inline-flex items-center gap-2 text-sm font-semibold text-[#1D1D1F] dark:text-white group hover:text-[#0066CC] dark:hover:text-[#4DA6FF] transition-colors">
|
||||
{t("button")} <ArrowRight size={16} className="group-hover:translate-x-1 transition-transform" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
import { motion, useScroll, useTransform } from "framer-motion";
|
||||
import { Zap, Waves, Cpu, Activity, ThermometerSun } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function WhatWeDo() {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const t = useTranslations("WhatWeDo");
|
||||
|
||||
const { scrollYProgress } = useScroll({
|
||||
target: containerRef,
|
||||
offset: ["start start", "end end"],
|
||||
});
|
||||
|
||||
const p1Opacity = useTransform(scrollYProgress, [0, 0.05, 0.15, 0.2], [0, 1, 1, 0]);
|
||||
const p1Y = useTransform(scrollYProgress, [0, 0.05, 0.15, 0.2], [32, 0, 0, -32]);
|
||||
const p1Scale = useTransform(scrollYProgress, [0, 0.05, 0.15, 0.2], [0.97, 1, 1, 1.03]);
|
||||
|
||||
const p2Opacity = useTransform(scrollYProgress, [0.2, 0.25, 0.35, 0.4], [0, 1, 1, 0]);
|
||||
const p2Y = useTransform(scrollYProgress, [0.2, 0.25, 0.35, 0.4], [32, 0, 0, -32]);
|
||||
const p2Scale = useTransform(scrollYProgress, [0.2, 0.25, 0.35, 0.4], [0.97, 1, 1, 1.03]);
|
||||
|
||||
const p3Opacity = useTransform(scrollYProgress, [0.4, 0.45, 0.55, 0.6], [0, 1, 1, 0]);
|
||||
const p3Y = useTransform(scrollYProgress, [0.4, 0.45, 0.55, 0.6], [32, 0, 0, -32]);
|
||||
const p3Scale = useTransform(scrollYProgress, [0.4, 0.45, 0.55, 0.6], [0.97, 1, 1, 1.03]);
|
||||
|
||||
const p4Opacity = useTransform(scrollYProgress, [0.6, 0.65, 0.75, 0.8], [0, 1, 1, 0]);
|
||||
const p4Y = useTransform(scrollYProgress, [0.6, 0.65, 0.75, 0.8], [32, 0, 0, -32]);
|
||||
const p4Scale = useTransform(scrollYProgress, [0.6, 0.65, 0.75, 0.8], [0.97, 1, 1, 1.03]);
|
||||
|
||||
const p5Opacity = useTransform(scrollYProgress, [0.8, 0.85, 0.95, 1], [0, 1, 1, 0]);
|
||||
const p5Y = useTransform(scrollYProgress, [0.8, 0.85, 0.95, 1], [32, 0, 0, -32]);
|
||||
const p5Scale = useTransform(scrollYProgress, [0.8, 0.85, 0.95, 1], [0.97, 1, 1, 1.03]);
|
||||
|
||||
const makeStyle = (opacity: any, y: any, scale: any) => ({
|
||||
opacity, y, scale,
|
||||
willChange: "transform, opacity",
|
||||
});
|
||||
|
||||
// ── CLAMP FLUID TYPOGRAPHY ──────────────────────────────────────────
|
||||
// clamp(min, preferred, max) — escala suavemente entre tamaños
|
||||
// sin breakpoints abruptos. Probado contra todos los textos de los 5 idiomas.
|
||||
const fluidTitle = { fontSize: "clamp(1.35rem, 4.5vw, 3.75rem)", lineHeight: "1.25" };
|
||||
const fluidLarge = { fontSize: "clamp(1.2rem, 4vw, 3.5rem)", lineHeight: "1.3" };
|
||||
const fluidMedium = { fontSize: "clamp(1.1rem, 3.5vw, 3rem)", lineHeight: "1.35" };
|
||||
|
||||
// ── PANEL BASE ──────────────────────────────────────────────────────
|
||||
// En móvil: tarjeta con fondo glass que contiene el texto limpiamente.
|
||||
// En desktop: sin fondo, solo el texto flotante como antes.
|
||||
const cardClass = [
|
||||
// Posición y ancho
|
||||
"absolute flex flex-col items-center text-center",
|
||||
"w-[calc(100vw-2.5rem)] max-w-[36rem]", // móvil: viewport - 2*1.25rem padding
|
||||
"md:w-auto md:max-w-4xl lg:max-w-5xl",
|
||||
// Tarjeta glass SOLO en móvil
|
||||
"md:bg-transparent md:backdrop-blur-none md:border-transparent md:shadow-none md:p-0",
|
||||
"bg-white/70 dark:bg-black/40 backdrop-blur-xl",
|
||||
"border border-white/80 dark:border-white/10",
|
||||
"shadow-[0_8px_32px_rgba(0,0,0,0.06)] dark:shadow-[0_8px_32px_rgba(0,0,0,0.4)]",
|
||||
"rounded-[1.75rem] px-6 py-8",
|
||||
].join(" ");
|
||||
|
||||
const textColor = "text-[#1D1D1F] dark:text-white font-light tracking-tight";
|
||||
const blueAccent = "font-semibold text-[#0066CC] dark:text-[#00F0FF] italic";
|
||||
const eyebrow = "text-[9px] uppercase tracking-[0.3em] text-[#0066CC] dark:text-[#00F0FF] mb-3 font-bold";
|
||||
const iconBox = "p-3 bg-[#0066CC]/8 dark:bg-[#00F0FF]/8 border border-[#0066CC]/15 dark:border-[#00F0FF]/15 rounded-2xl mb-5 backdrop-blur-md";
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={containerRef}
|
||||
className="relative w-full h-[600vh] bg-transparent"
|
||||
style={{ contain: "layout" }}
|
||||
>
|
||||
<div className="sticky top-0 left-0 w-full h-screen flex items-center justify-center pointer-events-none">
|
||||
|
||||
{/* Fondo radial */}
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,rgba(245,245,247,0.92)_0%,transparent_65%)] dark:bg-[radial-gradient(circle_at_center,rgba(10,10,12,0.92)_0%,transparent_65%)] pointer-events-none -z-10" />
|
||||
|
||||
{/* ── PANEL 1: Introducción ── */}
|
||||
<motion.div style={makeStyle(p1Opacity, p1Y, p1Scale)} className={cardClass}>
|
||||
<div className={iconBox}>
|
||||
<Zap className="text-[#0066CC] dark:text-[#00F0FF]" size={22} />
|
||||
</div>
|
||||
<p className={eyebrow}>{t("subtitle")}</p>
|
||||
<h3
|
||||
className={`${textColor} mb-4`}
|
||||
style={fluidTitle}
|
||||
>
|
||||
{t("title")}
|
||||
</h3>
|
||||
<p
|
||||
className="text-[#86868B] dark:text-[#A1A1A6] font-light leading-relaxed"
|
||||
style={{ fontSize: "clamp(0.82rem, 2vw, 1.15rem)" }}
|
||||
>
|
||||
{t("desc")}
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* ── PANEL 2: Tecnología ── */}
|
||||
<motion.div style={makeStyle(p2Opacity, p2Y, p2Scale)} className={cardClass}>
|
||||
<div className={iconBox}>
|
||||
<Cpu className="text-[#0066CC] dark:text-[#00F0FF]" size={22} />
|
||||
</div>
|
||||
{/* Número decorativo — da jerarquía visual sin añadir texto */}
|
||||
<p className="text-[9px] uppercase tracking-[0.3em] text-[#0066CC]/60 dark:text-[#00F0FF]/60 mb-3 font-bold">01 — {t("subtitle")}</p>
|
||||
<p
|
||||
className={textColor}
|
||||
style={fluidLarge}
|
||||
>
|
||||
{t("tech")}
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* ── PANEL 3: Proceso ── */}
|
||||
<motion.div style={makeStyle(p3Opacity, p3Y, p3Scale)} className={cardClass}>
|
||||
<div className={iconBox}>
|
||||
<Activity className="text-[#0066CC] dark:text-[#00F0FF]" size={22} />
|
||||
</div>
|
||||
<p className="text-[9px] uppercase tracking-[0.3em] text-[#0066CC]/60 dark:text-[#00F0FF]/60 mb-3 font-bold">02 — {t("subtitle")}</p>
|
||||
<p
|
||||
className={textColor}
|
||||
style={fluidLarge}
|
||||
>
|
||||
{t("process")}
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* ── PANEL 4: Eficiencia ── */}
|
||||
<motion.div style={makeStyle(p4Opacity, p4Y, p4Scale)} className={cardClass}>
|
||||
<div className={iconBox}>
|
||||
<ThermometerSun className="text-[#0066CC] dark:text-[#00F0FF]" size={22} />
|
||||
</div>
|
||||
<p className="text-[9px] uppercase tracking-[0.3em] text-[#0066CC]/60 dark:text-[#00F0FF]/60 mb-3 font-bold">03 — {t("subtitle")}</p>
|
||||
<p
|
||||
className={textColor}
|
||||
style={fluidMedium}
|
||||
>
|
||||
{t("efficiency")}
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* ── PANEL 5: Servicios ── */}
|
||||
<motion.div style={makeStyle(p5Opacity, p5Y, p5Scale)} className={cardClass}>
|
||||
<div className={iconBox}>
|
||||
<Waves className="text-[#0066CC] dark:text-[#00F0FF]" size={22} />
|
||||
</div>
|
||||
<p className={eyebrow}>{t("servicesSubtitle")}</p>
|
||||
<h3
|
||||
className={`${textColor} mb-4`}
|
||||
style={fluidTitle}
|
||||
>
|
||||
{t("servicesTitle1")}
|
||||
<span className={blueAccent}>{t("servicesTitle2")}</span>
|
||||
{t("servicesTitle3")}
|
||||
<span className={blueAccent}>{t("servicesTitle4")}</span>
|
||||
</h3>
|
||||
<p
|
||||
className="text-[#86868B] dark:text-[#A1A1A6] font-light leading-relaxed"
|
||||
style={{ fontSize: "clamp(0.82rem, 2vw, 1.15rem)" }}
|
||||
>
|
||||
{t("servicesDesc")}
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowUpRight } from "lucide-react";
|
||||
|
||||
// 1. El botón principal (Contact Engineering)
|
||||
export function AiContactButton() {
|
||||
const handleContactEngineering = () => {
|
||||
const prompt = "I am ready to optimize my production. I would like to schedule a technical consultation with FLUX Engineering to explore custom RF solutions and calculate my ROI.";
|
||||
window.dispatchEvent(new CustomEvent("flux:trigger-ai", { detail: { prompt } }));
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleContactEngineering}
|
||||
className="flex items-center gap-3 bg-white dark:bg-[#00F0FF] text-[#1D1D1F] px-8 py-4 rounded-full font-semibold hover:scale-105 hover:shadow-[0_0_30px_rgba(0,102,204,0.4)] dark:hover:shadow-[0_0_30px_rgba(0,240,255,0.4)] transition-all duration-300"
|
||||
>
|
||||
Contact FLUX Engineering <ArrowUpRight size={18} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Los enlaces de texto para la columna "Technology"
|
||||
export function AiFooterLink({ label, prompt }: { label: string, prompt: string }) {
|
||||
const handleTrigger = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
window.dispatchEvent(new CustomEvent("flux:trigger-ai", { detail: { prompt } }));
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleTrigger}
|
||||
className="text-left hover:text-[#0066CC] dark:hover:text-[#00F0FF] transition-colors font-light w-fit"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
"use client";
|
||||
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { X, MapPin, Calendar, Leaf, CheckCircle2, Factory, Presentation, Image as ImageIcon } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export interface CaseStudyData {
|
||||
id: string;
|
||||
title: string;
|
||||
location: string;
|
||||
nodeType: string;
|
||||
application: string;
|
||||
stats: string;
|
||||
mediaFileName?: string | null;
|
||||
projectOverview?: string | null;
|
||||
energySavings?: string | null;
|
||||
galleryJson?: string | null;
|
||||
eventDate?: string | null;
|
||||
}
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
data: CaseStudyData | null;
|
||||
}
|
||||
|
||||
const renderMarkdown = (text: string) => {
|
||||
if (!text) return null;
|
||||
const lines = text.split('\n');
|
||||
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-[500px] shadow-lg rounded-2xl overflow-hidden border border-black/5 dark:border-white/5">
|
||||
<thead>
|
||||
<tr className="bg-[#F5F5F7] dark:bg-[#1D1D1F]">
|
||||
{tableHeaders.map((th, i) => (
|
||||
<th key={i} className={`p-4 border-b border-black/5 dark:border-white/10 text-xs uppercase tracking-widest font-semibold ${i === tableHeaders.length - 1 ? 'text-[#0066CC] dark:text-[#4DA6FF] bg-[#0066CC]/5 dark:bg-[#4DA6FF]/5' : 'text-[#1D1D1F] dark:text-white'}`}>
|
||||
{parseInline(th)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-[#0A0A0C]">
|
||||
{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-sm ${cIdx === 0 ? 'text-[#86868B] dark:text-[#A1A1A6] font-medium' : cIdx === row.length - 1 ? 'text-[#1D1D1F] dark:text-white font-semibold bg-[#0066CC]/5 dark:bg-[#4DA6FF]/5 group-hover:bg-[#0066CC]/10 dark:group-hover:bg-[#4DA6FF]/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-6 mb-6 text-[#86868B] dark:text-[#A1A1A6] space-y-2 text-base font-light marker:text-[#0066CC] dark:marker:text-[#4DA6FF]">
|
||||
{listItems}
|
||||
</ol>
|
||||
) : (
|
||||
<ul key={`ul-${elements.length}`} className="list-disc ml-6 mb-6 text-[#86868B] dark:text-[#A1A1A6] space-y-2 text-base font-light marker:text-[#0066CC] dark:marker:text-[#4DA6FF]">
|
||||
{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-medium 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();
|
||||
elements.push(
|
||||
<div key={`img-${idx}`} className="relative w-full my-8 rounded-2xl overflow-hidden border border-black/10 dark:border-white/10 shadow-lg bg-[#F5F5F7] dark:bg-[#1D1D1F]">
|
||||
<img src={imgMatch[2]} alt={imgMatch[1]} className="w-full h-auto object-cover hover:scale-105 transition-transform duration-700" loading="lazy" />
|
||||
</div>
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const h3Match = trimmed.match(/^###\s*(.*)/);
|
||||
if (h3Match) { pushList(); pushTable(); elements.push(<h3 key={idx} className="text-xl mt-8 mb-3 font-medium text-[#0066CC] dark:text-[#4DA6FF]">{parseInline(h3Match[1])}</h3>); return; }
|
||||
|
||||
const h2Match = trimmed.match(/^##\s*(.*)/);
|
||||
if (h2Match) { pushList(); pushTable(); elements.push(<h2 key={idx} className="text-2xl mt-10 mb-4 font-light text-[#1D1D1F] dark:text-white tracking-tight">{parseInline(h2Match[1])}</h2>); return; }
|
||||
|
||||
const h1Match = trimmed.match(/^#\s*(.*)/);
|
||||
if (h1Match) { pushList(); pushTable(); elements.push(<h1 key={idx} className="text-3xl 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(); pushTable();
|
||||
elements.push(
|
||||
<blockquote key={idx} className="border-l-4 border-[#0066CC] dark:border-[#4DA6FF] pl-5 py-2 my-6 text-lg font-light italic text-[#1D1D1F]/70 dark:text-[#A1A1A6] bg-[#0066CC]/5 dark:bg-[#4DA6FF]/5 rounded-r-xl">
|
||||
{parseInline(quoteMatch[1])}
|
||||
</blockquote>
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const ulMatch = trimmed.match(/^[-*]\s*(.*)/);
|
||||
if (ulMatch) { isOrderedList = false; listItems.push(<li key={idx} className="leading-relaxed pl-2">{parseInline(ulMatch[1])}</li>); return; }
|
||||
|
||||
const olMatch = trimmed.match(/^\d+\.\s*(.*)/);
|
||||
if (olMatch) { isOrderedList = true; listItems.push(<li key={idx} className="leading-relaxed pl-2">{parseInline(olMatch[1])}</li>); return; }
|
||||
|
||||
pushList();
|
||||
elements.push(<p key={idx} className="text-[#86868B] dark:text-[#A1A1A6] font-light leading-relaxed mb-4 text-base">{parseInline(trimmed)}</p>);
|
||||
});
|
||||
|
||||
pushList();
|
||||
pushTable();
|
||||
|
||||
return <>{elements}</>;
|
||||
};
|
||||
|
||||
export default function CaseStudyModal({ isOpen, onClose, data }: ModalProps) {
|
||||
const [gallery, setGallery] = useState<string[]>([]);
|
||||
const t = useTranslations("CaseStudyModal");
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) document.body.style.overflow = "hidden";
|
||||
else document.body.style.overflow = "unset";
|
||||
return () => { document.body.style.overflow = "unset"; };
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.galleryJson) {
|
||||
try {
|
||||
setGallery(JSON.parse(data.galleryJson));
|
||||
} catch (e) {
|
||||
setGallery([]);
|
||||
}
|
||||
} else {
|
||||
setGallery([]);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
const isEvent = data.nodeType === "event";
|
||||
const isHQ = data.nodeType === "hq";
|
||||
const coverImage = data.mediaFileName ? `/cases/${data.mediaFileName}` : null;
|
||||
|
||||
let formattedDate = null;
|
||||
let isUpcoming = false;
|
||||
if (data.eventDate) {
|
||||
const eventD = new Date(data.eventDate);
|
||||
formattedDate = eventD.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||
isUpcoming = eventD > new Date();
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 sm:p-6 md:p-12">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
||||
onClick={onClose}
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-md"
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 40, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 20, scale: 0.95 }}
|
||||
transition={{ type: "spring", damping: 25, stiffness: 300 }}
|
||||
className="relative w-full max-w-4xl bg-white dark:bg-[#0A0A0C] border border-black/10 dark:border-white/10 rounded-[2rem] md:rounded-[3rem] shadow-2xl overflow-hidden flex flex-col max-h-[90vh]"
|
||||
>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-6 right-6 z-50 w-10 h-10 bg-black/50 hover:bg-black/80 backdrop-blur-md text-white rounded-full flex items-center justify-center transition-colors border border-white/20"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
|
||||
<div className="flex-1 overflow-y-auto [scrollbar-width:none]">
|
||||
|
||||
<div className="relative w-full h-64 md:h-96 bg-[#1D1D1F] overflow-hidden">
|
||||
{coverImage ? (
|
||||
<Image src={coverImage} alt={data.title} fill className="object-cover" />
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,rgba(0,102,204,0.4)_0%,transparent_100%)] flex items-center justify-center">
|
||||
<Factory size={64} className="text-white/10" />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-white via-white/20 dark:from-[#0A0A0C] dark:via-[#0A0A0C]/20 to-transparent" />
|
||||
|
||||
<div className="absolute bottom-6 left-6 md:left-10 flex items-center gap-2">
|
||||
<div className="bg-[#0066CC] text-white px-3 py-1.5 rounded-full text-[10px] font-bold uppercase tracking-widest flex items-center gap-1.5 shadow-lg">
|
||||
{isEvent ? <Presentation size={12} /> : isHQ ? <MapPin size={12} /> : <Factory size={12} />}
|
||||
{isEvent ? t("typeEvent") : isHQ ? t("typeHQ") : t("typeInstall")}
|
||||
</div>
|
||||
<div className="bg-white/90 dark:bg-black/80 backdrop-blur-md text-[#1D1D1F] dark:text-[#F5F5F7] border border-black/5 dark:border-white/10 px-3 py-1.5 rounded-full text-[10px] font-bold uppercase tracking-widest shadow-lg">
|
||||
{data.application.replace("-", " ")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 md:p-10 lg:p-12 relative -mt-4 bg-white dark:bg-[#0A0A0C] rounded-t-[2rem] md:rounded-t-[3rem] z-10">
|
||||
|
||||
<div className="mb-10">
|
||||
<h2 className="text-4xl md:text-5xl font-light text-[#1D1D1F] dark:text-[#F5F5F7] tracking-tight mb-4">
|
||||
{data.title}
|
||||
</h2>
|
||||
<div className="flex flex-wrap items-center gap-4 md:gap-8 text-sm text-[#86868B]">
|
||||
<span className="flex items-center gap-1.5"><MapPin size={16} /> {data.location}</span>
|
||||
{formattedDate && (
|
||||
<span className={`flex items-center gap-1.5 ${isUpcoming ? 'text-[#0066CC] dark:text-[#4DA6FF] font-medium' : ''}`}>
|
||||
<Calendar size={16} /> {formattedDate} {isUpcoming && "(Upcoming)"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 mb-10">
|
||||
<div className="bg-[#F5F5F7] dark:bg-[#1D1D1F] p-5 rounded-2xl border border-transparent dark:border-white/5">
|
||||
<span className="text-[10px] uppercase tracking-widest text-[#86868B] block mb-1">
|
||||
{isEvent ? t("keyHighlight") : t("keyMetric")}
|
||||
</span>
|
||||
<span className="text-lg md:text-xl font-medium text-[#1D1D1F] dark:text-white leading-tight">
|
||||
{data.stats}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{data.energySavings && (
|
||||
<div className="bg-[#0066CC]/5 dark:bg-[#4DA6FF]/10 p-5 rounded-2xl border border-[#0066CC]/10 dark:border-[#4DA6FF]/20">
|
||||
<span className="text-[10px] uppercase tracking-widest text-[#0066CC] dark:text-[#4DA6FF] block mb-1 flex items-center gap-1">
|
||||
{isEvent ? <MapPin size={10} /> : <Leaf size={10} />}
|
||||
{isEvent ? t("locationStand") : t("energyImpact")}
|
||||
</span>
|
||||
<span className="text-lg md:text-xl font-medium text-[#0066CC] dark:text-[#4DA6FF] leading-tight">
|
||||
{data.energySavings}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="col-span-2 md:col-span-1 bg-[#F5F5F7] dark:bg-[#1D1D1F] p-5 rounded-2xl border border-transparent dark:border-white/5 flex flex-col justify-center">
|
||||
<span className="text-[10px] uppercase tracking-widest text-[#86868B] block mb-1">{t("systemStatus")}</span>
|
||||
<span className="flex items-center gap-2 text-sm font-medium text-emerald-600 dark:text-emerald-400">
|
||||
<CheckCircle2 size={16} />
|
||||
{/* 🔥 AQUÍ ESTABA EL ERROR: Simplificamos la lógica 🔥 */}
|
||||
{isEvent ? (isUpcoming ? t("scheduled") : t("concluded")) : t("operational")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data.projectOverview ? (
|
||||
<div className="max-w-none mb-12">
|
||||
<h3 className="text-2xl font-light mb-6 text-[#1D1D1F] dark:text-white">
|
||||
{isEvent ? t("eventOverview") : t("projectChronicle")}
|
||||
</h3>
|
||||
{renderMarkdown(data.projectOverview)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-12 text-center border-2 border-dashed border-black/5 dark:border-white/5 rounded-2xl mb-12">
|
||||
<p className="text-[#86868B] text-sm uppercase tracking-widest">{t("pendingData")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{gallery.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-2xl font-light mb-6 text-[#1D1D1F] dark:text-white flex items-center gap-2">
|
||||
<ImageIcon size={20} className="text-[#86868B]" /> {t("mediaGallery")}
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{gallery.map((imgSrc, idx) => (
|
||||
<div key={idx} className={`relative rounded-2xl overflow-hidden bg-[#1D1D1F] border border-black/10 dark:border-white/10 ${idx === 0 && gallery.length % 2 !== 0 ? 'sm:col-span-2 h-64 md:h-80' : 'h-48 md:h-64'}`}>
|
||||
<Image src={`/cases/${imgSrc}`} alt={`Gallery image ${idx + 1}`} fill className="object-cover hover:scale-105 transition-transform duration-700" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useMemo, useEffect, useState } from "react";
|
||||
import { Canvas, useFrame } from "@react-three/fiber";
|
||||
import * as THREE from "three";
|
||||
|
||||
function EntityWave({ isDark }: { isDark: boolean }) {
|
||||
const geometryRef = useRef<THREE.BufferGeometry>(null);
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
const scrollData = useRef({ y: 0, targetY: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
scrollData.current.targetY = window.scrollY;
|
||||
};
|
||||
window.addEventListener("scroll", handleScroll, { passive: true });
|
||||
return () => window.removeEventListener("scroll", handleScroll);
|
||||
}, []);
|
||||
|
||||
const { positions, phases, count } = useMemo(() => {
|
||||
const tracks = 60;
|
||||
const pointsPerTrack = 250;
|
||||
const totalPoints = tracks * pointsPerTrack;
|
||||
const pos = new Float32Array(totalPoints * 3);
|
||||
const ph = new Float32Array(totalPoints);
|
||||
|
||||
for (let t = 0; t < tracks; t++) {
|
||||
for (let p = 0; p < pointsPerTrack; p++) {
|
||||
const idx = t * pointsPerTrack + p;
|
||||
const i3 = idx * 3;
|
||||
pos[i3] = (p / pointsPerTrack - 0.5) * 60;
|
||||
pos[i3 + 1] = 0;
|
||||
pos[i3 + 2] = (t / tracks - 0.5) * 12;
|
||||
ph[idx] = Math.random() * Math.PI * 2;
|
||||
}
|
||||
}
|
||||
return { positions: pos, phases: ph, count: totalPoints };
|
||||
}, []);
|
||||
|
||||
useFrame((state) => {
|
||||
if (!geometryRef.current || !groupRef.current) return;
|
||||
const time = state.clock.getElapsedTime();
|
||||
const pos = geometryRef.current.attributes.position.array as Float32Array;
|
||||
|
||||
scrollData.current.y = THREE.MathUtils.lerp(
|
||||
scrollData.current.y, scrollData.current.targetY, 0.05
|
||||
);
|
||||
const scrollOffset = scrollData.current.y;
|
||||
|
||||
groupRef.current.rotation.x = THREE.MathUtils.lerp(0.15, 0.35, scrollOffset / 3000);
|
||||
groupRef.current.position.y = THREE.MathUtils.lerp(-1.5, 1.5, scrollOffset / 3000);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const i3 = i * 3;
|
||||
const x = pos[i3];
|
||||
const z = pos[i3 + 2];
|
||||
const phase = phases[i];
|
||||
let y = Math.sin(x * 0.12 + time * 0.3) * 2.0;
|
||||
y += Math.sin(z * 0.5 - time * 0.4) * 0.8;
|
||||
const dist = Math.sqrt(x * x + z * z);
|
||||
const gaussian = Math.exp(-(dist * dist) / 40);
|
||||
y += Math.cos(x * 0.8 - time * 2.0 + phase) * gaussian * 1.5;
|
||||
pos[i3 + 1] = y;
|
||||
}
|
||||
geometryRef.current.attributes.position.needsUpdate = true;
|
||||
});
|
||||
|
||||
return (
|
||||
<group ref={groupRef}>
|
||||
<points>
|
||||
<bufferGeometry ref={geometryRef}>
|
||||
{/* @ts-ignore - R3F v9 strict types bypass */}
|
||||
<bufferAttribute
|
||||
attach="attributes-position"
|
||||
count={count}
|
||||
array={positions}
|
||||
itemSize={3}
|
||||
usage={THREE.DynamicDrawUsage}
|
||||
/>
|
||||
</bufferGeometry>
|
||||
<pointsMaterial
|
||||
size={isDark ? 0.09 : 0.06}
|
||||
color={isDark ? "#00C8FF" : "#0066CC"}
|
||||
transparent={true}
|
||||
// FIX: más visible en dark para efecto mágico entre globo y fondo
|
||||
opacity={isDark ? 0.75 : 0.32}
|
||||
blending={isDark ? THREE.AdditiveBlending : THREE.NormalBlending}
|
||||
depthWrite={false}
|
||||
sizeAttenuation={true}
|
||||
/>
|
||||
</points>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BreathingField() {
|
||||
const [isDark, setIsDark] = useState(false);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// ─── Detectar tema ───────────────────────────────────────────
|
||||
const checkTheme = () =>
|
||||
setIsDark(document.documentElement.classList.contains("dark"));
|
||||
checkTheme();
|
||||
const observer = new MutationObserver(checkTheme);
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class"],
|
||||
});
|
||||
|
||||
// ─── FIX DEFINITIVO iOS SAFARI ───────────────────────────────
|
||||
// Safari iOS ignora overflow-x:hidden y overscroll-behavior en
|
||||
// elementos position:fixed. La única solución confiable es capturar
|
||||
// el evento touchmove en el wrapper y cancelar SOLO el movimiento
|
||||
// horizontal (cuando deltaX > deltaY el usuario intenta hacer scroll
|
||||
// lateral — lo bloqueamos). El scroll vertical queda libre.
|
||||
const el = wrapperRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const preventHorizontalScroll = (e: TouchEvent) => {
|
||||
// Este elemento es pointer-events:none, así que este handler
|
||||
// es solo por seguridad — no debería recibir eventos táctiles.
|
||||
// La magia real viene del clip-path + contain en el CSS inline.
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
// passive:false es obligatorio para poder llamar preventDefault
|
||||
el.addEventListener("touchmove", preventHorizontalScroll, { passive: false });
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
el.removeEventListener("touchmove", preventHorizontalScroll);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className="fixed inset-0 pointer-events-none bg-transparent"
|
||||
style={{
|
||||
// FIX: z-index entre el fondo de página (z=-1) y la sección del globo (z=1)
|
||||
// Esto hace que las partículas aparezcan "dentro" del globo desde lejos
|
||||
// y le dan el efecto mágico de profundidad sin tapar la UI
|
||||
zIndex: 0,
|
||||
clipPath: "inset(0)",
|
||||
contain: "strict",
|
||||
width: "100vw",
|
||||
height: "100vh",
|
||||
transform: "none",
|
||||
}}
|
||||
>
|
||||
<Canvas
|
||||
camera={{ position: [0, 4, 22], fov: 45 }}
|
||||
dpr={[1, 1.5]}
|
||||
gl={{
|
||||
antialias: false,
|
||||
alpha: true,
|
||||
powerPreference: "high-performance",
|
||||
}}
|
||||
style={{
|
||||
// Canvas también recortado explícitamente
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
<fog attach="fog" args={[isDark ? "#0A0A0C" : "#F5F5F7", 12, 40]} />
|
||||
<EntityWave isDark={isDark} />
|
||||
</Canvas>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user