3a94e7c003
Security (critical):
- SESSION_SECRET fail-fast: refuse to boot without a 32+ char secret
(src/lib/session.ts, src/app/actions/clientAuth.ts)
- Rate limit with pluggable backend: in-memory by default, auto-promotes
to Upstash Redis when REDIS_URL is set (src/lib/rateLimit.ts)
- CSRF (double-submit HMAC) + Zod validation on /api/consultation;
new /api/csrf endpoint mints tokens (src/lib/csrf.ts)
- escapeHtml + safeMailto helpers; consultation email template now
fully escapes user-controlled fields (src/lib/escapeHtml.ts)
- Magic-byte validation for /api/public-upload — rejects HTML/JS
payloads renamed to .png/.mp4 (src/lib/fileType.ts)
- Nginx: CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy,
Permissions-Policy + 5r/m upload zone for /api/public-upload and
/api/assets (nginx/conf.d/flux.conf)
Quality:
- Delete GlobalOperations_old.tsx dead code (310 LOC)
- NavBar: replace 2s session polling with CustomEvent("flux:session-
changed") + visibilitychange listener (no more interval leaks)
- Type-safe CMS shapes via src/types/cms.ts (replaces any[] in
ApplicationsDashboard + GlobalOperations)
- /api/health now pings Postgres; docker-compose healthcheck added
- Structured JSON logger (src/lib/logger.ts) — drop-in replacement
for console.error across API routes
- Prisma indices on isActive/category/nodeType filters
FluxAI persistence + analytics:
- New models AiConversation + AiEvent with funnel stage detection
(DISCOVERY -> QUALIFY -> RECOMMEND -> HANDOFF) and OperationsSignal
back-ref so converted chats link to their consultation ticket
- /api/chat persists every user msg, ai msg, tool call, tool result;
IP is sha256-hashed with SESSION_SECRET salt; promptCacheKey wired
for when @ai-sdk/openai lands the feature
- New HQ dashboard at /hq-command/dashboard/conversations: 4 KPIs
(total, conversion rate, avg messages, avg tools), funnel + industry
breakdowns, last-50 table, per-id transcript with tool timeline
- SilentObserver sends sessionId/locale/pageUrl in transport body so
the route can stitch messages into the same conversation
- src/lib/aiSessionId.ts: localStorage UUID with sessionStorage +
in-memory fallbacks for privacy mode
- Golden tests via node --test (13 cases, no new deps);
npm run test:ai
Migration:
- prisma/migrations/20260526180000_add_indexes_and_ai_telemetry —
additive only, IF NOT EXISTS guards, safe for migrate deploy
env template hardened: SESSION_SECRET documented as required + how
to generate; REDIS_URL/REDIS_TOKEN documented as opt-in for multi-
instance deploys.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
367 lines
16 KiB
TypeScript
367 lines
16 KiB
TypeScript
"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);
|
|
|
|
// Cookie check is now event-driven (no setInterval polling).
|
|
// Triggers:
|
|
// - Initial mount
|
|
// - "flux:session-changed" CustomEvent dispatched by AuthModal on login/logout
|
|
// - visibilitychange (catches logout-in-another-tab)
|
|
// - storage events (multi-tab logout via shared cookie)
|
|
const checkSession = () => {
|
|
const cookies = document.cookie.split("; ");
|
|
const sessionExists = cookies.some((c) => c.startsWith("flux_b2b_session="));
|
|
setHasSession(sessionExists);
|
|
};
|
|
checkSession();
|
|
|
|
const handleVisibility = () => {
|
|
if (document.visibilityState === "visible") checkSession();
|
|
};
|
|
|
|
window.addEventListener("flux:session-changed", checkSession);
|
|
document.addEventListener("visibilitychange", handleVisibility);
|
|
|
|
return () => {
|
|
window.removeEventListener("scroll", handleScroll);
|
|
document.removeEventListener("mousedown", handleClickOutside);
|
|
window.removeEventListener("flux:session-changed", checkSession);
|
|
document.removeEventListener("visibilitychange", handleVisibility);
|
|
};
|
|
}, []);
|
|
|
|
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>
|
|
);
|
|
} |