production: docker + nginx config for rf-flux.com
Deploy to VPS / deploy (push) Has been cancelled

This commit is contained in:
2026-03-20 13:46:05 -05:00
parent b275b19f08
commit fc24313f15
187 changed files with 20977 additions and 767 deletions
+233
View File
@@ -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>
);
}
+417
View File
@@ -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>
);
}
+44
View File
@@ -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>
);
}
+234
View File
@@ -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>
);
}
+138
View File
@@ -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>
);
}
+257
View File
@@ -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>
);
}
+319
View File
@@ -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>
</>
);
}
+268
View File
@@ -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>
);
}
+108
View File
@@ -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>
);
}
+356
View File
@@ -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>
);
}
+125
View File
@@ -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} />
</>
);
}
+104
View File
@@ -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>
);
}
+131
View File
@@ -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>
);
}
+170
View File
@@ -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>
);
}
+37
View File
@@ -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>
);
}
+338
View File
@@ -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>
);
}
+174
View File
@@ -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>
);
}