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
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,79 @@
export const dynamic = "force-dynamic";
import Link from "next/link";
import fs from "fs";
import path from "path";
import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";
import ApplicationClient from "./ApplicationClient";
// 🔥 IMPORTAMOS LA TUBERÍA MÁGICA
import { getLocalizedData } from "@/lib/i18nHelper";
// --- FUNCIÓN ORIGINAL PARA LEER IMÁGENES LOCALES ---
function getApplicationImages(slug: string) {
const imagesDir = path.join(process.cwd(), "public", "applications", slug);
let blueprints: string[] = [];
let machines: string[] = [];
let heroImage = "";
if (fs.existsSync(imagesDir)) {
const files = fs.readdirSync(imagesDir).filter(file => /\.(png|jpe?g|webp)$/i.test(file));
heroImage = files[0] ? `/applications/${slug}/${files[0]}` : "";
blueprints = files.filter(f => f.includes("Screenshot") || f.startsWith("P10") || f.includes("blueprint")).slice(0, 3).map(f => `/applications/${slug}/${f}`);
machines = files.filter(f => !f.includes("Screenshot") && !f.startsWith("P10") && !f.includes("blueprint")).slice(1, 4).map(f => `/applications/${slug}/${f}`);
}
return { heroImage, blueprints, machines };
}
// GENERACIÓN DE RUTAS ESTÁTICAS DESDE LA BD
export async function generateStaticParams() {
const apps = await prisma.application.findMany({ select: { slug: true } });
return apps.map((app: { slug: string }) => ({ slug: app.slug }));
}
export const revalidate = 60;
// 🔥 AHORA RECIBIMOS EL LOCALE DESDE LA URL
export default async function ApplicationPage({ params }: { params: Promise<{ slug: string, locale: string }> }) {
const resolvedParams = await params;
const { slug, locale } = resolvedParams;
// 1. Buscamos la Teoría General de la Aplicación
const rawData = await prisma.application.findUnique({
where: { slug }
});
if (!rawData) {
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-[#F5F5F7] dark:bg-[#050505]">
<h1 className="text-2xl text-[#86868B] mb-4">Application not found in Database</h1>
<Link href={`/${locale}/#applications-deep`} className="text-[#00F0FF] hover:underline">Return Home</Link>
</div>
);
}
// 🔥 TRADUCIMOS LA APLICACIÓN PRINCIPAL
const data = getLocalizedData(rawData, locale);
// 2. Buscamos el "Muro de Soluciones" (Casos Reales específicos de esta app)
const rawRealCases = await prisma.globalNode.findMany({
where: {
application: slug,
isActive: true,
projectOverview: { not: null }
},
orderBy: { createdAt: 'desc' }
});
// 🔥 TRADUCIMOS TODOS LOS CASOS DE ESTUDIO DEL MURO
const realCases = rawRealCases.map((node: any) => getLocalizedData(node, locale));
// 3. Leemos las imágenes de la carpeta original
const images = getApplicationImages(slug);
// Pasamos TODO al componente cliente interactivo (que ya viene traducido)
return <ApplicationClient data={data} realCases={realCases} images={images} />;
}
+266
View File
@@ -0,0 +1,266 @@
export const dynamic = "force-dynamic";
import Link from "next/link";
import Image from "next/image";
import { prisma } from "@/lib/prisma";
import { ArrowLeft } from "lucide-react";
import BreathingField from "@/components/visuals/BreathingField";
// 🔥 IMPORTACIONES DE IDIOMAS
import { getLocalizedData } from "@/lib/i18nHelper";
import { getTranslations } from "next-intl/server";
// ── SÚPER PARSER MARKDOWN (Con Tablas, Imágenes y Dark Mode puro) ──
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-12 w-full overflow-x-auto pb-4 [scrollbar-width:none]">
<table className="w-full text-left border-collapse min-w-[600px] shadow-2xl rounded-2xl overflow-hidden border border-white/5">
<thead>
<tr className="bg-[#111]">
{tableHeaders.map((th, i) => (
<th key={i} className={`p-5 border-b border-white/10 text-xs uppercase tracking-widest font-semibold ${i === tableHeaders.length - 1 ? 'text-[#00F0FF] bg-[#00F0FF]/5' : 'text-white'}`}>
{parseInline(th)}
</th>
))}
</tr>
</thead>
<tbody className="bg-black/40 backdrop-blur-md">
{tableRows.map((row, rIdx) => (
<tr key={rIdx} className="hover:bg-white/[0.02] transition-colors group">
{row.map((cell, cIdx) => (
<td key={cIdx} className={`p-5 border-b border-white/5 text-sm ${cIdx === 0 ? 'text-[#A1A1A6] font-medium' : cIdx === row.length - 1 ? 'text-white font-semibold bg-[#00F0FF]/5 group-hover:bg-[#00F0FF]/10 transition-colors' : '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-8 text-[#A1A1A6] space-y-2 text-lg font-light marker:text-[#00F0FF]">
{listItems}
</ol>
) : (
<ul key={`ul-${elements.length}`} className="list-disc ml-6 mb-8 text-[#A1A1A6] space-y-2 text-lg font-light marker:text-[#00F0FF]">
{listItems}
</ul>
)
);
listItems = [];
}
};
const parseInline = (str: string) => {
const boldRegex = /\*\*(.*?)\*\*/g;
const italicRegex = /\*(.*?)\*/g;
let parts = str.split(boldRegex);
return parts.map((part, i) => {
if (i % 2 === 1) return <strong key={i} className="font-semibold text-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-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-12 rounded-[2rem] overflow-hidden border border-white/10 shadow-2xl bg-[#111]">
<img src={imgMatch[2]} alt={imgMatch[1]} className="w-full h-auto object-cover hover:scale-105 transition-transform duration-1000 grayscale hover:grayscale-0" loading="lazy" />
</div>
);
return;
}
const h3Match = trimmed.match(/^###\s*(.*)/);
if (h3Match) { pushList(); pushTable(); elements.push(<h3 key={idx} className="text-2xl mt-8 mb-4 font-medium text-[#00F0FF]">{parseInline(h3Match[1])}</h3>); return; }
const h2Match = trimmed.match(/^##\s*(.*)/);
if (h2Match) { pushList(); pushTable(); elements.push(<h2 key={idx} className="text-3xl mt-10 mb-5 font-light 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-4xl md:text-5xl mt-12 mb-6 font-light 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-[#00F0FF] pl-5 py-2 my-8 text-xl font-light italic text-[#A1A1A6] bg-[#00F0FF]/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-[#A1A1A6] font-light leading-relaxed mb-6 text-lg">
{parseInline(trimmed)}
</p>
);
});
pushList();
pushTable();
return <>{elements}</>;
};
// ── COMPONENTE PRINCIPAL (AHORA RECIBE EL LOCALE) ──
export default async function HeritagePage({ params }: { params: Promise<{ locale: string }> }) {
const resolvedParams = await params;
const locale = resolvedParams.locale;
// 🔥 Cargamos el diccionario para los textos fijos
const t = await getTranslations("HeritagePage");
let rawSections: any[] = [];
try {
rawSections = await prisma.heritageSection.findMany({
orderBy: { order: 'asc' }
});
} catch (e) {
console.error(e);
}
// 🔥 Pasamos las secciones de Prisma por el traductor dinámico
const sections = rawSections.map(sec => getLocalizedData(sec, locale));
return (
<main className="relative min-h-screen pb-24 bg-[#0A0A0C]">
<BreathingField />
{/* NAVEGACIÓN FLOTANTE */}
<div className="fixed top-24 left-6 z-50">
<Link href={`/${locale}/#our-story`} className="inline-flex items-center gap-2 text-sm font-medium text-[#86868B] hover:text-white transition-colors py-2 px-4 bg-white/5 backdrop-blur-md rounded-full border border-white/10">
<ArrowLeft size={16} /> {t("backToOverview")}
</Link>
</div>
{/* HERO GIGANTE */}
<section className="relative w-full h-[70vh] flex items-center justify-center overflow-hidden">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_center,rgba(0,102,204,0.15)_0%,transparent_70%)]" />
<div className="relative z-10 max-w-4xl mx-auto px-6 text-center">
<span className="text-[#00F0FF] uppercase tracking-widest text-sm font-semibold mb-4 block">
{t("subtitle")}
</span>
<h1 className="text-5xl md:text-7xl font-light text-white tracking-tight leading-tight">
{t("title1")} <br/> <span className="text-[#86868B]">{t("title2")}</span>
</h1>
</div>
</section>
{/* LOS BLOQUES DINÁMICOS DEL CMS */}
<div className="relative z-10 max-w-3xl mx-auto px-6 space-y-16 md:space-y-24">
{sections.length === 0 ? (
<p className="text-center text-[#86868B]">{t("emptyState")}</p>
) : (
sections.map((sec) => (
<div key={sec.id} className="animate-in fade-in slide-in-from-bottom-8 duration-1000">
{/* El título ya viene traducido */}
{sec.title && <h2 className="text-3xl font-medium text-white mb-6">{sec.title}</h2>}
{/* 🔥 BLOQUE DE TEXTO CON SÚPER MARKDOWN 🔥 */}
{sec.type === 'text' && (
<div className="max-w-none">
{renderMarkdown(sec.content || "")}
</div>
)}
{/* 🔥 BLOQUE DE IMAGEN GIGANTE 🔥 */}
{sec.type === 'image' && sec.mediaUrl && (
<div className="relative w-full h-80 md:h-[500px] rounded-3xl overflow-hidden border border-white/10 shadow-2xl">
<Image src={`/heritage/${sec.mediaUrl}`} alt={sec.title || "Heritage Image"} fill className="object-cover grayscale hover:grayscale-0 transition-all duration-1000" />
</div>
)}
{/* 🔥 BLOQUE DE VIDEO NATIVO (MP4 LOCAL) 🔥 */}
{sec.type === 'video' && sec.mediaUrl && (
<div className="relative w-full aspect-video rounded-3xl overflow-hidden border border-white/10 shadow-2xl bg-black">
<video
src={`/heritage/videos/${sec.mediaUrl}`}
className="absolute inset-0 w-full h-full object-cover"
autoPlay
loop
muted
playsInline
controls
/>
</div>
)}
</div>
))
)}
</div>
</main>
);
}
+81
View File
@@ -0,0 +1,81 @@
import type { Metadata, Viewport } from "next";
import { Inter } from "next/font/google";
import "../globals.css";
import NavBar from "@/components/layout/NavBar";
import NavigationManager from "@/components/layout/NavigationManager";
import SilentObserver from "@/components/ai/SilentObserver";
import Footer from "@/components/layout/Footer";
// 🔥 NUEVO: Importamos el Drawer del Carrito / Helpdesk
import CartDrawer from "@/components/layout/CartDrawer";
import { NextIntlClientProvider } from 'next-intl';
import { getMessages } from 'next-intl/server';
import { notFound } from 'next/navigation';
import { routing } from '@/i18n/routing';
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "FLUX | Energy, Directed.",
description: "Advanced Radio Frequency Solutions by Patrizio Grando.",
};
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
maximumScale: 1,
userScalable: false,
viewportFit: "cover",
};
export default async function RootLayout({
children,
params
}: Readonly<{
children: React.ReactNode;
params: Promise<{ locale: string }>;
}>) {
const resolvedParams = await params;
const locale = resolvedParams.locale;
if (!routing.locales.includes(locale as any)) {
notFound();
}
const messages = await getMessages();
return (
<html lang={locale} className="scroll-smooth bg-[#F5F5F7]">
<body
className={`${inter.className} text-[#1D1D1F] antialiased flex flex-col min-h-screen overflow-x-hidden w-full relative`}
style={{
overscrollBehaviorX: "none",
touchAction: "pan-y",
maxWidth: "100vw",
position: "relative",
}}
>
<NextIntlClientProvider messages={messages}>
<NavBar />
{/* 🔥 Panel del Carrito de Repuestos y Soporte Técnico 🔥 */}
<CartDrawer />
{/* Inyectamos el manejador de transiciones aquí */}
<NavigationManager />
<div className="flex-grow w-full flex flex-col relative">
{children}
</div>
<Footer />
<SilentObserver />
</NextIntlClientProvider>
</body>
</html>
);
}
+274
View File
@@ -0,0 +1,274 @@
export const dynamic = "force-dynamic";
import Link from "next/link";
import Image from "next/image";
import { prisma } from "@/lib/prisma";
import { ArrowLeft, Calendar, Tag, Linkedin } from "lucide-react";
import BreathingField from "@/components/visuals/BreathingField";
// 🔥 IMPORTACIÓN DEL MOTOR DE TRADUCCIÓN 🔥
import { getLocalizedData } from "@/lib/i18nHelper";
export async function generateStaticParams() {
const articles = await prisma.newsArticle.findMany({ select: { slug: true } });
return articles.map((a: any) => ({ slug: a.slug }));
}
// ── SÚPER PARSER MARKDOWN (Con Tablas, Imágenes y Dark/Light Mode) ──
// ... (El código del Súper Parser se queda IGUAL, no te preocupes) ...
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-12 w-full overflow-x-auto pb-4 [scrollbar-width:none]">
<table className="w-full text-left border-collapse min-w-[600px] shadow-2xl rounded-2xl overflow-hidden border border-black/5 dark:border-white/5">
<thead>
<tr className="bg-[#1D1D1F] dark:bg-[#111]">
{tableHeaders.map((th, i) => (
<th key={i} className={`p-5 border-b border-black/10 dark:border-white/10 text-xs uppercase tracking-widest font-semibold ${i === tableHeaders.length - 1 ? 'text-[#0066CC] dark:text-[#00F0FF] bg-[#0066CC]/5 dark:bg-[#00F0FF]/5' : 'text-white'}`}>
{parseInline(th)}
</th>
))}
</tr>
</thead>
<tbody className="bg-white/50 dark:bg-black/40 backdrop-blur-md">
{tableRows.map((row, rIdx) => (
<tr key={rIdx} className="hover:bg-black/5 dark:hover:bg-white/[0.02] transition-colors group">
{row.map((cell, cIdx) => (
<td key={cIdx} className={`p-5 border-b border-black/5 dark:border-white/5 text-sm ${cIdx === 0 ? 'text-[#86868B] font-medium' : cIdx === row.length - 1 ? 'text-[#1D1D1F] dark:text-white font-semibold bg-[#0066CC]/5 dark:bg-[#00F0FF]/5 group-hover:bg-[#0066CC]/10 dark:group-hover:bg-[#00F0FF]/10 transition-colors' : 'text-[#1D1D1F]/80 dark:text-white/80'}`}>
{parseInline(cell)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
inTable = false;
tableHeaders = [];
tableRows = [];
}
};
const pushList = () => {
if (listItems.length > 0) {
elements.push(
isOrderedList ? (
<ol key={`ol-${elements.length}`} className="list-decimal ml-6 mb-8 text-[#86868B] dark:text-[#A1A1A6] space-y-2 text-lg font-light marker:text-[#0066CC] dark:marker:text-[#00F0FF]">
{listItems}
</ol>
) : (
<ul key={`ul-${elements.length}`} className="list-disc ml-6 mb-8 text-[#86868B] dark:text-[#A1A1A6] space-y-2 text-lg font-light marker:text-[#0066CC] dark:marker:text-[#00F0FF]">
{listItems}
</ul>
)
);
listItems = [];
}
};
const parseInline = (str: string) => {
const boldRegex = /\*\*(.*?)\*\*/g;
const italicRegex = /\*(.*?)\*/g;
let parts = str.split(boldRegex);
return parts.map((part, i) => {
if (i % 2 === 1) return <strong key={i} className="font-semibold text-[#1D1D1F] dark:text-white">{part}</strong>;
let subParts = part.split(italicRegex);
return subParts.map((subPart, j) => {
if (j % 2 === 1) return <em key={`${i}-${j}`} className="italic text-[#1D1D1F]/90 dark:text-white/90">{subPart}</em>;
return subPart;
});
});
};
lines.forEach((line, idx) => {
const trimmed = line.trim();
if (!trimmed) {
pushList(); pushTable();
return;
}
if (trimmed.startsWith('|') && trimmed.endsWith('|')) {
pushList();
const cells = trimmed.split('|').filter((_, i, arr) => i !== 0 && i !== arr.length - 1).map(c => c.trim());
if (!inTable) { inTable = true; tableHeaders = cells; }
else if (cells.every(c => c.match(/^[-:]+$/))) { }
else { tableRows.push(cells); }
return;
} else {
pushTable();
}
const imgMatch = trimmed.match(/^!\[(.*?)\]\((.*?)\)$/);
if (imgMatch) {
pushList(); pushTable();
elements.push(
<div key={`img-${idx}`} className="relative w-full my-12 rounded-[2rem] overflow-hidden border border-black/10 dark:border-white/10 shadow-2xl bg-[#F5F5F7] dark:bg-[#111]">
<img src={imgMatch[2]} alt={imgMatch[1]} className="w-full h-auto object-cover hover:scale-105 transition-transform duration-1000" loading="lazy" />
</div>
);
return;
}
const h3Match = trimmed.match(/^###\s*(.*)/);
if (h3Match) { pushList(); pushTable(); elements.push(<h3 key={idx} className="text-2xl mt-8 mb-4 font-medium text-[#0066CC] dark:text-[#00F0FF]">{parseInline(h3Match[1])}</h3>); return; }
const h2Match = trimmed.match(/^##\s*(.*)/);
if (h2Match) { pushList(); pushTable(); elements.push(<h2 key={idx} className="text-3xl mt-10 mb-5 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-4xl md:text-5xl mt-12 mb-6 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-[#00F0FF] pl-6 py-2 my-8 text-xl font-light italic text-[#1D1D1F]/70 dark:text-[#A1A1A6] bg-[#0066CC]/5 dark:bg-[#00F0FF]/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-[#1D1D1F]/80 dark:text-[#A1A1A6] font-light leading-relaxed mb-6 text-lg">
{parseInline(trimmed)}
</p>
);
});
pushList();
pushTable();
return <>{elements}</>;
};
// ── COMPONENTE PRINCIPAL (AHORA LEE EL LOCALE) ──
export default async function ArticlePage({ params }: { params: Promise<{ slug: string, locale: string }> }) {
const resolvedParams = await params;
const { slug, locale } = resolvedParams;
const rawArticle = await prisma.newsArticle.findUnique({
where: { slug }
});
if (!rawArticle) {
return (
<div className="min-h-screen flex flex-col items-center justify-center text-center">
<h1 className="text-2xl text-[#86868B] mb-4">Article not found</h1>
<Link href={`/${locale}/news`} className="text-[#0066CC] hover:underline">Return to News Hub</Link>
</div>
);
}
// 🔥 TRADUCCIÓN MÁGICA ANTES DEL RENDER 🔥
const article = getLocalizedData(rawArticle, locale);
let gallery: string[] = [];
try { gallery = JSON.parse(article.galleryJson || "[]"); } catch (e) {}
return (
<main className="relative min-h-screen pb-24">
<BreathingField />
<div className="fixed top-24 left-6 z-50 hidden md:block">
{/* EL BOTÓN DE VOLVER AHORA RESPETA EL IDIOMA */}
<Link href={`/${locale}/news`} className="inline-flex items-center gap-2 text-sm font-medium text-[#86868B] hover:text-[#0066CC] dark:hover:text-[#4DA6FF] transition-colors py-2 px-4 bg-white/50 dark:bg-black/50 backdrop-blur-md rounded-full group shadow-sm border border-black/5 dark:border-white/10">
<ArrowLeft size={16} className="group-hover:-translate-x-1 transition-transform" /> Back to News Hub
</Link>
</div>
<section className="relative w-full h-[50vh] md:h-[70vh] flex items-end justify-center overflow-hidden bg-[#1D1D1F]">
{article.coverImage && (
<Image src={`/news/${article.coverImage}`} alt={article.title} fill className="object-cover object-center opacity-60" priority />
)}
<div className="absolute inset-0 bg-gradient-to-t from-white via-white/80 dark:from-[#0A0A0C] dark:via-[#0A0A0C]/80 to-transparent" />
<div className="relative z-10 max-w-4xl w-full mx-auto px-6 pb-12 md:pb-20 text-center">
<div className="flex flex-wrap items-center justify-center gap-4 text-xs font-semibold uppercase tracking-widest text-[#0066CC] dark:text-[#4DA6FF] mb-6">
<span className="flex items-center gap-1.5 bg-[#0066CC]/10 dark:bg-[#4DA6FF]/10 px-3 py-1.5 rounded-full"><Tag size={14}/> {article.category}</span>
<span className="flex items-center gap-1.5 text-[#86868B]"><Calendar size={14}/> {new Date(article.publishedAt).toLocaleDateString(locale === 'en' ? 'en-US' : locale, { month: 'long', day: 'numeric', year: 'numeric' })}</span>
</div>
<h1 className="text-4xl md:text-6xl font-medium text-[#1D1D1F] dark:text-[#F5F5F7] tracking-tight mb-6 leading-tight">
{article.title}
</h1>
<p className="text-lg md:text-xl text-[#86868B] dark:text-[#A1A1A6] font-light max-w-2xl mx-auto">
{article.excerpt}
</p>
</div>
</section>
<div className="relative z-10 max-w-3xl mx-auto px-6 mt-12 md:mt-20">
{/* 🔥 EL MARKDOWN TRADUCIDO AL VUELO 🔥 */}
<div className="max-w-none mb-16">
{renderMarkdown(article.content)}
</div>
{gallery.length > 0 && (
<div className="mt-16 pt-16 border-t border-black/5 dark:border-white/5">
<h3 className="text-xl font-medium mb-8 text-[#1D1D1F] dark:text-white">Media Gallery</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{gallery.map((imgSrc: string, idx: number) => (
<div key={idx} className={`relative rounded-3xl 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-96' : 'h-48 md:h-64'}`}>
<Image src={`/news/${imgSrc}`} alt={`Gallery image ${idx + 1}`} fill className="object-cover hover:scale-105 transition-transform duration-700" />
</div>
))}
</div>
</div>
)}
<div className="mt-16 pt-8 border-t border-black/10 dark:border-white/10 flex justify-between items-center">
<Link href={`/${locale}/news`} className="text-[#0066CC] dark:text-[#4DA6FF] font-medium flex items-center gap-2 hover:underline md:hidden">
<ArrowLeft size={16} /> Back to News
</Link>
{article.linkedinUrl ? (
<a
href={article.linkedinUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-sm font-semibold text-white bg-[#0A66C2] px-6 py-3 rounded-full hover:bg-[#004182] transition-all shadow-lg shadow-[#0A66C2]/20 ml-auto md:ml-0"
>
<Linkedin size={16} /> Join the conversation on LinkedIn
</a>
) : (
<div className="text-xs text-[#86868B] italic hidden md:block">
Internal Corporate Release
</div>
)}
</div>
</div>
</main>
);
}
+117
View File
@@ -0,0 +1,117 @@
export const dynamic = "force-dynamic";
import Link from "next/link";
import Image from "next/image";
import { prisma } from "@/lib/prisma";
import { Newspaper, ArrowRight, Calendar } from "lucide-react";
import BreathingField from "@/components/visuals/BreathingField";
import { getLocalizedData } from "@/lib/i18nHelper";
import { getTranslations } from "next-intl/server"; // 🔥 IMPORTAMOS LA VERSIÓN DE SERVIDOR
export const revalidate = 60;
export default async function NewsHub({ params }: { params: Promise<{ locale: string }> }) {
const resolvedParams = await params;
const locale = resolvedParams.locale;
// 🔥 LLAMAMOS AL DICCIONARIO
const t = await getTranslations("NewsHub");
let rawArticles: any[] = [];
try {
rawArticles = await prisma.newsArticle.findMany({
where: { isActive: true },
orderBy: [{ order: 'asc' }, { publishedAt: 'desc' }]
});
} catch (error) {
console.error("Error fetching news:", error);
}
const articles = rawArticles.map(article => getLocalizedData(article, locale));
const heroArticle = articles.length > 0 ? articles[0] : null;
const gridArticles = articles.length > 1 ? articles.slice(1) : [];
return (
<main className="relative min-h-screen pt-32 pb-24">
<BreathingField />
<div className="relative z-10 max-w-7xl mx-auto px-6">
<header className="mb-16 md:mb-24 text-center max-w-3xl mx-auto">
<div className="inline-flex items-center justify-center gap-2 text-[#0066CC] dark:text-[#4DA6FF] mb-6 bg-[#0066CC]/10 dark:bg-[#4DA6FF]/10 px-4 py-2 rounded-full">
<Newspaper size={18} />
<span className="text-xs font-semibold uppercase tracking-widest">{t("subtitle")}</span>
</div>
<h1 className="text-5xl md:text-7xl font-light text-[#1D1D1F] dark:text-[#F5F5F7] tracking-tight mb-6">
{t("title1")} <span className="font-medium">{t("title2")}</span>
</h1>
<p className="text-lg md:text-xl text-[#86868B] dark:text-[#A1A1A6] font-light leading-relaxed">
{t("description")}
</p>
</header>
{articles.length === 0 ? (
<div className="text-center py-20 border-2 border-dashed border-black/10 dark:border-white/10 rounded-3xl">
<p className="text-[#86868B] dark:text-[#A1A1A6]">{t("emptyState")}</p>
</div>
) : (
<div className="flex flex-col gap-6 md:gap-8">
{/* HERO ARTICLE */}
{heroArticle && (
<Link href={`/${locale}/news/${heroArticle.slug}`} className="group relative w-full rounded-[2rem] overflow-hidden bg-white/60 dark:bg-[#1D1D1F]/40 backdrop-blur-2xl border border-black/5 dark:border-white/10 flex flex-col md:flex-row shadow-lg hover:shadow-xl transition-all duration-500 hover:-translate-y-1">
<div className="w-full md:w-1/2 h-56 md:h-[400px] relative overflow-hidden bg-[#1D1D1F]">
{heroArticle.coverImage ? (
<Image src={`/news/${heroArticle.coverImage}`} alt={heroArticle.title} fill className="object-cover transition-transform duration-700 group-hover:scale-105" />
) : (
<div className="absolute inset-0 bg-gradient-to-br from-[#0066CC]/20 to-[#0A0A0C] flex items-center justify-center"><Newspaper size={64} className="text-white/10" /></div>
)}
</div>
<div className="w-full md:w-1/2 p-6 md:p-10 flex flex-col justify-center">
<div className="flex items-center gap-3 mb-4">
<span className="text-[9px] font-bold uppercase tracking-widest text-[#0066CC] dark:text-[#4DA6FF] bg-[#0066CC]/10 dark:bg-[#4DA6FF]/10 px-2.5 py-1 rounded-full">{heroArticle.category}</span>
<span className="text-[10px] font-medium text-[#86868B] flex items-center gap-1"><Calendar size={12}/> {new Date(heroArticle.publishedAt).toLocaleDateString(locale === 'en' ? 'en-US' : locale, { month: 'short', day: 'numeric', year: 'numeric' })}</span>
</div>
<h2 className="text-2xl md:text-3xl font-medium text-[#1D1D1F] dark:text-white mb-3 leading-tight group-hover:text-[#0066CC] dark:group-hover:text-[#4DA6FF] transition-colors">{heroArticle.title}</h2>
<p className="text-sm text-[#86868B] dark:text-[#A1A1A6] leading-relaxed mb-6 line-clamp-3">{heroArticle.excerpt}</p>
<span className="inline-flex items-center gap-1.5 text-xs font-semibold text-[#1D1D1F] dark:text-white mt-auto">
{t("readFull")} <ArrowRight size={14} className="group-hover:translate-x-1 transition-transform" />
</span>
</div>
</Link>
)}
{/* GRID COLUMNAS */}
{gridArticles.length > 0 && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 md:gap-6 mt-4">
{gridArticles.map((article) => (
<Link key={article.id} href={`/${locale}/news/${article.slug}`} className="group flex flex-col bg-white/60 dark:bg-[#1D1D1F]/40 backdrop-blur-md border border-black/5 dark:border-white/10 rounded-3xl overflow-hidden shadow-sm hover:shadow-md transition-all duration-500 hover:-translate-y-1">
<div className="w-full h-40 relative overflow-hidden bg-[#1D1D1F]">
{article.coverImage ? (
<Image src={`/news/${article.coverImage}`} alt={article.title} fill className="object-cover transition-transform duration-700 group-hover:scale-105" />
) : (
<div className="absolute inset-0 bg-gradient-to-br from-[#0066CC]/10 to-[#0A0A0C]" />
)}
</div>
<div className="p-5 flex flex-col flex-1">
<div className="flex justify-between items-center mb-3">
<span className="text-[8px] font-bold uppercase tracking-widest text-[#0066CC] dark:text-[#4DA6FF]">{article.category}</span>
<span className="text-[9px] text-[#86868B]">{new Date(article.publishedAt).toLocaleDateString(locale === 'en' ? 'en-US' : locale, { month: 'short', day: 'numeric' })}</span>
</div>
<h3 className="text-base font-medium text-[#1D1D1F] dark:text-white mb-2 leading-snug group-hover:text-[#0066CC] dark:group-hover:text-[#4DA6FF] transition-colors line-clamp-2">{article.title}</h3>
<p className="text-xs text-[#86868B] dark:text-[#A1A1A6] line-clamp-2 mb-4">{article.excerpt}</p>
<span className="mt-auto flex items-center gap-1.5 text-[11px] font-semibold text-[#1D1D1F] dark:text-white group-hover:text-[#0066CC] dark:group-hover:text-[#4DA6FF] transition-colors">
{t("readMore")} <ArrowRight size={12} className="group-hover:translate-x-1 transition-transform" />
</span>
</div>
</Link>
))}
</div>
)}
</div>
)}
</div>
</main>
);
}
+107
View File
@@ -0,0 +1,107 @@
// src/app/[locale]/page.tsx
// ✅ CORRECCIÓN: dynamic ya estaba, pero reforzamos el patrón de params
import fs from "fs";
import path from "path";
import { prisma } from "@/lib/prisma";
import { getLocalizedData } from "@/lib/i18nHelper";
import BreathingField from "@/components/visuals/BreathingField";
import ApplicationsDashboard from "@/components/sections/ApplicationsDashboard";
import GlobalOperations from "@/components/sections/GlobalOperations";
import PatrizioLegacy from "@/components/sections/PatrizioLegacy";
import OurStory from "@/components/sections/OurStory";
import ApplicationsDeep from "@/components/sections/ApplicationsDeep";
import HeroReel from "@/components/sections/HeroReel";
import WhatWeDo from "@/components/sections/WhatWeDo";
export const dynamic = "force-dynamic";
// ✅ Next.js 16: params es Promise y DEBE ser awaiteado
export default async function Home({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
// --- 1. LECTURA DE IMÁGENES ---
let footageImages: string[] = [];
try {
const footageDir = path.join(process.cwd(), "public", "footage", "main");
if (fs.existsSync(footageDir)) {
const files = fs.readdirSync(footageDir);
footageImages = files
.filter(file => /\.(png|jpe?g|webp)$/i.test(file))
.map(file => `/footage/main/${file}`);
}
} catch (error) {
console.error("Error reading footage directory:", error);
}
// --- 2. NODOS DEL GLOBO ---
let mapNodes: any[] = [];
try {
const rawNodes = await prisma.globalNode.findMany({
where: { isActive: true },
select: {
id: true, title: true, location: true, lat: true, lon: true,
application: true, stats: true, nodeType: true, projectOverview: true,
mediaFileName: true, energySavings: true, galleryJson: true,
eventDate: true, translationsJson: true
}
});
mapNodes = rawNodes.map((node: any) => {
const localizedNode = getLocalizedData(node as any, locale);
return {
...localizedNode,
eventDate: localizedNode.eventDate ? localizedNode.eventDate.toISOString() : null
};
});
} catch (error) {
console.error("Error fetching global nodes from DB:", error);
}
// --- 3. APLICACIONES ---
let dbApps: any[] = [];
try {
const rawApps = await prisma.application.findMany({
select: {
slug: true, title: true, subtitle: true, category: true,
shortDescription: true, heroDescription: true,
dashboardMetricsJson: true, isActive: true, translationsJson: true
},
orderBy: { createdAt: "asc" }
});
dbApps = rawApps.map((app: any) => getLocalizedData(app as any, locale));
} catch (error) {
console.error("Error fetching applications from DB:", error);
}
// --- 4. LÍNEA DE TIEMPO ---
let dbTimeline: any[] = [];
try {
const rawTimeline = await prisma.timelineEvent.findMany({
where: { isActive: true },
orderBy: { order: "asc" }
});
dbTimeline = rawTimeline.map((item: any) => getLocalizedData(item as any, locale));
} catch (error) {
console.error("Error fetching timeline from DB:", error);
}
return (
<main className="relative min-h-screen flex flex-col items-center w-full">
<BreathingField />
<div className="w-full overflow-hidden flex flex-col items-center justify-center">
<HeroReel images={footageImages} />
</div>
<WhatWeDo />
<div className="w-full overflow-hidden flex flex-col items-center">
<ApplicationsDeep dbApps={dbApps} />
<ApplicationsDashboard dbApps={dbApps} />
<GlobalOperations dbNodes={mapNodes} dbApps={dbApps} />
<OurStory dbTimeline={dbTimeline} />
<PatrizioLegacy />
<div className="h-64 w-full"></div>
</div>
</main>
);
}
@@ -0,0 +1,207 @@
"use client";
import { useState, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { X, KeyRound, Building2, User, Mail, LogOut, ShieldCheck, Sparkles, Loader2, Lock } from "lucide-react";
import { loginClient, registerClientRequest, logoutClient, updateClientPassword } from "@/app/actions/clientAuth";
import { useRouter } from "next/navigation";
export default function AuthModal({ session }: { session: any }) {
const [isOpen, setIsOpen] = useState(false);
const [mode, setMode] = useState<"LOGIN" | "REGISTER" | "PROFILE">(session ? "PROFILE" : "LOGIN");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const router = useRouter();
useEffect(() => {
const handleOpen = (e: Event) => {
const detail = (e as CustomEvent).detail;
setIsOpen(true);
setError(null);
setSuccess(null);
if (session) setMode("PROFILE");
else if (detail?.mode) setMode(detail.mode);
};
window.addEventListener("flux:open-auth", handleOpen);
return () => window.removeEventListener("flux:open-auth", handleOpen);
}, [session]);
if (!isOpen) return null;
const handleLogin = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = e.currentTarget;
setIsLoading(true);
setError(null);
const res = await loginClient(new FormData(form));
if (res.error) {
setError(res.error);
} else {
setIsOpen(false);
router.refresh();
}
setIsLoading(false);
};
const handleRegister = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = e.currentTarget;
setIsLoading(true);
setError(null);
const res = await registerClientRequest(new FormData(form));
if (res.error) {
setError(res.error);
} else {
setSuccess("Access requested successfully. We will notify you via email upon engineering approval.");
form.reset();
}
setIsLoading(false);
};
const handleChangePassword = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = e.currentTarget;
setIsLoading(true);
setError(null);
setSuccess(null);
const res = await updateClientPassword(new FormData(form));
if (res.error) {
setError(res.error);
} else {
setSuccess("Password updated securely.");
form.reset();
}
setIsLoading(false);
};
const handleLogout = async () => {
setIsLoading(true);
await logoutClient();
setIsOpen(false);
router.refresh();
};
return (
<div className="fixed inset-0 z-[200] flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setIsOpen(false)}
className="absolute inset-0 bg-black/60 backdrop-blur-md"
/>
<motion.div
initial={{ opacity: 0, y: 20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="relative w-full max-w-md bg-white dark:bg-[#111] rounded-[2rem] shadow-2xl overflow-hidden border border-black/5 dark:border-white/10"
>
{/* 🔥 FIX: Z-INDEX 100 PARA EVITAR BLOQUEOS */}
<button
onClick={() => setIsOpen(false)}
className="absolute top-4 right-4 p-2 bg-black/5 dark:bg-white/10 hover:bg-black/10 dark:hover:bg-white/20 rounded-full transition-colors z-[100]"
>
<X size={16} className="text-[#1D1D1F] dark:text-white" />
</button>
<div className="bg-[#F5F5F7] dark:bg-[#0A0A0C] p-6 pb-4 border-b border-black/5 dark:border-white/5 text-center relative overflow-hidden">
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-32 h-32 bg-[#0066CC]/20 blur-[50px] rounded-full pointer-events-none" />
<div className="relative z-10 flex flex-col items-center justify-center mb-3">
<img src="/flux-logo.svg" alt="FLUX" className="h-12 w-auto mb-2 drop-shadow-sm" />
</div>
<h2 className="text-2xl font-light text-[#1D1D1F] dark:text-white relative z-10 flex items-center justify-center gap-2">
<Lock size={20} className="text-[#86868B]" /> B2B Portal
</h2>
{!session && (
<div className="flex bg-black/5 dark:bg-white/5 rounded-xl p-1 mt-6 relative z-10">
<button onClick={() => {setMode("LOGIN"); setError(null); setSuccess(null);}} className={`flex-1 py-2 text-xs font-semibold rounded-lg transition-all ${mode === "LOGIN" ? "bg-white dark:bg-[#1D1D1F] shadow text-[#1D1D1F] dark:text-white" : "text-[#86868B]"}`}>Sign In</button>
<button onClick={() => {setMode("REGISTER"); setError(null); setSuccess(null);}} className={`flex-1 py-2 text-xs font-semibold rounded-lg transition-all ${mode === "REGISTER" ? "bg-white dark:bg-[#1D1D1F] shadow text-[#1D1D1F] dark:text-white" : "text-[#86868B]"}`}>Request Access</button>
</div>
)}
</div>
<div className="p-6">
{error && <div className="mb-4 p-3 bg-rose-500/10 border border-rose-500/20 text-rose-500 text-xs rounded-xl flex items-start gap-2"><ShieldCheck size={14} className="shrink-0 mt-0.5"/> {error}</div>}
{success && <div className="mb-4 p-3 bg-emerald-500/10 border border-emerald-500/20 text-emerald-600 text-xs rounded-xl flex items-start gap-2"><Sparkles size={14} className="shrink-0 mt-0.5"/> {success}</div>}
{mode === "LOGIN" && !session && (
<form onSubmit={handleLogin} className="space-y-4">
<div>
<label className="text-[10px] font-bold uppercase tracking-widest text-[#86868B] mb-1 flex items-center gap-1.5"><Mail size={12}/> Corporate Email</label>
<input name="email" type="email" required className="w-full bg-black/5 dark:bg-white/5 border border-transparent focus:border-[#0066CC] rounded-xl px-4 py-3 text-sm outline-none text-[#1D1D1F] dark:text-white transition-colors" />
</div>
<div>
<label className="text-[10px] font-bold uppercase tracking-widest text-[#86868B] mb-1 flex items-center gap-1.5"><KeyRound size={12}/> Password</label>
<input name="password" type="password" required className="w-full bg-black/5 dark:bg-white/5 border border-transparent focus:border-[#0066CC] rounded-xl px-4 py-3 text-sm outline-none text-[#1D1D1F] dark:text-white transition-colors" />
</div>
<button disabled={isLoading} className="w-full mt-2 bg-[#1D1D1F] dark:bg-white text-white dark:text-black py-3.5 rounded-xl text-sm font-semibold flex justify-center items-center gap-2 active:scale-[0.98] transition-transform">
{isLoading ? <Loader2 size={16} className="animate-spin" /> : "Access Secure Portal"}
</button>
</form>
)}
{mode === "REGISTER" && !session && (
<form onSubmit={handleRegister} className="space-y-3">
<div>
<label className="text-[10px] font-bold uppercase tracking-widest text-[#86868B] mb-1 flex items-center gap-1.5"><User size={12}/> Full Name</label>
<input name="fullName" required className="w-full bg-black/5 dark:bg-white/5 border border-transparent focus:border-[#0066CC] rounded-xl px-4 py-2.5 text-sm outline-none text-[#1D1D1F] dark:text-white transition-colors" />
</div>
<div>
<label className="text-[10px] font-bold uppercase tracking-widest text-[#86868B] mb-1 flex items-center gap-1.5"><Building2 size={12}/> Company Name</label>
<input name="companyName" required className="w-full bg-black/5 dark:bg-white/5 border border-transparent focus:border-[#0066CC] rounded-xl px-4 py-2.5 text-sm outline-none text-[#1D1D1F] dark:text-white transition-colors" />
</div>
<div>
<label className="text-[10px] font-bold uppercase tracking-widest text-[#86868B] mb-1 flex items-center gap-1.5"><Mail size={12}/> Work Email</label>
<input name="email" type="email" required className="w-full bg-black/5 dark:bg-white/5 border border-transparent focus:border-[#0066CC] rounded-xl px-4 py-2.5 text-sm outline-none text-[#1D1D1F] dark:text-white transition-colors" />
</div>
<div>
<label className="text-[10px] font-bold uppercase tracking-widest text-[#86868B] mb-1 flex items-center gap-1.5"><KeyRound size={12}/> Create Password</label>
<input name="password" type="password" required className="w-full bg-black/5 dark:bg-white/5 border border-transparent focus:border-[#0066CC] rounded-xl px-4 py-2.5 text-sm outline-none text-[#1D1D1F] dark:text-white transition-colors" />
</div>
<button disabled={isLoading} className="w-full mt-4 bg-[#1D1D1F] dark:bg-white text-white dark:text-black py-3.5 rounded-xl text-sm font-semibold flex justify-center items-center gap-2 active:scale-[0.98] transition-transform">
{isLoading ? <Loader2 size={16} className="animate-spin" /> : "Submit Request"}
</button>
</form>
)}
{mode === "PROFILE" && session && (
<div className="space-y-6">
<div className="bg-[#F5F5F7] dark:bg-white/5 p-4 rounded-2xl flex items-center gap-4 border border-black/5 dark:border-white/5">
<div className="w-12 h-12 bg-[#0066CC] text-white rounded-full flex items-center justify-center text-xl font-light shrink-0">
{session.name.charAt(0)}
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-[#1D1D1F] dark:text-white truncate">{session.name}</p>
<p className="text-xs text-[#86868B] truncate">{session.company}</p>
<p className="text-[10px] text-[#0066CC] font-mono mt-0.5 truncate">{session.email}</p>
</div>
</div>
<form onSubmit={handleChangePassword} className="space-y-3 pt-4 border-t border-black/5 dark:border-white/10">
<h3 className="text-xs font-semibold text-[#1D1D1F] dark:text-white mb-2">Update Security Credentials</h3>
<input name="currentPassword" type="password" placeholder="Current Password" required className="w-full bg-black/5 dark:bg-white/5 border border-transparent focus:border-[#0066CC] rounded-xl px-4 py-2.5 text-sm outline-none text-[#1D1D1F] dark:text-white transition-colors" />
<input name="newPassword" type="password" placeholder="New Password" required minLength={8} className="w-full bg-black/5 dark:bg-white/5 border border-transparent focus:border-[#0066CC] rounded-xl px-4 py-2.5 text-sm outline-none text-[#1D1D1F] dark:text-white transition-colors" />
<button disabled={isLoading} className="w-full bg-black/5 dark:bg-white/10 hover:bg-black/10 text-[#1D1D1F] dark:text-white py-2.5 rounded-xl text-sm font-medium transition-colors">
{isLoading ? <Loader2 size={16} className="animate-spin mx-auto" /> : "Change Password"}
</button>
</form>
<button onClick={handleLogout} className="w-full flex items-center justify-center gap-2 text-rose-500 hover:bg-rose-500/10 py-3.5 rounded-xl text-sm font-semibold transition-colors">
<LogOut size={16}/> Secure Logout
</button>
</div>
)}
</div>
</motion.div>
</div>
);
}
@@ -0,0 +1,150 @@
"use client";
import { useUIStore } from "@/lib/store/uiStore";
import { Plus, Check, Wrench, Search, X, ChevronLeft, ChevronRight, Lock, KeyRound, UserCircle } from "lucide-react";
import { motion } from "framer-motion";
import { useState, useEffect } from "react";
import { useTranslations } from "next-intl";
import PartDetailsModal from "./PartDetailsModal";
import AuthModal from "./AuthModal";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
interface ComponentGridProps {
initialParts: any[];
locale: string;
query: string;
currentPage: number;
totalPages: number;
totalItems: number;
session: any | null; // 🔥 LA SESIÓN DETERMINA TODO
}
export default function ComponentGrid({ initialParts, locale, query, currentPage, totalPages, totalItems, session }: ComponentGridProps) {
const t = useTranslations("SpareParts");
const addToCart = useUIStore(state => state.addToCart);
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [addedSku, setAddedSku] = useState<string | null>(null);
const [selectedPart, setSelectedPart] = useState<any | null>(null);
const [searchTerm, setSearchTerm] = useState(query);
useEffect(() => {
const timeoutId = setTimeout(() => {
if (searchTerm !== query && session) { // Solo busca si hay sesión
const params = new URLSearchParams(searchParams.toString());
if (searchTerm) { params.set('q', searchTerm); params.set('page', '1'); }
else { params.delete('q'); }
router.push(`${pathname}?${params.toString()}`, { scroll: false });
}
}, 500);
return () => clearTimeout(timeoutId);
}, [searchTerm, router, pathname, searchParams, query, session]);
const handleAdd = (e: React.MouseEvent, part: any) => {
e.stopPropagation();
addToCart({ id: part.id, sku: part.sku, title: part.title, price: part.price, showPrice: part.showPrice, mediaUrl: part.coverImage });
setAddedSku(part.sku);
setTimeout(() => setAddedSku(null), 1500);
};
const handlePageChange = (newPage: number) => {
if (newPage < 1 || newPage > totalPages) return;
const params = new URLSearchParams(searchParams.toString());
params.set('page', newPage.toString());
router.push(`${pathname}?${params.toString()}`);
};
// 🔥 EVENTO PARA ABRIR MODAL
const openAuth = (mode: "LOGIN" | "REGISTER") => {
window.dispatchEvent(new CustomEvent('flux:open-auth', { detail: { mode } }));
};
return (
<>
<AuthModal session={session} />
{/* 🔥 BOTÓN FLOTANTE DE PERFIL / LOGIN (Arriba a la derecha) */}
<div className="absolute top-32 right-6 md:right-12 z-50">
<button
onClick={() => window.dispatchEvent(new CustomEvent('flux:open-auth'))}
className="flex items-center gap-2 px-4 py-2 bg-white dark:bg-[#111] border border-black/10 dark:border-white/10 rounded-full text-sm font-medium text-[#1D1D1F] dark:text-white shadow-sm hover:shadow-md transition-all"
>
{session ? <><UserCircle size={16}/> {session.name}</> : <><Lock size={16}/> B2B Login</>}
</button>
</div>
{/* 🛑 ESTADO BLOQUEADO (Visitantes sin sesión) */}
{!session ? (
<div className="mt-8 py-24 flex flex-col items-center justify-center text-center border border-dashed border-black/10 dark:border-white/10 rounded-[2rem] bg-white/30 dark:bg-[#111]/30">
<div className="w-20 h-20 bg-black/5 dark:bg-white/5 rounded-full flex items-center justify-center mb-6">
<Lock size={32} className="text-[#1D1D1F] dark:text-white" />
</div>
<h3 className="text-2xl font-light text-[#1D1D1F] dark:text-white mb-3">Access Restricted</h3>
<p className="text-[#86868B] max-w-md mx-auto mb-8">
The FLUX Component Matrix is an exclusive B2B portal. Please sign in with your corporate account or request access to view components, pricing, and technical datasheets.
</p>
<div className="flex flex-col sm:flex-row gap-4">
<button onClick={() => openAuth("LOGIN")} className="px-8 py-3.5 rounded-xl text-sm font-semibold bg-[#1D1D1F] dark:bg-white text-white dark:text-black flex items-center justify-center gap-2 transition-transform active:scale-95"><KeyRound size={16}/> Sign In</button>
<button onClick={() => openAuth("REGISTER")} className="px-8 py-3.5 rounded-xl text-sm font-semibold bg-black/5 dark:bg-white/10 text-[#1D1D1F] dark:text-white hover:bg-black/10 dark:hover:bg-white/20 transition-all active:scale-95">Request Access</button>
</div>
</div>
) : (
/* ✅ ESTADO DESBLOQUEADO (Catálogo Completo) */
<>
<div className="flex flex-col md:flex-row justify-between items-center gap-4 mb-8">
<div className="relative w-full md:w-96">
<div className="absolute inset-y-0 left-4 flex items-center pointer-events-none"><Search size={18} className="text-[#86868B]" /></div>
<input type="text" value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} placeholder="Search by SKU, name or spec..." className="w-full bg-white dark:bg-[#111] border border-black/10 dark:border-white/10 rounded-full pl-12 pr-10 py-3.5 text-sm text-[#1D1D1F] dark:text-white outline-none focus:border-[#0066CC] dark:focus:border-amber-500 transition-colors shadow-sm" />
{searchTerm && <button onClick={() => setSearchTerm("")} className="absolute inset-y-0 right-4 flex items-center text-[#86868B] hover:text-[#1D1D1F] dark:hover:text-white transition-colors"><X size={16} /></button>}
</div>
<div className="text-sm text-[#86868B] font-medium">{totalItems} {totalItems === 1 ? "component found" : "components found"}</div>
</div>
{initialParts.length === 0 ? (
<div className="py-20 flex flex-col items-center justify-center text-center border border-dashed border-black/10 dark:border-white/10 rounded-[2rem] bg-white/30 dark:bg-[#111]/30">
<Wrench size={48} className="text-[#86868B]/30 mb-4" />
<h3 className="text-lg font-medium text-[#1D1D1F] dark:text-white mb-2">No components found</h3>
<button onClick={() => setSearchTerm("")} className="mt-4 text-sm text-[#0066CC] dark:text-amber-500 font-medium hover:underline">Clear search</button>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{initialParts.map((part, idx) => {
const isAddedRecently = addedSku === part.sku;
return (
<motion.div key={part.sku} initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.4, delay: idx * 0.05 }} onClick={() => setSelectedPart(part)} className="group flex flex-col bg-white dark:bg-[#111] rounded-[2rem] border border-black/5 dark:border-white/5 overflow-hidden shadow-sm hover:shadow-xl transition-all duration-300 cursor-pointer">
<div className="aspect-square bg-black/5 dark:bg-black/40 relative overflow-hidden flex items-center justify-center p-6">
{part.coverImage ? <img src={`/parts/${part.sku.toLowerCase()}/${part.coverImage}`} alt={part.title} className="w-full h-full object-contain group-hover:scale-105 transition-transform duration-500" /> : <Wrench size={48} className="text-black/10 dark:text-white/10" />}
<div className="absolute top-4 left-4 bg-white/80 dark:bg-black/60 backdrop-blur px-2.5 py-1 rounded text-[10px] font-mono tracking-widest text-[#1D1D1F] dark:text-white border border-black/10 dark:border-white/10">{part.sku}</div>
</div>
<div className="p-6 flex flex-col flex-1">
<h3 className="font-medium text-[#1D1D1F] dark:text-white mb-1 line-clamp-2">{part.title}</h3>
<div className="mt-auto pt-4 flex items-center justify-between">
{part.showPrice && part.price ? <span className="font-mono text-lg text-[#1D1D1F] dark:text-[#F5F5F7]">{part.price.toFixed(2)}</span> : <span className="text-xs uppercase tracking-widest text-[#86868B] font-semibold">{t("quoteBased")}</span>}
<button onClick={(e) => handleAdd(e, part)} className={`w-10 h-10 rounded-full flex items-center justify-center transition-all z-10 ${isAddedRecently ? "bg-emerald-500 text-white" : "bg-[#F5F5F7] dark:bg-white/5 hover:bg-[#1D1D1F] hover:text-white dark:hover:bg-amber-500 dark:hover:text-black text-[#1D1D1F] dark:text-white"}`}>
{isAddedRecently ? <Check size={18} /> : <Plus size={18} />}
</button>
</div>
</div>
</motion.div>
);
})}
</div>
)}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-4 mt-12">
<button onClick={() => handlePageChange(currentPage - 1)} disabled={currentPage === 1} className="p-3 rounded-full bg-white dark:bg-[#111] border border-black/10 dark:border-white/10 text-[#1D1D1F] dark:text-white disabled:opacity-30 disabled:cursor-not-allowed hover:bg-black/5 dark:hover:bg-white/5 transition-colors"><ChevronLeft size={20} /></button>
<span className="text-sm font-medium text-[#86868B]">Page <strong className="text-[#1D1D1F] dark:text-white">{currentPage}</strong> of {totalPages}</span>
<button onClick={() => handlePageChange(currentPage + 1)} disabled={currentPage === totalPages} className="p-3 rounded-full bg-white dark:bg-[#111] border border-black/10 dark:border-white/10 text-[#1D1D1F] dark:text-white disabled:opacity-30 disabled:cursor-not-allowed hover:bg-black/5 dark:hover:bg-white/5 transition-colors"><ChevronRight size={20} /></button>
</div>
)}
<PartDetailsModal part={selectedPart} isOpen={!!selectedPart} onClose={() => setSelectedPart(null)} />
</>
)}
</>
);
}
@@ -0,0 +1,203 @@
"use client";
import { motion, AnimatePresence } from "framer-motion";
import { X, Wrench, ShoppingBag, ChevronLeft, ChevronRight, Tag, Info, Play, Lock } from "lucide-react";
import { useState } from "react";
import { useUIStore } from "@/lib/store/uiStore";
import { useTranslations } from "next-intl";
// 🔥 IMPORTAMOS TU SUPER PARSER
import { renderMarkdown } from "@/lib/markdownParser";
interface PartDetailsModalProps {
part: any;
isOpen: boolean;
onClose: () => void;
}
export default function PartDetailsModal({ part, isOpen, onClose }: PartDetailsModalProps) {
const [currentMediaIdx, setCurrentMediaIdx] = useState(0);
const addToCart = useUIStore(state => state.addToCart);
const t = useTranslations("SpareParts");
if (!isOpen || !part) return null;
// Parseamos la galería y especificaciones de forma segura
let media: string[] = [];
let specs: {label: string, value: string}[] = [];
try { media = JSON.parse(part.mediaJson || "[]"); } catch {}
try { specs = JSON.parse(part.specsJson || "[]"); } catch {}
const handleAddToCart = () => {
addToCart({
id: part.id,
sku: part.sku,
title: part.title,
price: part.price,
showPrice: part.showPrice,
mediaUrl: media.length > 0 ? media[0] : undefined,
});
onClose();
};
const nextMedia = () => setCurrentMediaIdx((prev) => (prev + 1) % media.length);
const prevMedia = () => setCurrentMediaIdx((prev) => (prev - 1 + media.length) % media.length);
return (
<AnimatePresence>
{isOpen && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 md:p-6 lg:p-12">
{/* Backdrop con Blur */}
<motion.div
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
onClick={onClose}
className="absolute inset-0 bg-black/60 dark:bg-black/80 backdrop-blur-md"
/>
{/* Contenedor Principal del Modal */}
<motion.div
initial={{ opacity: 0, y: 30, 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-5xl bg-white dark:bg-[#111] rounded-[2rem] shadow-2xl overflow-hidden flex flex-col md:flex-row max-h-[90vh]"
>
{/* Botón Cerrar (Flotante) */}
<button onClick={onClose} className="absolute top-4 right-4 z-50 p-2 bg-black/10 dark:bg-white/10 hover:bg-black/20 dark:hover:bg-white/20 text-[#1D1D1F] dark:text-white rounded-full transition-colors">
<X size={20} />
</button>
{/* SECCIÓN IZQUIERDA: Visor Multimedia (50% ancho en desktop) */}
<div className="w-full md:w-1/2 bg-[#F5F5F7] dark:bg-[#0A0A0C] relative flex flex-col border-r border-black/5 dark:border-white/5">
<div className="flex-1 relative flex items-center justify-center min-h-[300px] md:min-h-full p-8 group">
{media.length > 0 ? (
<>
{media[currentMediaIdx].endsWith('.mp4') || media[currentMediaIdx].endsWith('.mov') ? (
<video
src={`/parts/${part.sku.toLowerCase()}/${media[currentMediaIdx]}`}
controls
className="w-full h-full object-contain"
/>
) : (
<img
src={`/parts/${part.sku.toLowerCase()}/${media[currentMediaIdx]}`}
alt={`${part.title} preview`}
className="w-full h-full object-contain drop-shadow-xl"
/>
)}
{/* Controles de Galería (si hay más de 1 archivo) */}
{media.length > 1 && (
<div className="absolute inset-x-4 flex justify-between opacity-0 group-hover:opacity-100 transition-opacity">
<button onClick={prevMedia} className="p-2 bg-white/80 dark:bg-black/60 backdrop-blur rounded-full text-[#1D1D1F] dark:text-white hover:scale-110 transition-transform"><ChevronLeft size={24} /></button>
<button onClick={nextMedia} className="p-2 bg-white/80 dark:bg-black/60 backdrop-blur rounded-full text-[#1D1D1F] dark:text-white hover:scale-110 transition-transform"><ChevronRight size={24} /></button>
</div>
)}
</>
) : (
<div className="flex flex-col items-center text-[#86868B]/40">
<Wrench size={64} className="mb-4" />
<p className="text-sm font-medium uppercase tracking-widest">No Media Available</p>
</div>
)}
</div>
{/* Miniaturas de Galería */}
{media.length > 1 && (
<div className="h-20 bg-white/50 dark:bg-black/40 border-t border-black/5 dark:border-white/5 flex items-center gap-2 px-4 overflow-x-auto [scrollbar-width:none] shrink-0">
{media.map((file, idx) => {
const isVideo = file.endsWith('.mp4') || file.endsWith('.mov');
return (
<button
key={idx}
onClick={() => setCurrentMediaIdx(idx)}
className={`h-14 w-14 shrink-0 rounded-xl overflow-hidden border-2 transition-all relative ${idx === currentMediaIdx ? 'border-[#0066CC] dark:border-amber-500' : 'border-transparent opacity-50 hover:opacity-100'}`}
>
{isVideo ? (
<div className="w-full h-full bg-black flex items-center justify-center text-white"><Play size={16} /></div>
) : (
<img src={`/parts/${part.sku.toLowerCase()}/${file}`} alt="" className="w-full h-full object-cover bg-black/5 dark:bg-white/5" />
)}
</button>
)
})}
</div>
)}
</div>
{/* SECCIÓN DERECHA: Información y Especificaciones */}
<div className="w-full md:w-1/2 flex flex-col h-[50vh] md:h-auto">
{/* Cabecera del Producto */}
<div className="p-6 md:p-8 border-b border-black/5 dark:border-white/5 shrink-0 bg-white dark:bg-[#111]">
<div className="flex items-center gap-2 mb-3">
<span className="px-2.5 py-1 bg-black/5 dark:bg-white/10 text-[10px] font-mono font-bold tracking-widest text-[#1D1D1F] dark:text-white rounded border border-black/10 dark:border-white/10">
{part.sku}
</span>
</div>
<h2 className="text-2xl md:text-3xl font-light text-[#1D1D1F] dark:text-white mb-4 leading-tight">
{part.title}
</h2>
<div className="flex items-end justify-between">
<div>
<p className="text-[10px] uppercase tracking-widest text-[#86868B] font-semibold mb-1">Unit Price</p>
{part.showPrice && part.price ? (
<p className="text-3xl font-mono font-medium text-[#1D1D1F] dark:text-white">{part.price.toFixed(2)}</p>
) : (
<p className="text-lg uppercase tracking-widest text-[#86868B] font-semibold">{t("quoteBased")}</p>
)}
</div>
</div>
</div>
{/* Cuerpo Desplazable (Markdown + Specs) */}
<div className="flex-1 overflow-y-auto p-6 md:p-8 space-y-8 [scrollbar-width:none] bg-[#F5F5F7]/30 dark:bg-transparent">
{/* Descripción (Usando el parser personalizado) */}
{part.description && part.description !== "Draft description..." && (
<div>
<h3 className="text-xs uppercase tracking-widest text-[#86868B] font-semibold mb-4 flex items-center gap-2">
<Info size={14} /> Product Overview
</h3>
<div className="max-w-none">
{/* 🔥 APLICAMOS TU PARSER AQUÍ 🔥 */}
{renderMarkdown(part.description)}
</div>
</div>
)}
{/* Especificaciones Técnicas */}
{specs.length > 0 && (
<div>
<h3 className="text-xs uppercase tracking-widest text-[#86868B] font-semibold mb-4 flex items-center gap-2">
<Tag size={14} /> Technical Specifications
</h3>
<div className="bg-white dark:bg-black/40 rounded-2xl overflow-hidden border border-black/5 dark:border-white/5 shadow-sm">
{specs.map((spec, idx) => (
<div key={idx} className="flex justify-between p-3.5 border-b border-black/5 dark:border-white/5 last:border-0 hover:bg-black/5 dark:hover:bg-white/5 transition-colors">
<span className="text-xs uppercase tracking-wider font-semibold text-[#86868B]">{spec.label}</span>
<span className="text-sm font-medium text-[#1D1D1F] dark:text-white text-right">{spec.value}</span>
</div>
))}
</div>
</div>
)}
</div>
{/* Botonera Fija (Footer) */}
<div className="p-6 md:p-8 border-t border-black/5 dark:border-white/5 shrink-0 bg-white dark:bg-[#111]">
<button
onClick={handleAddToCart}
className="w-full bg-[#1D1D1F] dark:bg-amber-500 text-white dark:text-black py-4 rounded-xl font-semibold flex items-center justify-center gap-2 transition-transform active:scale-[0.98] hover:shadow-lg dark:hover:shadow-[0_0_20px_rgba(245,158,11,0.3)]"
>
<ShoppingBag size={18} /> Add to Operations Cart
</button>
</div>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
);
}
+91
View File
@@ -0,0 +1,91 @@
export const dynamic = "force-dynamic";
import { prisma } from "@/lib/prisma";
import { getLocalizedData } from "@/lib/i18nHelper";
import { getTranslations } from "next-intl/server";
import { Suspense } from "react";
import ComponentGrid from "./_components/ComponentGrid";
import { Metadata } from "next";
import { getClientSession } from "@/app/actions/clientAuth";
export const revalidate = 0;
export const metadata: Metadata = {
title: "Component Matrix | FLUX",
robots: "noindex, nofollow",
};
export default async function PartsCatalogPage({
params, searchParams
}: { params: Promise<{ locale: string }>; searchParams: Promise<{ q?: string; page?: string }>; }) {
const resolvedParams = await params;
const resolvedSearchParams = await searchParams;
const locale = resolvedParams.locale;
const query = resolvedSearchParams.q || "";
const currentPage = parseInt(resolvedSearchParams.page || "1", 10);
const t = await getTranslations({ locale, namespace: "SpareParts" });
const session = await getClientSession();
const isAuthenticated = !!session;
const rawHero = await prisma.pageContent.findUnique({ where: { slug: "parts-catalog" } });
const heroData = rawHero ? getLocalizedData(rawHero, locale) : null;
let totalItems = 0;
let translatedParts: any[] = [];
let totalPages = 0;
if (isAuthenticated) {
const ITEMS_PER_PAGE = 12;
const whereClause = { isActive: true, ...(query ? { OR: [{ title: { contains: query, mode: "insensitive" as const } }, { sku: { contains: query, mode: "insensitive" as const } }, { description: { contains: query, mode: "insensitive" as const } }] } : {}) };
const [count, rawParts] = await Promise.all([
prisma.sparePart.count({ where: whereClause }),
prisma.sparePart.findMany({ where: whereClause, orderBy: { createdAt: "desc" }, skip: (currentPage - 1) * ITEMS_PER_PAGE, take: ITEMS_PER_PAGE })
]);
totalItems = count;
totalPages = Math.ceil(totalItems / ITEMS_PER_PAGE);
translatedParts = rawParts.map((part: any) => {
const localized = getLocalizedData(part, locale);
let coverImage = null;
try { const media = JSON.parse(localized.mediaJson || "[]"); if (media.length > 0) coverImage = media[0]; } catch (e) {}
return { id: localized.id, sku: localized.sku, title: localized.title, description: localized.description, price: localized.price, showPrice: localized.showPrice, coverImage };
});
}
return (
<main className="min-h-screen bg-[#F5F5F7] dark:bg-[#050505] pt-32 pb-24 relative">
<div className="max-w-7xl mx-auto px-6 md:px-12">
{/* CABECERA (Visible para todos) */}
<div className="mb-12 mt-12 md:mt-0">
<h1 className="text-4xl md:text-6xl font-light text-[#1D1D1F] dark:text-white tracking-tight mb-4">
{heroData?.title || t("title1")} <span className="font-medium italic">{heroData?.subtitle || t("title2")}</span>
</h1>
<p className="text-[#86868B] text-lg max-w-2xl font-light">
{heroData?.description || t("description")}
</p>
</div>
{/* GRID LIMPIO Y SIN ERRORES DE HIDRATACIÓN */}
<Suspense fallback={<div className="h-64 flex items-center justify-center text-[#86868B]">Loading Component Matrix...</div>}>
<ComponentGrid
initialParts={translatedParts}
locale={locale}
query={query}
currentPage={currentPage}
totalPages={totalPages}
totalItems={totalItems}
session={session}
/>
</Suspense>
</div>
</main>
);
}
+109
View File
@@ -0,0 +1,109 @@
"use server";
import { prisma } from "@/lib/prisma";
import bcrypt from "bcryptjs";
import { cookies } from "next/headers";
import { SignJWT, jwtVerify } from "jose";
const getSecretKey = () => new TextEncoder().encode(process.env.SESSION_SECRET || "flux-super-secret-key-2026");
export async function registerClientRequest(formData: FormData) {
const fullName = formData.get("fullName") as string;
const email = formData.get("email") as string;
const companyName = formData.get("companyName") as string;
const password = formData.get("password") as string;
if (!fullName || !email || !companyName || !password) return { error: "All fields are required." };
try {
const existing = await prisma.clientUser.findUnique({ where: { email: email.toLowerCase().trim() } });
if (existing) return { error: "Email is already registered." };
const passwordHash = await bcrypt.hash(password, 12);
const client = await prisma.clientUser.create({
data: { email: email.toLowerCase().trim(), passwordHash, fullName, companyName, isApproved: false }
});
const year = new Date().getFullYear();
const count = await prisma.operationsSignal.count({ where: { ticketId: { startsWith: `ACC-${year}` } } });
const seq = String(count + 1).padStart(4, "0");
await prisma.operationsSignal.create({
data: {
ticketId: `ACC-${year}-${seq}`, type: "ACCESS_REQUEST", status: "PENDING",
clientName: fullName, clientEmail: email, clientCompany: companyName, clientId: client.id,
message: `New B2B portal access request from ${companyName}. Please verify their credentials before approving.`,
}
});
return { success: true };
} catch (error) { return { error: "An error occurred during registration." }; }
}
export async function loginClient(formData: FormData) {
const email = formData.get("email") as string;
const password = formData.get("password") as string;
if (!email || !password) return { error: "Email and password are required." };
try {
const user = await prisma.clientUser.findUnique({ where: { email: email.toLowerCase().trim() } });
if (!user) return { error: "Invalid credentials." };
if (!user.isApproved) return { error: "Your account is still pending engineering approval." };
const isValid = await bcrypt.compare(password, user.passwordHash);
if (!isValid) return { error: "Invalid credentials." };
await prisma.clientUser.update({ where: { id: user.id }, data: { lastLoginAt: new Date() } });
const token = await new SignJWT({ userId: user.id, email: user.email, name: user.fullName, company: user.companyName })
.setProtectedHeader({ alg: 'HS256' })
.setExpirationTime('7d')
.sign(getSecretKey());
const cookieStore = await cookies();
cookieStore.set("flux_b2b_session", token, {
httpOnly: true, secure: process.env.NODE_ENV === "production",
sameSite: "lax", maxAge: 60 * 60 * 24 * 7, path: "/",
});
return { success: true };
} catch (error) { return { error: "Login failed." }; }
}
export async function logoutClient() {
const cookieStore = await cookies();
cookieStore.delete("flux_b2b_session");
return { success: true };
}
export async function getClientSession() {
const cookieStore = await cookies();
const token = cookieStore.get("flux_b2b_session")?.value;
if (!token) return null;
try {
const { payload } = await jwtVerify(token, getSecretKey());
return payload as { userId: string; email: string; name: string; company: string };
} catch (error) { return null; }
}
export async function updateClientPassword(formData: FormData) {
const session = await getClientSession();
if (!session) return { error: "Unauthorized." };
const currentPassword = formData.get("currentPassword") as string;
const newPassword = formData.get("newPassword") as string;
try {
const user = await prisma.clientUser.findUnique({ where: { id: session.userId } });
if (!user) return { error: "User not found." };
const isValid = await bcrypt.compare(currentPassword, user.passwordHash);
if (!isValid) return { error: "Current password is incorrect." };
const newHash = await bcrypt.hash(newPassword, 12);
await prisma.clientUser.update({ where: { id: user.id }, data: { passwordHash: newHash } });
return { success: true };
} catch (error) { return { error: "Failed to update password." }; }
}
+189
View File
@@ -0,0 +1,189 @@
// /src/app/actions/operations.ts
"use server";
import { prisma } from "@/lib/prisma";
import { sendEmail } from "@/lib/mailer";
// ── Helper: Generate sequential ticket ID ──
async function generateTicketId(type: string): Promise<string> {
// Prefix based on type
const prefix = type === "CONSULTATION" ? "CON" : type === "DIAGNOSTIC" ? "DIA" : "REQ";
const year = new Date().getFullYear();
// Count existing tickets of this type this year to make sequential
const count = await prisma.operationsSignal.count({
where: {
ticketId: { startsWith: `${prefix}-${year}` },
},
});
const seq = String(count + 1).padStart(4, "0");
return `${prefix}-${year}-${seq}`;
}
// ── Helper: Resolve email targets from NotificationRoute ──
async function getTargetEmails(type: string): Promise<string[]> {
const route = await prisma.notificationRoute.findUnique({
where: { routeType: type },
});
if (route && route.isActive && route.emails) {
return route.emails.split(",").map((e: string) => e.trim()).filter(Boolean);
}
// Fallbacks
const fallbacks: Record<string, string> = {
ORDER: "sales@fluxsrl.com",
DIAGNOSTIC: "support@fluxsrl.com",
CONSULTATION: "engineering@fluxsrl.com",
};
return [fallbacks[type] || "info@fluxsrl.com"];
}
// ═══════════════════════════════════════════════════════════════
// MAIN: Submit an operations signal (ORDER, DIAGNOSTIC, CONSULTATION)
// Called from CartDrawer.tsx
// ═══════════════════════════════════════════════════════════════
export async function submitOperationsSignal(payload: {
type: string;
clientName: string;
clientEmail: string;
clientCompany: string;
clientPhone?: string;
message?: string;
cartPayload: string;
attachedFiles: string;
}) {
try {
const ticketId = await generateTicketId(payload.type);
// AI analysis placeholder
let aiAnalysis = null;
if (payload.message && payload.message.length > 20) {
aiAnalysis = `[AI SUMMARY]\nClient requires assistance. Type: ${payload.type}. Priority: To be determined.\n\n[CLIENT MESSAGE]\n${payload.message}`;
}
// Save to DB
const signal = await prisma.operationsSignal.create({
data: {
ticketId,
type: payload.type,
clientName: payload.clientName,
clientEmail: payload.clientEmail,
clientCompany: payload.clientCompany,
clientPhone: payload.clientPhone,
message: payload.message,
cartPayload: payload.cartPayload,
attachedFiles: payload.attachedFiles,
aiAnalysis,
status: "PENDING",
},
});
// Send email notification
const targetEmails = await getTargetEmails(payload.type);
const appUrl = process.env.NEXT_PUBLIC_APP_URL || "https://flux.com";
const emailResult = await sendEmail({
to: targetEmails,
subject: `[${payload.type}] New Signal from ${payload.clientCompany}${ticketId}`,
html: generateRichEmailHtml(payload, ticketId, aiAnalysis, appUrl),
replyTo: payload.clientEmail,
});
// Track email delivery in DB
await prisma.operationsSignal.update({
where: { id: signal.id },
data: {
emailSentTo: emailResult.sentTo.join(", "),
emailSentAt: emailResult.sentAt,
emailError: emailResult.error,
},
});
return { success: true, ticketId, emailSent: emailResult.success };
} catch (error) {
console.error("Error submitting signal:", error);
return { error: "Failed to submit request. Please try again." };
}
}
// ── Rich Email HTML Generator ──
// ── Rich Email HTML Generator ──
function generateRichEmailHtml(payload: any, ticketId: string, aiAnalysis: string | null, appUrl: string) {
let cartItems = [];
let files = [];
try { cartItems = JSON.parse(payload.cartPayload || "[]"); } catch {}
try { files = JSON.parse(payload.attachedFiles || "[]"); } catch {}
const cartRows = cartItems.map((item: any) => `
<tr>
<td style="padding: 16px 12px; border-bottom: 1px solid #E5E5EA;">
<strong style="color: #1D1D1F; font-size: 14px;">${item.title}</strong><br/>
<span style="color: #86868B; font-size: 11px; font-family: monospace;">SKU: ${item.sku}</span>
</td>
<td style="padding: 16px 12px; border-bottom: 1px solid #E5E5EA; text-align: center; font-family: monospace; font-weight: 600; color: #1D1D1F;">${item.quantity}</td>
</tr>
`).join('');
const fileLinks = files.map((fileUrl: string) => {
const isVideo = fileUrl.endsWith('.mp4') || fileUrl.endsWith('.mov');
return `<a href="${appUrl}${fileUrl}" style="display: inline-block; padding: 10px 16px; background: #0066CC; color: white; text-decoration: none; border-radius: 8px; margin: 4px 8px 4px 0; font-size: 13px; font-weight: 600; text-align: center;">View ${isVideo ? 'Video' : 'Image'}</a>`;
}).join('');
return `
<div style="background-color: #F5F5F7; padding: 40px 20px; width: 100%; box-sizing: border-box; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;">
<div style="max-width: 600px; margin: 0 auto; background-color: #ffffff; border-radius: 16px; overflow: hidden; box-shadow: 0 8px 32px rgba(0,0,0,0.06);">
<div style="padding: 40px 32px 32px 32px; text-align: center; border-bottom: 1px solid #E5E5EA;">
<img src="${appUrl}/logoEmail.png" alt="FLUX SRL" style="height: 50px; width: auto; object-fit: contain; margin-bottom: 24px;" />
<p style="color: #0066CC; font-size: 11px; letter-spacing: 2px; text-transform: uppercase; margin: 0; font-weight: 700;">Operations Command</p>
<h1 style="margin: 12px 0 16px 0; font-size: 26px; font-weight: 300; color: #1D1D1F; letter-spacing: -0.5px;">Incoming ${payload.type} Signal</h1>
<span style="display: inline-block; padding: 6px 16px; background-color: #F5F5F7; border-radius: 20px; font-family: monospace; color: #1D1D1F; font-size: 13px; font-weight: 600; border: 1px solid #E5E5EA;">Ticket: ${ticketId}</span>
</div>
<div style="padding: 32px; color: #1D1D1F;">
<div style="background-color: #F5F5F7; padding: 24px; border-radius: 12px; margin-bottom: 32px; border: 1px solid #E5E5EA;">
<p style="margin: 0 0 8px 0; font-size: 15px;"><strong>${payload.clientName}</strong> · ${payload.clientCompany}</p>
<p style="margin: 0 0 8px 0; font-size: 14px; color: #86868B;">Email: <a href="mailto:${payload.clientEmail}" style="color: #0066CC; text-decoration: none;">${payload.clientEmail}</a></p>
<p style="margin: 0; font-size: 14px; color: #86868B;">Phone: ${payload.clientPhone || 'N/A'}</p>
</div>
${payload.message ? `
<div style="margin-bottom: 32px;">
<h3 style="font-size: 12px; text-transform: uppercase; color: #86868B; letter-spacing: 1px; margin-bottom: 12px;">Client Notes</h3>
<div style="padding: 20px; border-left: 4px solid #1D1D1F; background: #FAFAFA; border-radius: 0 8px 8px 0; font-size: 14px; line-height: 1.6;">${payload.message}</div>
</div>
` : ''}
${cartItems.length > 0 ? `
<div style="margin-bottom: 32px;">
<h3 style="font-size: 12px; text-transform: uppercase; color: #86868B; letter-spacing: 1px; margin-bottom: 12px;">Requested Components</h3>
<table style="width: 100%; border-collapse: collapse; background: #FAFAFA; border-radius: 8px; overflow: hidden; border: 1px solid #E5E5EA;">
<thead>
<tr style="background: #F5F5F7;">
<th style="padding: 12px; border-bottom: 1px solid #E5E5EA; text-align: left; font-size: 12px; color: #86868B; text-transform: uppercase;">Component</th>
<th style="padding: 12px; border-bottom: 1px solid #E5E5EA; text-align: center; font-size: 12px; color: #86868B; text-transform: uppercase;">QTY</th>
</tr>
</thead>
<tbody>${cartRows}</tbody>
</table>
</div>
` : ''}
${files.length > 0 ? `
<div style="margin-bottom: 32px;">
<h3 style="font-size: 12px; text-transform: uppercase; color: #86868B; letter-spacing: 1px; margin-bottom: 12px;">Diagnostic Media</h3>
<div style="background: #FAFAFA; padding: 20px; border-radius: 12px; border: 1px solid #E5E5EA;">${fileLinks}</div>
</div>
` : ''}
</div>
<div style="background-color: #FAFAFA; border-top: 1px solid #E5E5EA; padding: 24px; font-size: 12px; color: #86868B; text-align: center;">
<p style="margin: 0;">Automated by <strong>FLUX Operations Command</strong></p>
<p style="margin: 8px 0 0 0;">Reply to this email to reach the client directly.</p>
</div>
</div>
</div>
`;
}
+264
View File
@@ -0,0 +1,264 @@
// ─────────────────────────────────────────────────────────────────────────────
// 🔥 ASSET MANAGER API — File Browser & Upload for Applications CMS
// ─────────────────────────────────────────────────────────────────────────────
// Place this file at: /src/app/api/assets/route.ts (or /app/api/assets/route.ts)
//
// Endpoints:
// GET /api/assets?slug=textile-drying&path=images
// → Lists all files and folders inside /public/applications/{slug}/{path}
//
// POST /api/assets (multipart FormData with: slug, path, file)
// → Uploads a file to /public/applications/{slug}/{path}/
//
// PUT /api/assets (JSON body: { slug, folderName, parentPath })
// → Creates a new folder at /public/applications/{slug}/{parentPath}/{folderName}
//
// DELETE /api/assets (JSON body: { slug, filePath })
// → Deletes a file at /public/applications/{slug}/{filePath}
//
// Security:
// - All paths are sanitized and clamped to /public/applications/{slug}/
// - No traversal (../) allowed
// - Only known media extensions are served
// ─────────────────────────────────────────────────────────────────────────────
// /src/app/api/assets/route.ts — ASSET MANAGER API v3
// Supports scope=applications (/public/applications/{slug}/)
// scope=cases (/public/cases/{slug}/)
// scope=news (/public/news/{slug}/)
import { NextRequest, NextResponse } from "next/server";
import fs from "fs";
import path from "path";
const SCOPE_ROOTS: Record<string, string> = {
applications: path.join(process.cwd(), "public", "applications"),
cases: path.join(process.cwd(), "public", "cases"),
news: path.join(process.cwd(), "public", "news"),
// 🔥 NUEVO: Scope para el Component Matrix
parts: path.join(process.cwd(), "public", "parts"),
};
const MEDIA_TYPES: Record<string, string[]> = {
image: [".jpg", ".jpeg", ".png", ".webp", ".gif", ".svg", ".avif"],
video: [".mp4", ".webm", ".mov"],
model: [".glb", ".gltf", ".usdz"],
document: [".pdf"],
};
const ALL_EXTENSIONS = Object.values(MEDIA_TYPES).flat();
function getFileType(filename: string): string {
const ext = path.extname(filename).toLowerCase();
for (const [type, exts] of Object.entries(MEDIA_TYPES)) {
if (exts.includes(ext)) return type;
}
return "unknown";
}
function getFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function sanitizePath(input: string): string {
return input
.replace(/\.\./g, "")
.replace(/[<>:"|?*]/g, "")
.replace(/\/+/g, "/")
.replace(/^\/+|\/+$/g, "");
}
function buildSafePath(scope: string, slug: string, subPath?: string): string | null {
const root = SCOPE_ROOTS[scope];
if (!root || !slug) return null;
const appDir = path.join(root, slug);
if (!subPath || subPath === "" || subPath === "/") return appDir;
const cleaned = sanitizePath(subPath);
const fullPath = path.join(appDir, cleaned);
if (!path.resolve(fullPath).startsWith(path.resolve(appDir))) return null;
return fullPath;
}
function buildBreadcrumbs(subPath: string) {
const parts = subPath ? subPath.split("/").filter(Boolean) : [];
const crumbs = [{ name: "Root", path: "" }];
let acc = "";
for (const p of parts) {
acc += (acc ? "/" : "") + p;
crumbs.push({ name: p, path: acc });
}
return crumbs;
}
// GET — List files and folders
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const scope = searchParams.get("scope") || "applications";
const slug = searchParams.get("slug");
const subPath = searchParams.get("path") || "";
if (!slug) return NextResponse.json({ error: "Missing slug" }, { status: 400 });
if (!SCOPE_ROOTS[scope]) return NextResponse.json({ error: `Invalid scope "${scope}"` }, { status: 400 });
const dirPath = buildSafePath(scope, slug, subPath);
if (!dirPath) return NextResponse.json({ error: "Invalid path" }, { status: 400 });
if (!fs.existsSync(dirPath)) {
return NextResponse.json({
success: true, scope, slug,
currentPath: subPath || "/",
items: [],
breadcrumbs: buildBreadcrumbs(subPath),
});
}
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
const items = entries
.filter(e => !e.name.startsWith(".") && e.name !== "Thumbs.db")
.map(entry => {
const entryPath = path.join(dirPath, entry.name);
const rel = subPath ? `${subPath}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
let childCount = 0;
try { childCount = fs.readdirSync(entryPath).filter(f => !f.startsWith(".")).length; } catch {}
return { name: entry.name, type: "folder" as const, path: rel, childCount };
}
const stats = fs.statSync(entryPath);
return {
name: entry.name,
type: "file" as const,
mediaType: getFileType(entry.name),
extension: path.extname(entry.name).toLowerCase(),
path: rel,
publicUrl: `/${scope}/${slug}/${rel}`,
size: getFileSize(stats.size),
sizeBytes: stats.size,
modifiedAt: stats.mtime.toISOString(),
};
})
.sort((a, b) => {
if (a.type === "folder" && b.type !== "folder") return -1;
if (a.type !== "folder" && b.type === "folder") return 1;
return a.name.localeCompare(b.name);
});
return NextResponse.json({
success: true, scope, slug,
currentPath: subPath || "/",
items,
breadcrumbs: buildBreadcrumbs(subPath),
});
} catch (error) {
console.error("Asset GET error:", error);
return NextResponse.json({ error: "Failed to list directory" }, { status: 500 });
}
}
// POST — Upload a file
export async function POST(request: NextRequest) {
try {
const formData = await request.formData();
const scope = (formData.get("scope") as string) || "applications";
const slug = formData.get("slug") as string;
const subPath = formData.get("path") as string || "";
const file = formData.get("file") as File;
if (!slug || !file) return NextResponse.json({ error: "Missing slug or file" }, { status: 400 });
if (!SCOPE_ROOTS[scope]) return NextResponse.json({ error: "Invalid scope" }, { status: 400 });
const ext = path.extname(file.name).toLowerCase();
if (!ALL_EXTENSIONS.includes(ext)) {
return NextResponse.json({ error: `Type "${ext}" not allowed. Accepted: ${ALL_EXTENSIONS.join(", ")}` }, { status: 400 });
}
if (file.size > 50 * 1024 * 1024) {
return NextResponse.json({ error: "File exceeds 50MB limit" }, { status: 400 });
}
const dirPath = buildSafePath(scope, slug, subPath);
if (!dirPath) return NextResponse.json({ error: "Invalid path" }, { status: 400 });
fs.mkdirSync(dirPath, { recursive: true });
const safeName = file.name.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9._-]/g, "");
const filePath = path.join(dirPath, safeName);
const existed = fs.existsSync(filePath);
fs.writeFileSync(filePath, Buffer.from(await file.arrayBuffer()));
const rel = subPath ? `${subPath}/${safeName}` : safeName;
return NextResponse.json({
success: true,
file: {
name: safeName,
publicUrl: `/${scope}/${slug}/${rel}`,
path: rel,
mediaType: getFileType(safeName),
size: getFileSize(file.size),
overwritten: existed,
}
});
} catch (error) {
console.error("Asset POST error:", error);
return NextResponse.json({ error: "Upload failed" }, { status: 500 });
}
}
// PUT — Create a new folder
export async function PUT(request: NextRequest) {
try {
const body = await request.json();
const { scope = "applications", slug, folderName, parentPath = "" } = body;
if (!slug || !folderName) return NextResponse.json({ error: "Missing slug or folderName" }, { status: 400 });
if (!SCOPE_ROOTS[scope]) return NextResponse.json({ error: "Invalid scope" }, { status: 400 });
const safe = folderName.toLowerCase().trim().replace(/\s+/g, "-").replace(/[^a-z0-9_-]/g, "");
if (!safe) return NextResponse.json({ error: "Invalid folder name" }, { status: 400 });
const targetPath = buildSafePath(scope, slug, parentPath ? `${parentPath}/${safe}` : safe);
if (!targetPath) return NextResponse.json({ error: "Invalid path" }, { status: 400 });
if (fs.existsSync(targetPath)) return NextResponse.json({ error: "Folder already exists" }, { status: 409 });
fs.mkdirSync(targetPath, { recursive: true });
return NextResponse.json({
success: true,
folder: { name: safe, path: parentPath ? `${parentPath}/${safe}` : safe }
});
} catch (error) {
console.error("Asset PUT error:", error);
return NextResponse.json({ error: "Failed to create folder" }, { status: 500 });
}
}
// DELETE — Remove a file
export async function DELETE(request: NextRequest) {
try {
const body = await request.json();
const { scope = "applications", slug, filePath } = body;
if (!slug || !filePath) return NextResponse.json({ error: "Missing params" }, { status: 400 });
if (!SCOPE_ROOTS[scope]) return NextResponse.json({ error: "Invalid scope" }, { status: 400 });
const targetPath = buildSafePath(scope, slug, filePath);
if (!targetPath) return NextResponse.json({ error: "Invalid path" }, { status: 400 });
if (!fs.existsSync(targetPath)) return NextResponse.json({ error: "File not found" }, { status: 404 });
if (fs.statSync(targetPath).isDirectory()) return NextResponse.json({ error: "Cannot delete folders via API" }, { status: 400 });
fs.unlinkSync(targetPath);
return NextResponse.json({ success: true, deleted: filePath });
} catch (error) {
console.error("Asset DELETE error:", error);
return NextResponse.json({ error: "Delete failed" }, { status: 500 });
}
}
+588
View File
@@ -0,0 +1,588 @@
import { openai } from '@ai-sdk/openai';
import { streamText, UIMessage, convertToModelMessages, tool } from 'ai';
import { z } from 'zod';
import { prisma } from '@/lib/prisma';
export const maxDuration = 30;
// ─── PHYSICS CONSTANTS (NOT from DB — these are engineering benchmarks) ──────
// These stay hardcoded because they are physical/scientific constants,
// not business data that changes with CMS updates.
const ENERGY_DATA: Record<string, {
traditionalKwhPerKg: number;
rfKwhPerKg: number;
traditionalMethod: string;
co2FactorKgPerKwh: number;
typicalPaybackMonths: number;
}> = {
textile: { traditionalKwhPerKg: 1.8, rfKwhPerKg: 0.85, traditionalMethod: 'Hot Air Stenter', co2FactorKgPerKwh: 0.4, typicalPaybackMonths: 18 },
food: { traditionalKwhPerKg: 2.2, rfKwhPerKg: 0.95, traditionalMethod: 'Convection Oven', co2FactorKgPerKwh: 0.4, typicalPaybackMonths: 14 },
rubber: { traditionalKwhPerKg: 3.0, rfKwhPerKg: 1.2, traditionalMethod: 'Steam Autoclave', co2FactorKgPerKwh: 0.4, typicalPaybackMonths: 12 },
pharma: { traditionalKwhPerKg: 2.5, rfKwhPerKg: 1.0, traditionalMethod: 'Vacuum Tray Dryer', co2FactorKgPerKwh: 0.4, typicalPaybackMonths: 16 },
wood: { traditionalKwhPerKg: 2.0, rfKwhPerKg: 0.9, traditionalMethod: 'Kiln Drying', co2FactorKgPerKwh: 0.4, typicalPaybackMonths: 20 },
default: { traditionalKwhPerKg: 2.0, rfKwhPerKg: 0.9, traditionalMethod: 'Conventional Heating', co2FactorKgPerKwh: 0.4, typicalPaybackMonths: 18 },
};
const COMPARISON_DATA: Record<string, { rf: number; traditional: number; unit: string }> = {
efficiency: { rf: 95, traditional: 45, unit: '%' },
uniformity: { rf: 98, traditional: 70, unit: '%' },
speed: { rf: 85, traditional: 40, unit: 'score' },
maintenance: { rf: 90, traditional: 55, unit: 'score' },
lifespan: { rf: 20, traditional: 8, unit: 'years' },
footprint: { rf: 30, traditional: 100, unit: 'relative' },
};
// ─── DYNAMIC SYSTEM PROMPT BUILDER ──────────────────────────────
// Injects real-time database context so the AI knows what exists
async function buildSystemPrompt(): Promise<string> {
// Query real data from Prisma
const [activeApps, installationCount, eventCount, partsCount] = await Promise.all([
prisma.application.findMany({
where: { isActive: true },
select: { slug: true, title: true, shortDescription: true, category: true },
orderBy: { title: 'asc' },
}),
prisma.globalNode.count({ where: { nodeType: 'installation', isActive: true } }),
prisma.globalNode.count({ where: { nodeType: 'event', isActive: true } }),
prisma.sparePart.count({ where: { isActive: true } }),
]);
const appList = activeApps.map((a: any) => ` - ${a.title} (slug: "${a.slug}", category: ${a.category})`).join('\n');
return `You are "FluxAI", the intelligent engineering advisor and sales specialist for FLUX Srl — a world leader in solid-state Radio Frequency (RF), Microwave, and Infrared industrial equipment. Founded by Patrizio Grando with 40+ years of legacy. Headquarters: Romano d'Ezzelino, Vicenza, Italy.
PERSONALITY:
- Senior RF engineer who also understands business ROI.
- Concise, authoritative, no filler — every sentence delivers value.
- Apple-level design thinking meets German engineering precision.
- NO emojis. NO exclamation marks. Professional warmth only.
═══════════════════════════════════════════
LIVE DATABASE STATE (auto-updated from CMS):
- ${installationCount} active installations worldwide
- ${eventCount} upcoming/recent events
- ${partsCount} spare parts in catalog
- Applications available:
${appList}
═══════════════════════════════════════════
CORE KNOWLEDGE:
- FLUX solid-state RF operates at 27.12 MHz with 95%+ power transfer efficiency
- Volumetric heating (not surface): energy goes directly into the product mass
- Applications: Drying, Vulcanization, Curing, Baking, Defrosting, Sanitization
- Industries: Textile, Food Processing, Rubber/Latex, Pharma, Wood, Ceramics
- Key advantage vs vacuum tubes: no consumable parts, 20+ year lifespan, digital control
- Key advantage vs microwave (2450 MHz): deeper penetration, more uniform field, better for bulk materials
MULTI-STEP AUTONOMY (CRITICAL):
You are an autonomous agent. You MUST chain tools automatically without waiting for the user.
Example of a perfect autonomous flow:
1. User: "Will RF save me money in textile drying?"
2. You call 'search_installations' to find real textile drying installations.
3. You read the results and see 2 installations with -47% and -53% savings.
4. You call 'energy_savings_calculator' with the user's context.
5. You call 'show_case_study' with the most relevant installation's nodeId.
6. You output your final text referencing real data, the case study card, and gently offer a consultation.
═══════════════════════════════════════════
SALES METHODOLOGY — SPIN FRAMEWORK:
═══════════════════════════════════════════
Before deploying tools, qualify the prospect through natural conversation:
S (Situación): What's their current process? What method? What volume?
P (Problema): What's not working? Energy costs? Quality issues? Speed?
I (Implicación): What does the problem cost them? Rejected batches? Downtime?
N (Necesidad): Confirm the need before recommending.
RULES:
- If the user mentions an industry WITHOUT specifics → ask 1-2 qualifying questions BEFORE firing tools.
Example: "Estoy en textiles" → "What specific process are you evaluating — post-dye drying, finishing, moisture leveling? And what method do you currently use?"
- If the user provides DETAILED context (industry + process + volume OR problem) → proceed directly to tools.
- Never fire more than 2 tools in a single autonomous sequence without including meaningful analysis text.
- EXCEPTION: If the user explicitly asks for something specific ("show me case studies", "calculate savings for 500 kg/h"), deliver it immediately.
IDEAL CONVERSION FLOW:
Qualify → Educate (explainer/comparison) → Quantify (calculator) → Prove (case study) → Recommend (equipment specs) → Convert (consultation)
═══════════════════════════════════════════
TOOL USAGE RULES:
═══════════════════════════════════════════
1. SEARCH INSTALLATIONS: Use 'search_installations' to find real installations from our database. This is a DATA tool — you receive the results and reason about them before responding.
2. SHOW CASE STUDY: Use 'show_case_study' to display a rich case study card for a specific installation. Requires a nodeId (get it from search_installations first) or an application slug for auto-match.
3. SAVINGS/ROI: Use 'energy_savings_calculator' when discussing costs, energy, ROI. If volume is missing, assume 500 kg/h and 16h/day.
4. NAVIGATION: Use 'navigate_to_section' to move the user around the site.
5. COMPARISONS: Use 'process_comparison_table' for RF vs conventional tech.
6. EXPLAIN TECH: Use 'rf_technology_explainer' for physics/mechanism questions.
7. APPLICATION KNOWLEDGE: Use 'get_application_knowledge' to retrieve deep technical theory, advantages, and datasheets from our knowledge base.
8. EQUIPMENT SPECS: Use 'show_equipment_specs' to display real machine specifications from an actual installation.
9. CONSULTATION (PRIMARY GOAL): Use 'schedule_consultation' when the user shows buying intent.
PROACTIVE NEXT STEPS:
After showing results, gently suggest the logical next action:
savings → case study ("We have real installations proving these numbers...")
case study → equipment specs ("Want to see the technical specs of the system used?")
equipment → consultation ("Shall I arrange a conversation with our engineering team?")
LANGUAGE: Respond in the exact same language the user writes in.`;
}
// ─── HELPER: Parse JSON safely ──────────────────────────────────
function safeParseJson<T>(json: string | null | undefined, fallback: T): T {
if (!json) return fallback;
try { return JSON.parse(json); } catch { return fallback; }
}
// ─── HELPER: Extract industry from application slug ─────────────
function industryFromSlug(slug: string): string {
if (slug.includes('textile')) return 'textile';
if (slug.includes('food') || slug.includes('defrost') || slug.includes('bak') || slug.includes('pasteuriz')) return 'food';
if (slug.includes('rubber') || slug.includes('vulcaniz') || slug.includes('foam')) return 'rubber';
if (slug.includes('pharma') || slug.includes('cannabis') || slug.includes('lab')) return 'pharma';
if (slug.includes('wood')) return 'wood';
return 'other';
}
// ─── ROUTE HANDLER ──────────────────────────────────────────────
export async function POST(req: Request) {
const { messages, context }: {
messages: UIMessage[];
context?: { section?: string; activeTab?: string };
} = await req.json();
const contextNote = context?.section
? `\n\n[CONTEXT: User is currently viewing the "${context.section}" section${context.activeTab ? `, tab: "${context.activeTab}"` : ''}.`
: '';
// Build system prompt with live database context
const systemPrompt = await buildSystemPrompt();
const coreMessages = await convertToModelMessages(messages);
const result = streamText({
model: openai('gpt-4o'),
system: systemPrompt + contextNote,
messages: coreMessages,
// maxSteps has been temporarily removed to ensure compatibility with the installed AI SDK version
tools: {
// ══════════════════════════════════════════════════════════════
// DATA TOOLS (have execute, return data for AI to reason about)
// ══════════════════════════════════════════════════════════════
// ── TOOL 1: Search Installations (DATA — queries Prisma) ──
search_installations: tool({
description: `Search the FLUX global installation database for real case studies and projects. Returns summaries for you to analyze and reference in your response. Use when you need to find relevant installations to back up your claims, or when the user asks about references, case studies, or proven results. You can then call 'show_case_study' with a specific nodeId to display the full card to the user.`,
inputSchema: z.object({
application: z.string().optional()
.describe('Application slug to filter by, e.g. "textile-drying", "food-defrosting". Leave empty for all.'),
keyword: z.string().optional()
.describe('Keyword to search in title or location, e.g. "Japan", "latex"'),
limit: z.number().default(5)
.describe('Max results to return (default 5)'),
}),
execute: async ({ application, keyword, limit }) => {
const where: any = {
nodeType: 'installation',
isActive: true,
};
if (application) where.application = application;
let nodes = await prisma.globalNode.findMany({
where,
select: {
id: true,
title: true,
location: true,
application: true,
stats: true,
energySavings: true,
projectOverview: true,
specificDatasheetJson: true,
},
orderBy: { createdAt: 'desc' },
take: limit,
});
// Optional keyword filter (Prisma SQLite doesn't support full-text, so manual)
if (keyword) {
const kw = keyword.toLowerCase();
nodes = nodes.filter((n: any) =>
n.title.toLowerCase().includes(kw) ||
n.location.toLowerCase().includes(kw) ||
(n.projectOverview?.toLowerCase().includes(kw) ?? false)
);
}
if (nodes.length === 0) {
return {
found: 0,
message: `No installations found${application ? ` for application "${application}"` : ''}${keyword ? ` matching "${keyword}"` : ''}. Try broadening your search or check available applications.`,
installations: [],
};
}
return {
found: nodes.length,
installations: nodes.map((n: any) => ({
nodeId: n.id,
title: n.title,
location: n.location,
application: n.application,
stats: n.stats,
energySavings: n.energySavings || 'Data pending',
hasProjectOverview: !!n.projectOverview,
hasDatasheet: n.specificDatasheetJson !== '[]' && !!n.specificDatasheetJson,
// Include a brief excerpt for AI reasoning (first 200 chars)
overviewExcerpt: n.projectOverview?.slice(0, 200) || null,
})),
};
},
}),
// ── TOOL 2: Get Application Knowledge (DATA — queries Prisma) ──
get_application_knowledge: tool({
description: `Retrieve deep technical knowledge about a FLUX application from our knowledge base. Returns the scientific theory (heroDescription), technical sections, competitive advantages, and general datasheet. Use when you need to explain WHY RF is suitable for a specific application, or when the user asks technical questions about a process.`,
inputSchema: z.object({
slug: z.string()
.describe('Application slug, e.g. "textile-drying", "food-defrosting", "rubber-vulcanization"'),
}),
execute: async ({ slug }) => {
const app = await prisma.application.findUnique({
where: { slug },
select: {
title: true,
subtitle: true,
category: true,
shortDescription: true,
heroDescription: true,
sectionsJson: true,
advantagesJson: true,
datasheetJson: true,
dashboardMetricsJson: true,
},
});
if (!app) {
return { found: false, message: `Application "${slug}" not found. Check available applications in your database context.` };
}
return {
found: true,
title: app.title,
subtitle: app.subtitle,
category: app.category,
shortDescription: app.shortDescription,
// Truncate heroDescription for AI context (keep first 1500 chars — enough for reasoning)
theoryExcerpt: app.heroDescription.slice(0, 1500),
sections: safeParseJson(app.sectionsJson, []),
advantages: safeParseJson(app.advantagesJson, []),
datasheet: safeParseJson(app.datasheetJson, []),
metrics: safeParseJson(app.dashboardMetricsJson, []),
};
},
}),
// ══════════════════════════════════════════════════════════════
// UI + DATA TOOLS (have execute, return data for component rendering)
// ══════════════════════════════════════════════════════════════
// ── TOOL 3: Show Case Study (UI — fetches full GlobalNode for card) ──
show_case_study: tool({
description: `Display a rich, interactive case study card for a specific FLUX installation. Shows cover image, metrics, project overview, gallery, and equipment datasheet. Use AFTER calling 'search_installations' to get the nodeId, or provide an application slug to auto-select the best match. The card renders inline in the chat with CTAs to view the full case study modal and locate on the 3D globe.`,
inputSchema: z.object({
nodeId: z.string().optional()
.describe('Specific GlobalNode ID to display. Preferred — get this from search_installations results.'),
application: z.string().optional()
.describe('Fallback: application slug to auto-pick the best installation if nodeId is not available.'),
relevanceNote: z.string()
.describe('1-2 sentence AI explanation of WHY this case is relevant to the current conversation. Reference the user\'s industry, process, or concerns.'),
}),
execute: async ({ nodeId, application, relevanceNote }) => {
let node;
if (nodeId) {
node = await prisma.globalNode.findUnique({ where: { id: nodeId } });
}
if (!node && application) {
node = await prisma.globalNode.findFirst({
where: { application, nodeType: 'installation', isActive: true, projectOverview: { not: null } },
orderBy: { createdAt: 'desc' },
});
}
if (!node) {
return { found: false, message: 'No matching installation found.' };
}
return {
found: true,
id: node.id,
title: node.title,
location: node.location,
application: node.application,
industry: industryFromSlug(node.application),
stats: node.stats,
energySavings: node.energySavings,
projectOverview: node.projectOverview,
mediaFileName: node.mediaFileName,
gallery: safeParseJson(node.galleryJson, []),
datasheet: safeParseJson(node.specificDatasheetJson, []),
videos: safeParseJson(node.videosJson, []),
relevanceNote,
};
},
}),
// ── TOOL 4: Show Equipment Specs (UI — real machine datasheet) ──
show_equipment_specs: tool({
description: `Display a detailed equipment specification card based on a REAL installed FLUX machine. Shows the actual datasheet (specificDatasheetJson) from a proven installation, along with AI-generated sizing guidance. Use when the user asks about equipment, models, specs, sizing, "which machine", or wants to know what FLUX system fits their needs. The card shows real specs from a reference installation — more credible than generic catalog data.`,
inputSchema: z.object({
nodeId: z.string().optional()
.describe('Specific GlobalNode ID of the reference installation. Get from search_installations.'),
application: z.string().optional()
.describe('Fallback: application slug to auto-pick an installation with datasheet data.'),
whyThisModel: z.string()
.describe('2-3 sentence AI explanation of why this equipment configuration fits the user\'s needs.'),
sizingNote: z.string()
.describe('Specific sizing guidance for the user. Reference their production volume if known.'),
alternativeNote: z.string().nullable()
.describe('Note about scaling options or alternatives. Null if not applicable.'),
}),
execute: async ({ nodeId, application, whyThisModel, sizingNote, alternativeNote }) => {
let node;
if (nodeId) {
node = await prisma.globalNode.findUnique({ where: { id: nodeId } });
}
if (!node && application) {
// Find an installation WITH datasheet data
node = await prisma.globalNode.findFirst({
where: {
application,
nodeType: 'installation',
isActive: true,
specificDatasheetJson: { not: '[]' },
},
orderBy: { createdAt: 'desc' },
});
}
if (!node) {
return { found: false, message: 'No installation with equipment datasheet found for this application.' };
}
const datasheet = safeParseJson<{ label: string; value: string }[]>(node.specificDatasheetJson, []);
return {
found: true,
id: node.id,
title: node.title,
location: node.location,
application: node.application,
industry: industryFromSlug(node.application),
stats: node.stats,
mediaFileName: node.mediaFileName,
model3DPath: node.model3DPath,
dimensions: safeParseJson(node.model3DDimsJson, null),
datasheet,
whyThisModel,
sizingNote,
alternativeNote,
};
},
}),
// ── TOOL 5: Energy Savings Calculator (standalone — physics constants) ──
energy_savings_calculator: tool({
description: `Calculate and display an interactive energy savings comparison between FLUX solid-state RF technology and traditional industrial heating/drying methods. Use when the user asks about savings, ROI, energy costs, or efficiency comparisons. If they haven't specified volume, use defaults and state your assumptions.`,
inputSchema: z.object({
industry: z.enum(['textile', 'food', 'rubber', 'pharma', 'wood', 'other']),
process: z.string(),
productionVolumeKgPerHour: z.number().default(500),
operatingHoursPerDay: z.number().default(16),
currentMethod: z.string().optional(),
}),
execute: async ({ industry, process, productionVolumeKgPerHour, operatingHoursPerDay, currentMethod }) => {
const data = ENERGY_DATA[industry] || ENERGY_DATA.default;
const annualKg = productionVolumeKgPerHour * operatingHoursPerDay * 300;
const annualKwhTraditional = annualKg * data.traditionalKwhPerKg;
const annualKwhRF = annualKg * data.rfKwhPerKg;
const annualSavingsKwh = annualKwhTraditional - annualKwhRF;
const savingsPercent = Math.round((annualSavingsKwh / annualKwhTraditional) * 100);
return {
industry, process, productionVolumeKgPerHour, operatingHoursPerDay,
traditionalMethod: currentMethod || data.traditionalMethod,
traditionalKwhPerKg: data.traditionalKwhPerKg,
rfKwhPerKg: data.rfKwhPerKg,
savingsPercent,
annualKwhTraditional: Math.round(annualKwhTraditional),
annualKwhRF: Math.round(annualKwhRF),
annualSavingsKwh: Math.round(annualSavingsKwh),
annualCO2SavedTonnes: Math.round(annualSavingsKwh * data.co2FactorKgPerKwh / 1000),
annualCostSavingsEur: Math.round(annualSavingsKwh * 0.15),
paybackMonths: data.typicalPaybackMonths,
rfEfficiency: 95,
};
},
}),
// ── TOOL 6: Navigate to Section (client-side — NO execute) ──
navigate_to_section: tool({
description: `Maps the user to a specific section of the FLUX website. Use when the user says "show me", "take me to", "where is", or asks about a specific page section. Available sections: "hero", "applications-dashboard", "applications-deep", "global" (globe), "timeline", "heritage", "news", "parts-catalog", "contact".`,
inputSchema: z.object({
section: z.string().describe('Target section ID'),
subAction: z.enum(['none', 'activate-tab', 'highlight-node']).default('none'),
tabId: z.string().optional().describe('Application slug to activate'),
nodeId: z.string().optional().describe('Globe node ID to highlight'),
}),
}),
// ── TOOL 7: Process Comparison Table (standalone — physics data) ──
process_comparison_table: tool({
description: `Display a detailed visual comparison table between FLUX solid-state RF technology and a traditional/competing industrial heating method. Use when the user explicitly asks to compare technologies.`,
inputSchema: z.object({
competitorMethod: z.enum(['Hot Air', 'Steam', 'Microwave (2450 MHz)', 'Infrared', 'Vacuum Tubes', 'Convection Oven', 'Kiln Drying']),
applicationContext: z.string(),
}),
execute: async ({ competitorMethod, applicationContext }) => {
const compContext = `${applicationContext} application`;
const categories = [
{ label: 'Power Transfer Efficiency', rf: COMPARISON_DATA.efficiency.rf, competitor: COMPARISON_DATA.efficiency.traditional, unit: '%', note: 'FLUX solid-state delivers 95%+ of generated power directly into the product.' },
{ label: 'Heating Uniformity', rf: COMPARISON_DATA.uniformity.rf, competitor: COMPARISON_DATA.uniformity.traditional, unit: '%', note: 'Volumetric RF heats the entire mass simultaneously — no hot spots or cold cores.' },
{ label: 'Processing Speed', rf: COMPARISON_DATA.speed.rf, competitor: COMPARISON_DATA.speed.traditional, unit: ' (score)', note: 'RF reduces processing time by 50-80% depending on material and moisture content.' },
{ label: 'Maintenance Cost', rf: COMPARISON_DATA.maintenance.rf, competitor: COMPARISON_DATA.maintenance.traditional, unit: ' (score)', note: 'Solid-state = zero consumable parts. No magnetrons, no vacuum tubes to replace.' },
{ label: 'Equipment Lifespan', rf: COMPARISON_DATA.lifespan.rf, competitor: COMPARISON_DATA.lifespan.traditional, unit: ' years', note: 'FLUX solid-state generators are rated for 20+ years of continuous industrial operation.' },
{ label: 'Carbon Footprint', rf: COMPARISON_DATA.footprint.rf, competitor: COMPARISON_DATA.footprint.traditional, unit: ' (relative)', note: 'Less energy consumed = proportionally lower CO2 emissions.' },
];
return { fluxMethod: 'Solid-State RF (27.12 MHz)', competitorMethod, context: compContext, categories };
},
}),
// ── TOOL 8: RF Technology Explainer (standalone — physics) ──
rf_technology_explainer: tool({
description: `Display an interactive animated visualization explaining how Radio Frequency (RF) heating works at 27.12 MHz. Use for physics/mechanism questions.`,
inputSchema: z.object({
material: z.enum(['textile', 'food', 'rubber', 'pharma', 'wood', 'other']),
comparisonMethod: z.string().default('Hot Air'),
}),
execute: async ({ material, comparisonMethod }) => {
const materialData: Record<string, {
label: string; waterContent: number; dielectric: number;
penetration: string; timeReduction: number; advantages: string[]; physics: string;
}> = {
textile: {
label: 'Textile & Fabrics', waterContent: 60, dielectric: 80, penetration: 'Full web width',
timeReduction: 55,
advantages: [
'Uniform drying across full fabric width — eliminates edge-to-center moisture variation',
'No thermal damage to fibers — RF energy targets water, not the material',
'Replaces 40+ meter stenters with compact 10-12m RF modules',
'Self-regulating: wetter areas absorb more energy automatically',
],
physics: 'At 27.12 MHz, water molecules in the textile rotate 27 million times per second. This molecular friction generates heat precisely where moisture exists, leaving dry fibers unaffected.',
},
food: {
label: 'Food Products', waterContent: 70, dielectric: 75, penetration: 'Full depth',
timeReduction: 60,
advantages: [
'Defrost from -18°C to -2°C in minutes with zero temperature differential',
'No surface cooking or bacterial growth window',
'Uniform pasteurization through the entire product mass',
'Preserves texture, color, and nutritional value',
],
physics: 'RF dielectric heating at 27.12 MHz penetrates frozen food blocks completely. Unlike microwave (2450 MHz), the longer wavelength provides far more uniform energy distribution — no hot spots.',
},
rubber: {
label: 'Rubber & Latex', waterContent: 40, dielectric: 50, penetration: 'Full thickness (up to 25cm)',
timeReduction: 65,
advantages: [
'Simultaneous vulcanization through entire foam thickness',
'Eliminates thermal gradient: no over-cured surface / under-cured core',
'Cycle times reduced from 45+ minutes to 10-15 minutes',
'Uniform crosslink density = consistent product quality',
],
physics: 'Latex foam contains water and polar molecules that respond to the 27.12 MHz field. The electromagnetic energy converts to heat uniformly throughout the block, achieving the vulcanization temperature simultaneously at every point.',
},
pharma: {
label: 'Pharma & Botanicals', waterContent: 55, dielectric: 65, penetration: 'Full depth',
timeReduction: 50,
advantages: [
'Precise temperature control protects active pharmaceutical ingredients',
'Cannabis decontamination without terpene degradation',
'Uniform sanitization of herbs and spices',
'Gentle drying preserves molecular integrity',
],
physics: 'RF dielectric heating at 27.12 MHz provides gentle, volumetric energy delivery that can pasteurize or sanitize without exceeding critical temperatures that would degrade sensitive compounds.',
},
wood: {
label: 'Wood & Composites', waterContent: 30, dielectric: 20, penetration: 'Full depth',
timeReduction: 65,
advantages: [
'No case-hardening — moisture escapes uniformly',
'Eliminates internal stresses and checking/cracking',
'Phytosanitary treatment (ISPM-15) in minutes, not days',
'Adhesive curing in laminated products',
],
physics: 'Wood is a poor thermal conductor. Conventional kiln drying takes days because heat must slowly penetrate inward. RF bypasses this limitation — the electromagnetic field heats all water molecules simultaneously regardless of depth.',
},
other: {
label: 'Industrial Materials', waterContent: 25, dielectric: 40, penetration: 'Full depth',
timeReduction: 50,
advantages: [
'Volumetric heating eliminates processing bottlenecks',
'Digital control for precise, repeatable results',
'No combustion byproducts — clean process',
'Compact footprint vs conventional thermal equipment',
],
physics: 'At 27.12 MHz (ISM band), the RF electromagnetic field induces rapid dipole rotation and ionic conduction within the material, converting electrical energy to thermal energy with 95%+ efficiency directly inside the product.',
},
};
const d = materialData[material] || materialData.other;
return {
material, materialLabel: d.label, comparisonMethod, rfFrequency: '27.12 MHz',
penetrationDepth: d.penetration, heatingMechanism: 'Dielectric loss (dipole rotation + ionic conduction)',
waterContentPercent: d.waterContent, dielectricConstant: d.dielectric,
processingTimeReduction: d.timeReduction, keyAdvantages: d.advantages, physicsNote: d.physics,
};
},
}),
// ── TOOL 9: Schedule Consultation (server-side, pre-fills form) ──
schedule_consultation: tool({
description: `Display an intelligent consultation booking form pre-filled with context from the current conversation. This is the PRIMARY conversion tool — the goal of every FLUX conversation. Use when the user shows buying intent: schedule, contact, quote, proposal, "talk to someone", "I'm interested".`,
inputSchema: z.object({
industry: z.enum(['textile', 'food', 'rubber', 'pharma', 'wood', 'other']),
process: z.string(),
conversationInsights: z.array(z.string())
.describe('3-5 key points from the conversation with numbers, comparisons, technical details.'),
estimatedSavingsPercent: z.number().nullable(),
productionVolume: z.string().nullable(),
suggestedTopics: z.array(z.string())
.describe('2-4 topics the FLUX engineer should prepare for.'),
}),
execute: async ({ industry, process, conversationInsights, estimatedSavingsPercent, productionVolume, suggestedTopics }) => {
const industryLabels: Record<string, string> = {
textile: 'Textile', food: 'Food Processing', rubber: 'Rubber & Latex',
pharma: 'Pharma & Cosmetics', wood: 'Wood Treatment', other: 'Industrial',
};
return {
industry, industryLabel: industryLabels[industry] || industry,
process, conversationInsights, estimatedSavingsPercent, productionVolume, suggestedTopics,
};
},
}),
},
});
return result.toUIMessageStreamResponse();
}
+125
View File
@@ -0,0 +1,125 @@
// /src/app/api/consultation/route.ts
// Public API endpoint for ConsultationScheduler → OperationsSignal
// Uses SMTP mailer (no Resend dependency)
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { sendEmail } from "@/lib/mailer";
// Helper: sequential ticket ID
async function generateConsultationTicketId(): Promise<string> {
const year = new Date().getFullYear();
const count = await prisma.operationsSignal.count({
where: { ticketId: { startsWith: `CON-${year}` } },
});
return `CON-${year}-${String(count + 1).padStart(4, "0")}`;
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { contact, aiContext, meta } = body;
if (!contact?.name || !contact?.email || !contact?.company) {
return NextResponse.json({ error: "Missing required contact fields" }, { status: 400 });
}
const ticketId = await generateConsultationTicketId();
// Build structured AI analysis
const aiParts: string[] = [];
if (aiContext?.industryLabel && aiContext?.process) aiParts.push(`[INDUSTRY] ${aiContext.industryLabel}${aiContext.process}`);
if (aiContext?.estimatedSavingsPercent) aiParts.push(`[ESTIMATED SAVINGS] ~${aiContext.estimatedSavingsPercent}% energy reduction`);
if (aiContext?.productionVolume) aiParts.push(`[PRODUCTION VOLUME] ${aiContext.productionVolume}`);
if (aiContext?.conversationInsights?.length > 0) aiParts.push(`[AI DISCUSSION POINTS]\n${aiContext.conversationInsights.map((i: string) => `${i}`).join("\n")}`);
if (aiContext?.suggestedTopics?.length > 0) aiParts.push(`[SUGGESTED ENGINEERING TOPICS]\n${aiContext.suggestedTopics.map((t: string) => `${t}`).join("\n")}`);
if (contact?.preferredContact) aiParts.push(`[PREFERRED CONTACT] ${contact.preferredContact.toUpperCase()}`);
if (contact?.timeframe) aiParts.push(`[TIMEFRAME] ${contact.timeframe}`);
if (meta?.source) aiParts.push(`[SOURCE] ${meta.source}${meta.url || "N/A"}`);
const aiAnalysis = aiParts.join("\n\n");
const messageParts: string[] = [];
if (contact.message) messageParts.push(contact.message);
if (contact.timeframe) messageParts.push(`Timeframe: ${contact.timeframe}`);
if (contact.preferredContact) messageParts.push(`Preferred contact: ${contact.preferredContact}`);
// Save to DB
const signal = await prisma.operationsSignal.create({
data: {
ticketId,
type: "CONSULTATION",
status: "PENDING",
clientName: contact.name,
clientEmail: contact.email,
clientCompany: contact.company,
clientPhone: contact.phone || null,
message: messageParts.join("\n") || null,
cartPayload: "[]",
attachedFiles: "[]",
aiAnalysis,
},
});
// Resolve email targets
const route = await prisma.notificationRoute.findUnique({ where: { routeType: "CONSULTATION" } });
const targetEmails = route && route.isActive
? route.emails.split(",").map((e: string) => e.trim()).filter(Boolean)
: ["engineering@fluxsrl.com"];
const appUrl = process.env.NEXT_PUBLIC_APP_URL || "https://flux.com";
// Send via SMTP
const emailResult = await sendEmail({
to: targetEmails,
subject: `[CONSULTATION] ${aiContext?.industryLabel || "General"} inquiry from ${contact.company}${ticketId}`,
html: generateConsultationEmail(contact, aiContext, ticketId, appUrl),
replyTo: contact.email,
});
// Track email delivery
await prisma.operationsSignal.update({
where: { id: signal.id },
data: {
emailSentTo: emailResult.sentTo.join(", "),
emailSentAt: emailResult.sentAt,
emailError: emailResult.error,
},
});
return NextResponse.json({
success: true,
ticketId,
emailSent: emailResult.success,
emailError: emailResult.error,
});
} catch (error) {
console.error("Consultation API error:", error);
return NextResponse.json({ error: "Failed to submit consultation request" }, { status: 500 });
}
}
function generateConsultationEmail(contact: any, aiContext: any, ticketId: string, appUrl: string) {
const insights = (aiContext?.conversationInsights || []).map((i: string) => `<li style="margin-bottom: 6px;">${i}</li>`).join("");
const topics = (aiContext?.suggestedTopics || []).map((t: string) => `<li style="margin-bottom: 4px; color: #0066CC;">${t}</li>`).join("");
return `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto; color: #1D1D1F;">
<div style="border-bottom: 2px solid #00F0FF; padding-bottom: 20px; margin-bottom: 24px;">
<p style="color: #86868B; font-size: 12px; letter-spacing: 2px; text-transform: uppercase;">FLUX AI — Engineering Consultation</p>
<h1 style="margin: 8px 0 0 0; font-size: 22px;">New Consultation Request</h1>
<p style="font-family: monospace; color: #00F0FF;">${ticketId}</p>
</div>
<div style="background: #F5F5F7; padding: 20px; border-radius: 12px; margin-bottom: 24px;">
<p style="margin: 4px 0;"><strong>${contact.name}</strong> — ${contact.company}</p>
<p style="margin: 4px 0;">Email: <a href="mailto:${contact.email}" style="color: #0066CC;">${contact.email}</a></p>
${contact.phone ? `<p style="margin: 4px 0;">Phone: ${contact.phone}</p>` : ""}
<p style="margin: 4px 0;">Preferred: <strong>${(contact.preferredContact || "email").toUpperCase()}</strong> · Timeframe: <strong>${contact.timeframe || "N/A"}</strong></p>
</div>
${aiContext?.industryLabel ? `<div style="background: #F0FBFF; border-left: 4px solid #00F0FF; padding: 16px 20px; border-radius: 0 12px 12px 0; margin-bottom: 24px;"><h3 style="margin: 0 0 8px 0; font-size: 13px; color: #00F0FF;">AI Context</h3><p style="margin: 4px 0;"><strong>Industry:</strong> ${aiContext.industryLabel}${aiContext.process || "General"}</p>${aiContext.estimatedSavingsPercent ? `<p style="color: #059669;"><strong>Savings:</strong> ~${aiContext.estimatedSavingsPercent}%</p>` : ""}${aiContext.productionVolume ? `<p><strong>Volume:</strong> ${aiContext.productionVolume}</p>` : ""}</div>` : ""}
${insights ? `<div style="margin-bottom: 24px;"><h3 style="font-size: 13px; color: #86868B;">Key Points</h3><ul style="padding-left: 20px;">${insights}</ul></div>` : ""}
${topics ? `<div style="margin-bottom: 24px;"><h3 style="font-size: 13px; color: #0066CC;">Prepare Topics</h3><ul style="padding-left: 20px; list-style: none;">${topics}</ul></div>` : ""}
${contact.message ? `<div style="margin-bottom: 24px;"><h3 style="font-size: 13px; color: #86868B;">Client Notes</h3><div style="padding: 12px; border-left: 4px solid #1D1D1F; background: #FAFAFA;">${contact.message}</div></div>` : ""}
<div style="border-top: 1px solid #E5E5EA; padding-top: 16px; margin-top: 32px; font-size: 11px; color: #86868B; text-align: center;"><p>FLUX Operations · Reply to contact ${contact.name} directly.</p></div>
</div>
`;
}
+26
View File
@@ -0,0 +1,26 @@
// ═══════════════════════════════════════════════════════════════
// FLUX SRL — Health Check Endpoint
// Place this at: src/app/api/health/route.ts
// ═══════════════════════════════════════════════════════════════
import { prisma } from '@/lib/prisma';
import { NextResponse } from 'next/server';
export async function GET() {
try {
// Verify database connectivity
await prisma.$queryRaw`SELECT 1`;
return NextResponse.json({
status: 'healthy',
timestamp: new Date().toISOString(),
uptime: Math.floor(process.uptime()),
version: process.env.npm_package_version || '0.1.0',
});
} catch {
return NextResponse.json(
{ status: 'unhealthy', error: 'Database connection failed' },
{ status: 503 }
);
}
}
+70
View File
@@ -0,0 +1,70 @@
import { NextRequest, NextResponse } from "next/server";
import fs from "fs";
import path from "path";
// 1. REGLAS DE SEGURIDAD ESTRICTAS
const ALLOWED_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.webp', '.mp4', '.mov'];
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB Límite
export async function POST(request: NextRequest) {
try {
const formData = await request.formData();
const file = formData.get("file") as File;
const ticketId = formData.get("ticketId") as string;
const clientName = formData.get("clientName") as string || "unregistered";
// 2. VALIDACIONES INICIALES
if (!file || !ticketId) {
return NextResponse.json({ error: "Faltan datos requeridos (archivo o ticketId)" }, { status: 400 });
}
if (file.size > MAX_FILE_SIZE) {
return NextResponse.json({ error: "El archivo excede el límite de 50MB" }, { status: 400 });
}
const ext = path.extname(file.name).toLowerCase();
if (!ALLOWED_EXTENSIONS.includes(ext)) {
return NextResponse.json({ error: `Formato no permitido. Solo aceptamos: ${ALLOWED_EXTENSIONS.join(", ")}` }, { status: 400 });
}
// 3. SANITIZACIÓN DE NOMBRES (Evita inyección de código y caracteres raros)
const safeTicketId = ticketId.replace(/[^a-zA-Z0-9-]/g, "");
// Convertimos "David Herran!" a "david-herran"
const safeClientName = clientName.toLowerCase().replace(/[^a-z0-9]/g, "-").substring(0, 30);
const folderName = `${safeTicketId}-${safeClientName}`;
// 4. CREACIÓN DE LA CARPETA DEL CLIENTE
// Ruta final: /public/operations-inbox/REQ-2026-X8Y-david-herran/
const uploadDir = path.join(process.cwd(), "public", "operations-inbox", folderName);
// Escudo Anti-Hacking (Verifica que la ruta resuelta no se escape de la carpeta public)
if (!path.resolve(uploadDir).startsWith(path.resolve(process.cwd(), "public", "operations-inbox"))) {
return NextResponse.json({ error: "Ruta de directorio inválida" }, { status: 400 });
}
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
// 5. GUARDAR EL ARCHIVO FÍSICAMENTE
const safeFileName = file.name.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9._-]/g, "");
const filePath = path.join(uploadDir, safeFileName);
const buffer = Buffer.from(await file.arrayBuffer());
fs.writeFileSync(filePath, buffer);
// 6. DEVOLVER LA URL PÚBLICA PARA GUARDARLA EN POSTGRES
const publicUrl = `/operations-inbox/${folderName}/${safeFileName}`;
return NextResponse.json({
success: true,
url: publicUrl,
fileName: safeFileName,
type: ext === '.mp4' || ext === '.mov' ? 'video' : 'image'
});
} catch (error) {
console.error("Error crítico en subida pública:", error);
return NextResponse.json({ error: "Error interno del servidor" }, { status: 500 });
}
}
+49 -15
View File
@@ -1,26 +1,60 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--font-sans: var(--font-inter), sans-serif;
--animate-ping-slow: ping-slow 3s cubic-bezier(0, 0, 0.2, 1) infinite;
@keyframes ping-slow {
75%, 100% { transform: scale(2); opacity: 0; }
}
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
@custom-variant dark (&:where(.dark, .dark *));
:root { --background: #F5F5F7; --foreground: #1D1D1F; }
.dark { --background: #0A0A0C; --foreground: #F5F5F7; }
/* ═══════════════════════════════════════════════════════════
FIX 1 — SCROLL HORIZONTAL EN iOS SAFARI
Causa raíz: elementos Three.js fixed + transforms de framer-motion
crean un "scroll container" fantasma en WebKit.
Solución: bloquear con las 3 propiedades a la vez.
═══════════════════════════════════════════════════════════ */
html {
overflow-x: hidden;
overscroll-behavior-x: none;
max-width: 100%;
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
transition: background-color 0.5s ease, color 0.5s ease;
overflow-x: hidden;
overscroll-behavior-x: none;
touch-action: pan-y;
max-width: 100%;
}
button { outline: none; }
/* ═══════════════════════════════════════════════════════════
FIX 2 — ANTI-FLICKERING EN DESKTOP
Causa raíz: backdrop-blur + opacity/transform de AnimatePresence
en el mismo frame fuerzan repaints en cascada.
Solución: capa GPU dedicada con translateZ(0).
═══════════════════════════════════════════════════════════ */
[class*="backdrop-blur"] {
-webkit-transform: translateZ(0);
transform: translateZ(0);
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
}
/* ═══════════════════════════════════════════════════════════
FIX 3 — CANVAS BREATHINGFIELD
Clase .breathing-field-wrapper añadida en BreathingField.tsx.
Este canvas es decorativo → nunca debe capturar gestos táctiles.
═══════════════════════════════════════════════════════════ */
.breathing-field-wrapper canvas {
pointer-events: none !important;
}
@@ -0,0 +1,163 @@
//src/app/hq-command/dashboard/applications/actions.ts
"use server";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
import { unstable_noStore as noStore } from "next/cache"; // 🔥 ANTI-CACHÉ
// 🔥 IMPORTAMOS EL TRADUCTOR DE IA
import { translateContentForCMS } from "@/lib/aiTranslator";
const generateSlug = (title: string) => {
return title.toLowerCase().trim().replace(/[^\w\s-]/g, '').replace(/[\s_-]+/g, '-').replace(/^-+|-+$/g, '');
};
// 1. OBTENER LA LISTA DE APLICACIONES
export async function getApplications() {
noStore();
try {
const apps = await prisma.application.findMany({
orderBy: { createdAt: "asc" }
});
return { success: true, apps };
} catch (error) {
return { error: "Failed to fetch applications." };
}
}
// 2. OBTENER UNA APLICACIÓN ESPECÍFICA
export async function getApplicationBySlug(slug: string) {
try {
const app = await prisma.application.findUnique({
where: { slug }
});
if (!app) return { error: "Application not found." };
return { success: true, app };
} catch (error) {
return { error: "Failed to fetch application details." };
}
}
// 3. CREAR NUEVA APLICACIÓN (¡Ahora con IA!)
export async function createApplication(formData: FormData) {
try {
const title = formData.get("title") as string;
const subtitle = formData.get("subtitle") as string;
const category = formData.get("category") as string;
// Capturamos el switch de IA
const autoTranslate = formData.get("autoTranslate") === "on";
const shortDescription = "New application ready to be configured.";
const slug = generateSlug(title);
let translationsJson = "{}";
// 🔥 LA MAGIA DE LA IA EN LA CREACIÓN 🔥
if (autoTranslate) {
const aiResult = await translateContentForCMS({
title, subtitle, category, shortDescription
});
if (aiResult) translationsJson = JSON.stringify(aiResult);
}
await prisma.application.create({
data: {
slug, title, subtitle, category,
shortDescription,
heroDescription: "", sectionsJson: "[]", advantagesJson: "[]", datasheetJson: "{}", dashboardMetricsJson: "[]",
isActive: true,
translationsJson
}
});
revalidatePath("/hq-command/dashboard/applications");
revalidatePath("/[locale]", "layout");
return { success: true };
} catch (error) {
return { error: "Failed to create application. Title might already exist." };
}
}
// 4. ACTUALIZAR TODA LA INFORMACIÓN (¡Traduciendo JSONs completos!)
export async function updateApplicationData(formData: FormData) {
try {
const slug = formData.get("slug") as string;
const title = formData.get("title") as string;
const subtitle = formData.get("subtitle") as string;
const category = formData.get("category") as string;
const shortDescription = formData.get("shortDescription") as string;
const heroDescription = formData.get("heroDescription") as string;
const sectionsJson = formData.get("sectionsJson") as string;
const advantagesJson = formData.get("advantagesJson") as string;
const datasheetJson = formData.get("datasheetJson") as string || "{}";
const dashboardMetricsJson = formData.get("dashboardMetricsJson") as string || "[]";
const autoTranslate = formData.get("autoTranslate") === "on";
let updateData: any = {
title, subtitle, category, shortDescription, heroDescription,
sectionsJson, advantagesJson, datasheetJson, dashboardMetricsJson
};
// 🔥 LA MAGIA DE LA IA PARA EL CONTENIDO PROFUNDO 🔥
// Nota: Le mandamos los JSON stringificados. GPT-4o los traducirá y nos los devolverá con la misma estructura.
if (autoTranslate) {
const aiResult = await translateContentForCMS({
title, subtitle, category, shortDescription, heroDescription,
sectionsJson, advantagesJson, dashboardMetricsJson
});
if (aiResult) {
updateData.translationsJson = JSON.stringify(aiResult);
}
}
await prisma.application.update({
where: { slug },
data: updateData
});
revalidatePath(`/applications/${slug}`);
revalidatePath("/hq-command/dashboard/applications");
revalidatePath("/[locale]", "layout");
return { success: true };
} catch (error) {
console.error("Update Error:", error);
return { error: "Failed to update application data." };
}
}
// 5. OCULTAR / MOSTRAR APLICACIÓN
export async function toggleApplication(slug: string, currentStatus: boolean) {
try {
await prisma.application.update({
where: { slug },
data: { isActive: !currentStatus }
});
revalidatePath("/hq-command/dashboard/applications");
revalidatePath("/[locale]", "layout");
return { success: true };
} catch (e) {
return { error: "Failed to toggle status." };
}
}
// 6. ELIMINAR APLICACIÓN
export async function deleteApplication(slug: string) {
try {
await prisma.application.delete({ where: { slug } });
revalidatePath("/hq-command/dashboard/applications");
revalidatePath("/[locale]", "layout");
return { success: true };
} catch (e) {
return { error: "Failed to delete application." };
}
}
// 7. LA SEMILLA: AUTO-POBLAR LA BASE DE DATOS (Intacto)
export async function seedInitialApplications() {
// ... Tu código actual de la semilla se queda exactamente igual ...
// (Para mantener este mensaje limpio, asume que la función de seedInitialApplications() que ya tienes va aquí)
}
@@ -0,0 +1,746 @@
//src/app/hq-command/dashboard/applications/page.ts
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
ArrowLeft, Layers, DatabaseZap, Loader2, X, CheckCircle2, FileText, LayoutTemplate, AlignLeft, Plus, Trash2, AlertCircle, Eye, EyeOff, Sparkles,
Bold, Italic, Heading1, Heading2, Heading3, List, ListOrdered, Quote, Table, Minus, Image as ImageIcon, Video, Box, Type, Code, RotateCcw, RotateCw,
Maximize2, Minimize2, ChevronDown, FolderOpen, Upload, FolderPlus, ChevronRight, File, ArrowUpFromLine, Search, Grid3X3, LayoutList, Copy, Check
} from "lucide-react";
import { getApplications, createApplication, updateApplicationData, toggleApplication, deleteApplication } from "./actions";
// ─────────────────────────────────────────────────────────────────────────────
// 🔥 ASSET MANAGER — File Browser, Upload & Folder Creation
// ─────────────────────────────────────────────────────────────────────────────
// Connects to /api/assets to browse, upload, and organize media files
// within /public/applications/{slug}/
// ─────────────────────────────────────────────────────────────────────────────
interface AssetItem {
name: string;
type: "file" | "folder";
mediaType?: string;
extension?: string;
path: string;
publicUrl?: string;
size?: string;
sizeBytes?: number;
modifiedAt?: string;
childCount?: number;
}
interface AssetManagerProps {
slug: string;
isOpen: boolean;
onClose: () => void;
onInsert: (markdownSyntax: string) => void;
}
function AssetManager({ slug, isOpen, onClose, onInsert }: AssetManagerProps) {
const [currentPath, setCurrentPath] = useState("");
const [items, setItems] = useState<AssetItem[]>([]);
const [breadcrumbs, setBreadcrumbs] = useState<{name: string; path: string}[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState("");
const fileInputRef = useRef<HTMLInputElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [showNewFolder, setShowNewFolder] = useState(false);
const [newFolderName, setNewFolderName] = useState("");
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
const [searchQuery, setSearchQuery] = useState("");
const [copiedPath, setCopiedPath] = useState<string | null>(null);
const fetchAssets = useCallback(async (dirPath: string = "") => {
setIsLoading(true);
setError(null);
try {
const params = new URLSearchParams({ slug, path: dirPath });
const res = await fetch(`/api/assets?${params}`);
const data = await res.json();
if (data.success) {
setItems(data.items);
setBreadcrumbs(data.breadcrumbs);
setCurrentPath(dirPath);
} else {
setError(data.error || "Failed to load directory");
}
} catch (err) {
setError("Connection error — make sure /api/assets/route.ts exists.");
}
setIsLoading(false);
}, [slug]);
useEffect(() => {
if (isOpen) { fetchAssets(currentPath); setSearchQuery(""); }
}, [isOpen, fetchAssets]); // eslint-disable-line react-hooks/exhaustive-deps
const navigateTo = (folderPath: string) => { fetchAssets(folderPath); };
const uploadFile = async (file: File) => {
setIsUploading(true);
setUploadProgress(`Uploading ${file.name}...`);
try {
const formData = new FormData();
formData.append("slug", slug);
formData.append("path", currentPath);
formData.append("file", file);
const res = await fetch("/api/assets", { method: "POST", body: formData });
const data = await res.json();
if (data.success) {
setUploadProgress(`${data.file.name} uploaded`);
await fetchAssets(currentPath);
setTimeout(() => setUploadProgress(""), 2000);
} else {
setUploadProgress(`✗ Error: ${data.error}`);
setTimeout(() => setUploadProgress(""), 4000);
}
} catch (err) {
setUploadProgress("✗ Upload failed");
setTimeout(() => setUploadProgress(""), 3000);
}
setIsUploading(false);
};
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files) Array.from(files).forEach(uploadFile);
e.target.value = "";
};
const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); setIsDragging(true); };
const handleDragLeave = (e: React.DragEvent) => { e.preventDefault(); setIsDragging(false); };
const handleDrop = (e: React.DragEvent) => {
e.preventDefault(); setIsDragging(false);
const files = e.dataTransfer.files;
if (files.length > 0) Array.from(files).forEach(uploadFile);
};
const createFolder = async () => {
if (!newFolderName.trim()) return;
try {
const res = await fetch("/api/assets", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug, folderName: newFolderName, parentPath: currentPath }),
});
const data = await res.json();
if (data.success) {
setShowNewFolder(false); setNewFolderName(""); await fetchAssets(currentPath);
} else { alert(data.error || "Failed to create folder"); }
} catch { alert("Connection error creating folder"); }
};
const deleteFile = async (filePath: string, fileName: string) => {
if (!confirm(`Delete "${fileName}"? This cannot be undone.`)) return;
try {
const res = await fetch("/api/assets", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug, filePath }),
});
const data = await res.json();
if (data.success) await fetchAssets(currentPath);
else alert(data.error);
} catch { alert("Failed to delete file"); }
};
const insertAsset = (item: AssetItem) => {
if (item.type === "folder") return;
const url = item.publicUrl || `/applications/${slug}/${item.path}`;
let syntax = "";
switch (item.mediaType) {
case "image": syntax = `![${item.name}](${url})`; break;
case "video": syntax = `[VIDEO:${url}]`; break;
case "model": syntax = `[3D:${url}]`; break;
default: syntax = `[${item.name}](${url})`;
}
onInsert(syntax);
onClose();
};
const copyPath = (item: AssetItem) => {
const url = item.publicUrl || `/applications/${slug}/${item.path}`;
navigator.clipboard.writeText(url);
setCopiedPath(item.path);
setTimeout(() => setCopiedPath(null), 1500);
};
const filteredItems = searchQuery
? items.filter(item => item.name.toLowerCase().includes(searchQuery.toLowerCase()))
: items;
const renderThumbnail = (item: AssetItem) => {
if (item.type === "folder") return <div className="w-full h-full flex items-center justify-center bg-white/[0.02]"><FolderOpen size={28} className="text-purple-400/70" /></div>;
if (item.mediaType === "image" && item.publicUrl) return <img src={item.publicUrl} alt={item.name} className="w-full h-full object-cover" loading="lazy" />;
if (item.mediaType === "video") return <div className="w-full h-full flex items-center justify-center bg-blue-500/5"><Video size={24} className="text-blue-400/60" /></div>;
if (item.mediaType === "model") return <div className="w-full h-full flex items-center justify-center bg-purple-500/5"><Box size={24} className="text-purple-400/60" /></div>;
return <div className="w-full h-full flex items-center justify-center bg-white/[0.02]"><File size={24} className="text-[#86868B]/50" /></div>;
};
const typeBadge = (mediaType?: string) => {
const styles: Record<string, string> = {
image: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20",
video: "bg-blue-500/10 text-blue-400 border-blue-500/20",
model: "bg-purple-500/10 text-purple-400 border-purple-500/20",
document: "bg-amber-500/10 text-amber-400 border-amber-500/20",
};
return styles[mediaType || ""] || "bg-white/5 text-[#86868B] border-white/10";
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[70] flex items-center justify-center p-4 bg-black/85 backdrop-blur-md">
<div className="bg-[#0D0D0D] border border-white/10 w-full max-w-4xl rounded-[2rem] shadow-2xl flex flex-col max-h-[85vh] relative overflow-hidden"
onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop}>
{isDragging && (
<div className="absolute inset-0 z-50 bg-purple-500/10 border-2 border-dashed border-purple-500/50 rounded-[2rem] flex items-center justify-center backdrop-blur-sm">
<div className="text-center">
<ArrowUpFromLine size={48} className="text-purple-400 mx-auto mb-3 animate-bounce" />
<p className="text-purple-400 font-medium text-lg">Drop files to upload</p>
<p className="text-[#86868B] text-sm mt-1">to /applications/{slug}/{currentPath || "root"}</p>
</div>
</div>
)}
{/* Header */}
<div className="px-6 py-5 border-b border-white/10 shrink-0">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-purple-500/15 rounded-xl text-purple-400"><FolderOpen size={20} /></div>
<div>
<h3 className="text-lg font-medium text-white">Asset Manager</h3>
<p className="text-[10px] text-[#86868B] uppercase tracking-widest font-mono">/public/applications/{slug}/</p>
</div>
</div>
<button onClick={onClose} className="text-[#86868B] hover:text-white p-2 hover:bg-white/5 rounded-xl transition-all"><X size={20} /></button>
</div>
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-1 text-sm overflow-x-auto [scrollbar-width:none] flex-1 min-w-0">
{breadcrumbs.map((crumb, idx) => (
<span key={idx} className="flex items-center shrink-0">
{idx > 0 && <ChevronRight size={12} className="text-[#86868B]/50 mx-0.5" />}
<button onClick={() => navigateTo(crumb.path)} className={`px-2 py-1 rounded-lg transition-colors text-xs ${idx === breadcrumbs.length - 1 ? "text-purple-400 bg-purple-500/10" : "text-[#86868B] hover:text-white hover:bg-white/5"}`}>{crumb.name}</button>
</span>
))}
</div>
<div className="flex items-center gap-2 shrink-0">
<div className="relative">
<Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[#86868B]" />
<input value={searchQuery} onChange={e => setSearchQuery(e.target.value)} placeholder="Filter..." className="bg-white/5 border border-white/10 rounded-lg pl-8 pr-3 py-1.5 text-xs text-white w-32 focus:w-48 transition-all outline-none focus:border-purple-500/50" />
</div>
<div className="flex bg-white/5 rounded-lg border border-white/10 overflow-hidden">
<button onClick={() => setViewMode("grid")} className={`p-1.5 transition-colors ${viewMode === "grid" ? "bg-purple-500/20 text-purple-400" : "text-[#86868B] hover:text-white"}`}><Grid3X3 size={14} /></button>
<button onClick={() => setViewMode("list")} className={`p-1.5 transition-colors ${viewMode === "list" ? "bg-purple-500/20 text-purple-400" : "text-[#86868B] hover:text-white"}`}><LayoutList size={14} /></button>
</div>
<button onClick={() => setShowNewFolder(!showNewFolder)} className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-[#86868B] hover:text-white bg-white/5 border border-white/10 rounded-lg hover:bg-white/10 transition-all"><FolderPlus size={13} /> Folder</button>
<button onClick={() => fileInputRef.current?.click()} disabled={isUploading} className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-white bg-purple-500 rounded-lg hover:bg-purple-400 transition-all disabled:opacity-50 font-medium"><Upload size={13} /> Upload</button>
<input ref={fileInputRef} type="file" multiple accept=".jpg,.jpeg,.png,.webp,.gif,.svg,.avif,.mp4,.webm,.mov,.glb,.gltf,.usdz,.pdf" onChange={handleFileSelect} className="hidden" />
</div>
</div>
{showNewFolder && (
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-white/5">
<FolderPlus size={14} className="text-purple-400 shrink-0" />
<input value={newFolderName} onChange={e => setNewFolderName(e.target.value)} onKeyDown={e => { if (e.key === "Enter") createFolder(); if (e.key === "Escape") { setShowNewFolder(false); setNewFolderName(""); } }} placeholder="folder-name (lowercase, hyphens ok)" autoFocus className="flex-1 bg-black/40 border border-white/10 rounded-lg px-3 py-1.5 text-sm text-white focus:border-purple-500 outline-none font-mono" />
<button onClick={createFolder} className="px-3 py-1.5 text-xs bg-purple-500 text-white rounded-lg hover:bg-purple-400 font-medium">Create</button>
<button onClick={() => { setShowNewFolder(false); setNewFolderName(""); }} className="px-3 py-1.5 text-xs text-[#86868B] hover:text-white">Cancel</button>
</div>
)}
{uploadProgress && (
<div className="mt-3 pt-3 border-t border-white/5">
<div className="flex items-center gap-2 text-xs">
{isUploading && <Loader2 size={12} className="animate-spin text-purple-400" />}
<span className={isUploading ? "text-purple-400" : uploadProgress.startsWith("✓") ? "text-emerald-400" : "text-red-400"}>{uploadProgress}</span>
</div>
</div>
)}
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4 [scrollbar-width:none]">
{isLoading ? (
<div className="flex items-center justify-center py-20"><Loader2 size={24} className="animate-spin text-purple-400" /></div>
) : error ? (
<div className="flex flex-col items-center justify-center py-20 text-center">
<AlertCircle size={32} className="text-red-400/50 mb-3" />
<p className="text-red-400/80 text-sm mb-1">{error}</p>
<p className="text-[#86868B] text-xs">Make sure <code className="bg-white/5 px-1.5 py-0.5 rounded text-purple-400">/api/assets/route.ts</code> exists.</p>
</div>
) : filteredItems.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-center">
<FolderOpen size={48} className="text-[#86868B]/20 mb-4" />
{searchQuery ? (
<p className="text-[#86868B] text-sm">No files matching &quot;{searchQuery}&quot;</p>
) : (
<>
<p className="text-[#86868B] text-sm mb-2">This directory is empty</p>
<p className="text-[#86868B]/60 text-xs">Upload files or create subfolders to organize your assets</p>
<div className="flex gap-2 mt-4">
{["images", "videos", "models"].map(folder => (
<button key={folder} onClick={async () => {
await fetch("/api/assets", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ slug, folderName: folder, parentPath: currentPath }) });
fetchAssets(currentPath);
}} className="px-3 py-2 text-xs text-purple-400 bg-purple-500/10 border border-purple-500/20 rounded-lg hover:bg-purple-500/20 transition-colors">+ {folder}/</button>
))}
</div>
</>
)}
</div>
) : viewMode === "grid" ? (
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-3">
{filteredItems.map((item) => (
<div key={item.path} className="group relative bg-[#111] border border-white/5 rounded-xl overflow-hidden hover:border-purple-500/30 transition-all cursor-pointer" onClick={() => item.type === "folder" ? navigateTo(item.path) : insertAsset(item)}>
<div className="aspect-square overflow-hidden bg-black/20">{renderThumbnail(item)}</div>
<div className="p-2">
<p className="text-[11px] text-white truncate font-medium">{item.name}</p>
<div className="flex items-center justify-between mt-1">
{item.type === "folder" ? (
<span className="text-[9px] text-[#86868B]">{item.childCount} items</span>
) : (
<span className={`text-[9px] px-1.5 py-0.5 rounded border ${typeBadge(item.mediaType)}`}>{item.mediaType}</span>
)}
{item.size && <span className="text-[9px] text-[#86868B]">{item.size}</span>}
</div>
</div>
{item.type === "file" && (
<div className="absolute top-1 right-1 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button onClick={(e) => { e.stopPropagation(); copyPath(item); }} className="p-1.5 bg-black/70 backdrop-blur-sm rounded-lg text-[#86868B] hover:text-white transition-colors" title="Copy path">
{copiedPath === item.path ? <Check size={11} className="text-emerald-400" /> : <Copy size={11} />}
</button>
<button onClick={(e) => { e.stopPropagation(); deleteFile(item.path, item.name); }} className="p-1.5 bg-black/70 backdrop-blur-sm rounded-lg text-[#86868B] hover:text-red-400 transition-colors" title="Delete"><Trash2 size={11} /></button>
</div>
)}
</div>
))}
</div>
) : (
<div className="space-y-1">
{filteredItems.map((item) => (
<div key={item.path} className="group flex items-center gap-3 px-3 py-2.5 rounded-xl hover:bg-white/[0.03] transition-colors cursor-pointer" onClick={() => item.type === "folder" ? navigateTo(item.path) : insertAsset(item)}>
<div className="w-8 h-8 rounded-lg overflow-hidden shrink-0 border border-white/5">
{item.type === "folder" ? <div className="w-full h-full flex items-center justify-center bg-purple-500/10"><FolderOpen size={14} className="text-purple-400" /></div>
: item.mediaType === "image" && item.publicUrl ? <img src={item.publicUrl} alt="" className="w-full h-full object-cover" loading="lazy" />
: <div className={`w-full h-full flex items-center justify-center ${item.mediaType === "video" ? "bg-blue-500/10" : item.mediaType === "model" ? "bg-purple-500/10" : "bg-white/5"}`}>
{item.mediaType === "video" ? <Video size={12} className="text-blue-400" /> : item.mediaType === "model" ? <Box size={12} className="text-purple-400" /> : <File size={12} className="text-[#86868B]" />}
</div>}
</div>
<span className="text-sm text-white flex-1 truncate">{item.name}</span>
{item.type === "file" && <span className={`text-[9px] px-2 py-0.5 rounded border shrink-0 ${typeBadge(item.mediaType)}`}>{item.extension}</span>}
{item.type === "folder" && <span className="text-[9px] text-[#86868B] shrink-0">{item.childCount} items</span>}
{item.size && <span className="text-[10px] text-[#86868B] w-16 text-right shrink-0">{item.size}</span>}
{item.type === "file" && (
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
<button onClick={(e) => { e.stopPropagation(); copyPath(item); }} className="p-1.5 rounded-lg text-[#86868B] hover:text-white hover:bg-white/10 transition-colors">{copiedPath === item.path ? <Check size={12} className="text-emerald-400" /> : <Copy size={12} />}</button>
<button onClick={(e) => { e.stopPropagation(); deleteFile(item.path, item.name); }} className="p-1.5 rounded-lg text-[#86868B] hover:text-red-400 hover:bg-red-500/10 transition-colors"><Trash2 size={12} /></button>
</div>
)}
{item.type === "folder" && <ChevronRight size={14} className="text-[#86868B]/50 shrink-0" />}
</div>
))}
</div>
)}
</div>
<div className="px-6 py-3 border-t border-white/10 flex items-center justify-between text-[10px] text-[#86868B] shrink-0 bg-black/20">
<span>{filteredItems.length} items{searchQuery ? " (filtered)" : ""} Click a file to insert into editor</span>
<span className="font-mono">Drag & drop supported</span>
</div>
</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// 🔥 MARKDOWN EDITOR — Rich Toolbar + Asset Manager
// ─────────────────────────────────────────────────────────────────────────────
interface MarkdownToolbarProps {
name: string;
defaultValue?: string;
required?: boolean;
rows?: number;
placeholder?: string;
slug?: string;
}
function MarkdownEditor({ name, defaultValue = "", required, rows = 12, placeholder, slug }: MarkdownToolbarProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [value, setValue] = useState(defaultValue);
const [isExpanded, setIsExpanded] = useState(false);
const [showInsertMenu, setShowInsertMenu] = useState(false);
const [history, setHistory] = useState<string[]>([defaultValue]);
const [historyIndex, setHistoryIndex] = useState(0);
const insertMenuRef = useRef<HTMLDivElement>(null);
const [isAssetManagerOpen, setIsAssetManagerOpen] = useState(false);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (insertMenuRef.current && !insertMenuRef.current.contains(e.target as Node)) setShowInsertMenu(false);
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const historyTimeout = useRef<NodeJS.Timeout | null>(null);
const pushHistory = useCallback((newValue: string) => {
if (historyTimeout.current) clearTimeout(historyTimeout.current);
historyTimeout.current = setTimeout(() => {
setHistory(prev => [...prev.slice(0, historyIndex + 1), newValue].slice(-50));
setHistoryIndex(prev => Math.min(prev + 1, 49));
}, 500);
}, [historyIndex]);
const handleChange = (newValue: string) => { setValue(newValue); pushHistory(newValue); };
const undo = () => { if (historyIndex > 0) { const i = historyIndex - 1; setHistoryIndex(i); setValue(history[i]); } };
const redo = () => { if (historyIndex < history.length - 1) { const i = historyIndex + 1; setHistoryIndex(i); setValue(history[i]); } };
const getSelection = () => {
const ta = textareaRef.current;
if (!ta) return { start: 0, end: 0, selected: "", before: "", after: "" };
return { start: ta.selectionStart, end: ta.selectionEnd, selected: value.substring(ta.selectionStart, ta.selectionEnd), before: value.substring(0, ta.selectionStart), after: value.substring(ta.selectionEnd) };
};
const replaceSelection = (newText: string, cursorOffset?: number) => {
const { before, after } = getSelection();
handleChange(before + newText + after);
const pos = cursorOffset !== undefined ? before.length + cursorOffset : before.length + newText.length;
setTimeout(() => { const ta = textareaRef.current; if (ta) { ta.focus(); ta.setSelectionRange(pos, pos); } }, 0);
};
const wrapSelection = (prefix: string, suffix: string) => {
const { selected } = getSelection();
if (selected) { replaceSelection(`${prefix}${selected}${suffix}`, prefix.length + selected.length + suffix.length); }
else { replaceSelection(`${prefix}text${suffix}`, prefix.length); }
};
const insertAtCursor = (text: string, cursorOffset?: number) => { replaceSelection(text, cursorOffset); };
const handleAssetInsert = (markdownSyntax: string) => { insertAtCursor(`\n${markdownSyntax}\n`); };
const prependLine = (prefix: string) => {
const { start, selected } = getSelection();
const lineStart = value.lastIndexOf('\n', start - 1) + 1;
const before = value.substring(0, lineStart);
const currentLine = selected || value.substring(lineStart).split('\n')[0];
const afterLine = value.substring(lineStart + currentLine.length);
handleChange(before + prefix + currentLine + afterLine);
setTimeout(() => { const ta = textareaRef.current; if (ta) { ta.focus(); ta.setSelectionRange(lineStart + prefix.length, lineStart + prefix.length + currentLine.length); } }, 0);
};
const actions = {
bold: () => wrapSelection("**", "**"),
italic: () => wrapSelection("*", "*"),
h1: () => prependLine("# "),
h2: () => prependLine("## "),
h3: () => prependLine("### "),
quote: () => prependLine("> "),
ul: () => insertAtCursor("\n- Item 1\n- Item 2\n- Item 3\n", 3),
ol: () => insertAtCursor("\n1. First\n2. Second\n3. Third\n", 4),
hr: () => insertAtCursor("\n---\n"),
table: () => insertAtCursor("\n| Column A | Column B | Highlight |\n|---|---|---|\n| Data 1 | Data 2 | Value 1 |\n| Data 3 | Data 4 | Value 2 |\n", 2),
image: () => { insertAtCursor(`\n![Image description](/applications/${slug || "your-slug"}/image-name.jpg)\n`, 3); },
video: () => { insertAtCursor(`\n[VIDEO:/applications/${slug || "your-slug"}/videos/video-name.mp4]\n`, 8); },
model3d: () => { insertAtCursor(`\n[3D:/applications/${slug || "your-slug"}/models/model-name.glb]\n`, 5); },
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
const isMod = e.metaKey || e.ctrlKey;
if (isMod && e.key === 'b') { e.preventDefault(); actions.bold(); }
if (isMod && e.key === 'i') { e.preventDefault(); actions.italic(); }
if (isMod && e.key === 'z') { e.preventDefault(); e.shiftKey ? redo() : undo(); }
if (e.key === 'Tab') { e.preventDefault(); insertAtCursor(" "); }
};
const ToolBtn = ({ icon: Icon, label, onClick, className = "" }: { icon: any; label: string; onClick: () => void; className?: string }) => (
<button type="button" onClick={onClick} title={label} className={`p-1.5 rounded-lg text-[#86868B] hover:text-white hover:bg-white/10 transition-all active:scale-90 ${className}`}><Icon size={15} strokeWidth={2} /></button>
);
const Divider = () => <div className="w-px h-5 bg-white/10 mx-1" />;
return (
<div className={`flex flex-col ${isExpanded ? "fixed inset-0 z-[60] bg-[#0A0A0A] p-6" : ""}`}>
<input type="hidden" name={name} value={value} />
<div className={`flex flex-wrap items-center gap-0.5 px-2 py-1.5 bg-black/60 border border-white/10 rounded-t-xl ${isExpanded ? "border-b-0 rounded-t-2xl" : ""}`}>
<ToolBtn icon={Bold} label="Bold (⌘B)" onClick={actions.bold} />
<ToolBtn icon={Italic} label="Italic (⌘I)" onClick={actions.italic} />
<Divider />
<ToolBtn icon={Heading1} label="Heading 1" onClick={actions.h1} />
<ToolBtn icon={Heading2} label="Heading 2" onClick={actions.h2} />
<ToolBtn icon={Heading3} label="Heading 3" onClick={actions.h3} />
<Divider />
<ToolBtn icon={List} label="Bullet List" onClick={actions.ul} />
<ToolBtn icon={ListOrdered} label="Numbered List" onClick={actions.ol} />
<ToolBtn icon={Quote} label="Blockquote" onClick={actions.quote} />
<ToolBtn icon={Table} label="Insert Table" onClick={actions.table} />
<ToolBtn icon={Minus} label="Horizontal Rule" onClick={actions.hr} />
<Divider />
<div className="relative" ref={insertMenuRef}>
<button type="button" onClick={() => setShowInsertMenu(!showInsertMenu)} className="flex items-center gap-1 px-2 py-1.5 rounded-lg text-purple-400 hover:text-purple-300 hover:bg-purple-500/10 transition-all text-[11px] font-semibold uppercase tracking-wider">
<Plus size={13} /> Insert <ChevronDown size={11} className={`transition-transform ${showInsertMenu ? "rotate-180" : ""}`} />
</button>
{showInsertMenu && (
<div className="absolute top-full left-0 mt-1 w-56 bg-[#1A1A1A] border border-white/10 rounded-xl shadow-2xl z-50 overflow-hidden animate-in fade-in slide-in-from-top-2 duration-150">
<div className="p-1">
<button type="button" onClick={() => { actions.image(); setShowInsertMenu(false); }} className="w-full flex items-center gap-3 px-3 py-2.5 text-sm text-white/80 hover:text-white hover:bg-white/5 rounded-lg transition-colors text-left">
<div className="p-1.5 bg-emerald-500/15 rounded-lg text-emerald-400"><ImageIcon size={14} /></div>
<div><p className="text-xs font-medium">Image</p><p className="text-[10px] text-[#86868B]">.jpg .png .webp</p></div>
</button>
<button type="button" onClick={() => { actions.video(); setShowInsertMenu(false); }} className="w-full flex items-center gap-3 px-3 py-2.5 text-sm text-white/80 hover:text-white hover:bg-white/5 rounded-lg transition-colors text-left">
<div className="p-1.5 bg-blue-500/15 rounded-lg text-blue-400"><Video size={14} /></div>
<div><p className="text-xs font-medium">Video</p><p className="text-[10px] text-[#86868B]">.mp4 local file</p></div>
</button>
<button type="button" onClick={() => { actions.model3d(); setShowInsertMenu(false); }} className="w-full flex items-center gap-3 px-3 py-2.5 text-sm text-white/80 hover:text-white hover:bg-white/5 rounded-lg transition-colors text-left">
<div className="p-1.5 bg-purple-500/15 rounded-lg text-purple-400"><Box size={14} /></div>
<div><p className="text-xs font-medium">3D Model</p><p className="text-[10px] text-[#86868B]">.glb / .usdz (AR)</p></div>
</button>
<div className="border-t border-white/5 mt-1 pt-1">
<button type="button" onClick={() => { actions.table(); setShowInsertMenu(false); }} className="w-full flex items-center gap-3 px-3 py-2.5 text-sm text-white/80 hover:text-white hover:bg-white/5 rounded-lg transition-colors text-left">
<div className="p-1.5 bg-amber-500/15 rounded-lg text-amber-400"><Table size={14} /></div>
<div><p className="text-xs font-medium">Data Table</p><p className="text-[10px] text-[#86868B]">Auto-highlighted last column</p></div>
</button>
</div>
</div>
</div>
)}
</div>
{slug && (<><Divider /><button type="button" onClick={() => setIsAssetManagerOpen(true)} className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-emerald-400 hover:text-emerald-300 hover:bg-emerald-500/10 transition-all text-[11px] font-semibold uppercase tracking-wider" title="Browse & upload media files"><FolderOpen size={14} /> Assets</button></>)}
<div className="flex-1" />
<ToolBtn icon={RotateCcw} label="Undo (⌘Z)" onClick={undo} className={historyIndex <= 0 ? "opacity-30 pointer-events-none" : ""} />
<ToolBtn icon={RotateCw} label="Redo (⌘⇧Z)" onClick={redo} className={historyIndex >= history.length - 1 ? "opacity-30 pointer-events-none" : ""} />
<Divider />
<ToolBtn icon={isExpanded ? Minimize2 : Maximize2} label={isExpanded ? "Exit Fullscreen" : "Fullscreen"} onClick={() => setIsExpanded(!isExpanded)} />
</div>
<textarea ref={textareaRef} value={value} onChange={(e) => handleChange(e.target.value)} onKeyDown={handleKeyDown} required={required} rows={isExpanded ? 40 : rows} placeholder={placeholder} className={`w-full bg-black/40 border border-white/10 border-t-0 p-4 text-white font-mono text-sm focus:border-purple-500 outline-none resize-none leading-relaxed ${isExpanded ? "flex-1 rounded-b-2xl text-base leading-loose" : "rounded-b-xl"}`} style={{ tabSize: 2 }} />
<div className="flex items-center justify-between mt-1.5 px-1">
<div className="flex items-center gap-3 text-[10px] text-[#86868B] font-mono"><span>{value.split('\n').length} lines</span><span className="text-white/10">|</span><span>{value.length} chars</span></div>
<div className="flex items-center gap-2 text-[10px] text-[#86868B]"><span className="opacity-60">B Bold</span><span className="opacity-60">I Italic</span><span className="opacity-60">Tab Indent</span></div>
</div>
{!isExpanded && (
<div className="bg-purple-500/10 border border-purple-500/20 rounded-xl p-4 text-xs text-[#86868B] font-mono leading-relaxed mt-3">
<p className="text-purple-400 font-semibold uppercase tracking-widest mb-2 text-[10px]">Markdown Cheat Sheet:</p>
<p><strong># Title 1</strong> &nbsp;|&nbsp; <strong>## Title 2</strong> &nbsp;|&nbsp; <strong>**Bold**</strong></p>
<p className="mt-1"><strong>- List Item</strong> &nbsp;|&nbsp; <strong>&gt; Blockquote</strong></p>
<p className="mt-1 text-purple-400"><strong>![Image Description](/applications/{slug || "slug"}/image.jpg)</strong></p>
<div className="mt-3 pt-3 border-t border-purple-500/10">
<p className="mb-1"><strong>Media Assets <span className="text-emerald-400">(use the Assets button to browse & upload)</span>:</strong></p>
<p className="text-emerald-400/80">![alt](/applications/{slug || "slug"}/images/photo.jpg) Image</p>
<p className="text-blue-400/80 mt-1">[VIDEO:/applications/{slug || "slug"}/videos/clip.mp4] Video</p>
<p className="text-purple-400/80 mt-1">[3D:/applications/{slug || "slug"}/models/machine.glb] 3D Model</p>
</div>
</div>
)}
{slug && <AssetManager slug={slug} isOpen={isAssetManagerOpen} onClose={() => setIsAssetManagerOpen(false)} onInsert={handleAssetInsert} />}
</div>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// 🔥 MAIN PAGE — Applications Manager (Knowledge Base)
// ─────────────────────────────────────────────────────────────────────────────
export default function ApplicationsManager() {
const router = useRouter();
const [apps, setApps] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [editingApp, setEditingApp] = useState<any | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [activeTab, setActiveTab] = useState<"basic" | "advantages" | "sections" | "dashboard">("basic");
const [advantages, setAdvantages] = useState<any[]>([]);
const [sections, setSections] = useState<any[]>([]);
const [dashboardMetrics, setDashboardMetrics] = useState<any[]>([]);
const fetchApps = async () => { setIsLoading(true); const res = await getApplications(); if (res.success && res.apps) setApps(res.apps); setIsLoading(false); };
useEffect(() => { fetchApps(); }, []);
const openEditModal = (app: any) => {
setEditingApp(app); setActiveTab("basic");
try { setAdvantages(JSON.parse(app.advantagesJson || "[]")); } catch { setAdvantages([]); }
try { setSections(JSON.parse(app.sectionsJson || "[]")); } catch { setSections([]); }
try { setDashboardMetrics(JSON.parse(app.dashboardMetricsJson || "[]")); } catch { setDashboardMetrics([]); }
};
const handleSave = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); setIsSubmitting(true);
const formData = new FormData(e.currentTarget);
formData.append("advantagesJson", JSON.stringify(advantages));
formData.append("sectionsJson", JSON.stringify(sections));
formData.append("dashboardMetricsJson", JSON.stringify(dashboardMetrics));
const res = await updateApplicationData(formData);
if (res.error) { alert("Error saving data: " + res.error); }
else { setEditingApp(null); await fetchApps(); router.refresh(); }
setIsSubmitting(false);
};
const handleCreate = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); setIsSubmitting(true);
await createApplication(new FormData(e.currentTarget));
setIsCreateModalOpen(false); fetchApps(); setIsSubmitting(false);
};
return (
<div className="min-h-screen p-6 md:p-12 max-w-7xl mx-auto">
<div className="mb-10">
<Link href="/hq-command/dashboard" className="inline-flex items-center gap-2 text-sm text-[#86868B] hover:text-purple-400 transition-colors mb-6 group"><ArrowLeft size={16} className="group-hover:-translate-x-1 transition-transform" /> Back to Command Center</Link>
<div className="flex flex-col md:flex-row justify-between items-start md:items-end gap-6">
<div>
<h1 className="text-3xl font-light text-white flex items-center gap-3"><Layers className="text-purple-400" /> Knowledge Base</h1>
<p className="text-[#86868B] mt-2">Manage the technical literature and general specifications of your application categories.</p>
</div>
<button onClick={() => setIsCreateModalOpen(true)} className="flex items-center gap-2 bg-purple-500/10 text-purple-400 border border-purple-500/20 px-5 py-2.5 rounded-xl font-medium hover:bg-purple-500 hover:text-white transition-all"><Plus size={18} /> New Application</button>
</div>
</div>
<div className="bg-[#111] border border-white/5 rounded-3xl overflow-hidden shadow-2xl">
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead><tr className="border-b border-white/5 text-[10px] uppercase tracking-widest text-[#86868B] bg-black/40"><th className="p-6 font-semibold">Application Sector</th><th className="p-6 font-semibold">Status</th><th className="p-6 font-semibold">Visibility</th><th className="p-6 font-semibold text-right">Actions</th></tr></thead>
<tbody>
{isLoading ? (
<tr><td colSpan={4} className="p-8 text-center text-[#86868B]"><Loader2 className="animate-spin mx-auto mb-2" size={24} /> Loading database...</td></tr>
) : apps.map((app) => {
const isPopulated = app.heroDescription && app.heroDescription.length > 10;
return (
<tr key={app.slug} className={`border-b border-white/5 transition-colors group ${app.isActive ? 'hover:bg-white/[0.02]' : 'opacity-50'}`}>
<td className="p-6"><p className="font-medium text-white">{app.title}</p><p className="text-xs text-[#86868B] font-mono mt-1">/{app.slug}</p></td>
<td className="p-6">{isPopulated ? <span className="bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 px-3 py-1 rounded-full text-[10px] uppercase tracking-wider flex items-center gap-1.5 w-fit font-semibold"><CheckCircle2 size={12} /> Populated</span> : <span className="bg-white/5 text-[#86868B] border border-white/10 px-3 py-1 rounded-full text-[10px] uppercase tracking-wider flex items-center gap-1.5 w-fit"><AlertCircle size={12} /> Pending Setup</span>}</td>
<td className="p-6"><button onClick={() => {toggleApplication(app.slug, app.isActive); fetchApps();}} className={`text-xs font-medium px-3 py-1 rounded-full flex items-center gap-1.5 transition-colors ${app.isActive ? 'bg-purple-500/10 text-purple-400' : 'bg-red-500/10 text-red-400'}`}>{app.isActive ? <><Eye size={12} /> Visible</> : <><EyeOff size={12} /> Hidden</>}</button></td>
<td className="p-6 text-right"><div className="flex justify-end gap-2"><button onClick={() => openEditModal(app)} className={`inline-flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-semibold transition-all ${isPopulated ? 'bg-white/5 text-white hover:bg-white/10' : 'bg-purple-500 text-white hover:bg-purple-400'}`}><DatabaseZap size={14} /> Manage Core</button><button onClick={() => { if(confirm("Delete this application forever?")) { deleteApplication(app.slug); fetchApps(); } }} className="text-[#86868B] hover:text-red-400 p-2 rounded-lg transition-colors opacity-0 group-hover:opacity-100"><Trash2 size={18}/></button></div></td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
{isCreateModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm">
<div className="bg-[#111] border border-white/10 w-full max-w-lg rounded-[2rem] p-8 relative shadow-2xl">
<button onClick={() => setIsCreateModalOpen(false)} className="absolute top-6 right-6 text-[#86868B] hover:text-white"><X size={20} /></button>
<h3 className="text-2xl font-light mb-6 text-purple-400">Add New Application</h3>
<form onSubmit={handleCreate} className="space-y-4">
<div><label className="block text-[10px] uppercase text-[#86868B] mb-1">Title</label><input name="title" required placeholder="e.g. Digital Printing" className="w-full bg-black/40 border border-white/10 rounded-xl p-3 text-white focus:border-purple-500 outline-none" /></div>
<div><label className="block text-[10px] uppercase text-[#86868B] mb-1">Subtitle</label><input name="subtitle" required placeholder="e.g. Inkjet Drying" className="w-full bg-black/40 border border-white/10 rounded-xl p-3 text-white focus:border-purple-500 outline-none" /></div>
<div><label className="block text-[10px] uppercase text-[#86868B] mb-1">Category Label</label><input name="category" required placeholder="e.g. Graphic Arts" className="w-full bg-black/40 border border-white/10 rounded-xl p-3 text-white focus:border-purple-500 outline-none" /></div>
<div className="bg-gradient-to-r from-purple-500/10 to-transparent border border-purple-500/20 p-4 rounded-xl flex items-center justify-between mt-6">
<div className="flex items-center gap-3"><div className="p-2 bg-purple-500/20 rounded-lg text-purple-400"><Sparkles size={18} /></div><div><p className="text-sm text-white font-medium">Flux AI Auto-Translate</p><p className="text-[10px] text-[#86868B] uppercase tracking-widest mt-0.5">Generates Base Locales</p></div></div>
<label className="relative inline-flex items-center cursor-pointer"><input type="checkbox" name="autoTranslate" defaultChecked className="sr-only peer" /><div className="w-11 h-6 bg-black/50 border border-white/10 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-purple-500"></div></label>
</div>
<button type="submit" disabled={isSubmitting} className="w-full bg-purple-500 text-white py-3 mt-2 rounded-xl text-sm font-semibold hover:bg-purple-400 flex items-center justify-center gap-2">{isSubmitting ? <Loader2 size={16} className="animate-spin" /> : "Create Application"}</button>
</form>
</div>
</div>
)}
{editingApp && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm">
<div className="bg-[#111] border border-white/10 w-full max-w-4xl rounded-[2rem] p-0 relative shadow-2xl flex flex-col max-h-[90vh]">
<div className="p-6 md:p-8 border-b border-white/10 relative pb-0 shrink-0">
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-transparent via-purple-500 to-transparent"></div>
<button onClick={() => setEditingApp(null)} className="absolute top-6 right-6 text-[#86868B] hover:text-white transition-colors"><X size={20} /></button>
<h3 className="text-2xl font-light mb-1 text-purple-400">Data Management Core</h3>
<p className="text-[#86868B] text-[10px] uppercase font-mono tracking-widest mb-6">Route: /{editingApp.slug.toUpperCase()}</p>
<div className="flex gap-6 border-b border-white/10 overflow-x-auto">
{[{ id: "basic", label: "Overview", icon: FileText },{ id: "advantages", label: "Advantages", icon: CheckCircle2 },{ id: "sections", label: "Tech Sections", icon: AlignLeft },{ id: "dashboard", label: "Dashboard UI", icon: LayoutTemplate }].map(t => (
<button key={t.id} type="button" onClick={() => setActiveTab(t.id as any)} className={`pb-4 text-xs uppercase tracking-widest font-semibold flex items-center gap-2 transition-all border-b-2 whitespace-nowrap ${activeTab === t.id ? "text-purple-400 border-purple-400" : "text-[#86868B] border-transparent hover:text-white"}`}><t.icon size={14} /> {t.label}</button>
))}
</div>
</div>
<form id="edit-app-form" onSubmit={handleSave} className="flex-1 overflow-y-auto p-6 md:p-8 [scrollbar-width:none]">
<input type="hidden" name="slug" value={editingApp.slug} />
<div className={activeTab === "basic" ? "space-y-6 animate-in fade-in" : "hidden"}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div><label className="block text-[10px] uppercase text-[#86868B] mb-1">Display Title</label><input name="title" defaultValue={editingApp.title} required className="w-full bg-black/40 border border-white/10 rounded-xl p-3 text-white focus:border-purple-500 outline-none" /></div>
<div><label className="block text-[10px] uppercase text-[#86868B] mb-1">Subtitle</label><input name="subtitle" defaultValue={editingApp.subtitle} required className="w-full bg-black/40 border border-white/10 rounded-xl p-3 text-white focus:border-purple-500 outline-none" /></div>
</div>
<div><label className="block text-[10px] uppercase text-[#86868B] mb-1">Category Label</label><input name="category" defaultValue={editingApp.category} required className="w-full bg-black/40 border border-white/10 rounded-xl p-3 text-white focus:border-purple-500 outline-none" /></div>
<div><label className="block text-[10px] uppercase text-purple-400 mb-1 flex items-center justify-between"><span>External Card Description (Homepage)</span><span className="text-[#86868B]">Max 150 chars</span></label><textarea name="shortDescription" defaultValue={editingApp.shortDescription} required rows={2} placeholder="Short summary for the public homepage cards..." className="w-full bg-purple-500/5 border border-purple-500/20 rounded-xl p-3 text-white focus:border-purple-500 outline-none resize-none" /></div>
<div>
<label className="block text-[10px] uppercase text-[#86868B] mb-2 flex justify-between items-center"><span>Deep Page Story (Markdown)</span><span className="text-purple-400/60 text-[9px] font-normal normal-case">Rich Editor + Asset Manager</span></label>
<MarkdownEditor name="heroDescription" defaultValue={editingApp.heroDescription} required rows={14} placeholder="Write the application's deep technical content here using Markdown..." slug={editingApp.slug} />
</div>
</div>
<div className={activeTab === "advantages" ? "space-y-4 animate-in fade-in" : "hidden"}>
{advantages.map((adv, idx) => (
<div key={idx} className="bg-black/40 border border-white/10 p-4 rounded-xl relative group">
<button type="button" onClick={() => setAdvantages(advantages.filter((_, i) => i !== idx))} className="absolute top-4 right-4 text-[#86868B] hover:text-red-400"><Trash2 size={16}/></button>
<input value={adv.title} onChange={e => { const n = [...advantages]; n[idx].title = e.target.value; setAdvantages(n); }} placeholder="Advantage Title" className="w-full bg-black/40 border border-white/10 rounded-lg p-2 text-sm text-white mb-3 focus:border-purple-500 outline-none" />
<textarea value={adv.description} onChange={e => { const n = [...advantages]; n[idx].description = e.target.value; setAdvantages(n); }} placeholder="Description..." rows={2} className="w-full bg-black/40 border border-white/10 rounded-lg p-2 text-sm text-white focus:border-purple-500 outline-none resize-none" />
</div>
))}
<button type="button" onClick={() => setAdvantages([...advantages, { title: "", description: "" }])} className="w-full border border-dashed border-white/20 text-[#86868B] hover:text-white hover:border-purple-500 py-3 rounded-xl flex items-center justify-center gap-2"><Plus size={16}/> Add Advantage</button>
</div>
<div className={activeTab === "sections" ? "space-y-6 animate-in fade-in" : "hidden"}>
{sections.map((sec, secIdx) => (
<div key={secIdx} className="bg-black/40 border border-white/10 p-6 rounded-2xl relative">
<button type="button" onClick={() => setSections(sections.filter((_, i) => i !== secIdx))} className="absolute top-4 right-4 text-[#86868B] hover:text-red-400"><Trash2 size={16}/></button>
<div className="flex gap-4 mb-4">
<div className="flex-1"><input value={sec.title} onChange={e => { const n = [...sections]; n[secIdx].title = e.target.value; setSections(n); }} placeholder="Section Title" className="w-full bg-black/40 border border-white/10 rounded-lg p-2 text-sm text-white focus:border-purple-500 outline-none" /></div>
<label className="flex items-center gap-2 text-sm text-white mt-2 cursor-pointer"><input type="checkbox" checked={sec.isMainTech} onChange={e => { const n = [...sections]; n[secIdx].isMainTech = e.target.checked; setSections(n); }} className="accent-purple-500" /> Main Tech</label>
</div>
<div className="pl-4 border-l-2 border-white/10 space-y-3">
{sec.items.map((item: any, itemIdx: number) => (
<div key={itemIdx} className="flex gap-2">
<input value={item.label} onChange={e => { const n = [...sections]; n[secIdx].items[itemIdx].label = e.target.value; setSections(n); }} placeholder="Label" className="w-1/3 bg-black/40 border border-white/10 rounded-lg p-2 text-xs text-white outline-none" />
<input value={item.content} onChange={e => { const n = [...sections]; n[secIdx].items[itemIdx].content = e.target.value; setSections(n); }} placeholder="Content" className="flex-1 bg-black/40 border border-white/10 rounded-lg p-2 text-xs text-white outline-none" />
<button type="button" onClick={() => { const n = [...sections]; n[secIdx].items.splice(itemIdx, 1); setSections(n); }} className="text-[#86868B] hover:text-red-400 p-2"><Trash2 size={14}/></button>
</div>
))}
<button type="button" onClick={() => { const n = [...sections]; n[secIdx].items.push({ label: "", content: "" }); setSections(n); }} className="text-xs text-purple-400 hover:text-white flex items-center gap-1 mt-2"><Plus size={12}/> Add Row</button>
</div>
</div>
))}
<button type="button" onClick={() => setSections([...sections, { title: "", isMainTech: false, items: [] }])} className="w-full border border-dashed border-white/20 text-[#86868B] hover:text-white py-3 rounded-xl flex items-center justify-center gap-2"><Plus size={16}/> Add Section</button>
</div>
<div className={activeTab === "dashboard" ? "space-y-4 animate-in fade-in" : "hidden"}>
{dashboardMetrics.map((metric, idx) => (
<div key={idx} className="bg-black/40 border border-white/10 p-4 rounded-xl relative grid grid-cols-3 gap-3">
<button type="button" onClick={() => setDashboardMetrics(dashboardMetrics.filter((_, i) => i !== idx))} className="absolute -top-2 -right-2 text-[#86868B] hover:text-red-400 bg-black rounded-full p-1"><Trash2 size={14}/></button>
<input value={metric.label} onChange={e => { const n = [...dashboardMetrics]; n[idx].label = e.target.value; setDashboardMetrics(n); }} placeholder="Label" className="w-full bg-black/40 border border-white/10 rounded-lg p-2 text-sm text-white outline-none" />
<input value={metric.value} onChange={e => { const n = [...dashboardMetrics]; n[idx].value = e.target.value; setDashboardMetrics(n); }} placeholder="Value (e.g. 5kW)" className="w-full bg-black/40 border border-[#00F0FF]/30 rounded-lg p-2 text-sm text-[#00F0FF] outline-none" />
<input value={metric.subtext} onChange={e => { const n = [...dashboardMetrics]; n[idx].subtext = e.target.value; setDashboardMetrics(n); }} placeholder="Subtext" className="w-full bg-black/40 border border-white/10 rounded-lg p-2 text-sm text-[#86868B] outline-none" />
</div>
))}
<button type="button" onClick={() => setDashboardMetrics([...dashboardMetrics, { label: "", value: "", subtext: "" }])} className="w-full border border-dashed border-white/20 text-[#86868B] hover:text-white py-3 rounded-xl flex items-center justify-center gap-2"><Plus size={16}/> Add Metric</button>
</div>
<div className="bg-gradient-to-r from-purple-500/10 to-transparent border border-purple-500/20 p-4 rounded-xl flex items-center justify-between mt-10">
<div className="flex items-center gap-3"><div className="p-2 bg-purple-500/20 rounded-lg text-purple-400"><Sparkles size={18} /></div><div><p className="text-sm text-white font-medium">Flux AI JSON Translation</p><p className="text-[10px] text-[#86868B] uppercase tracking-widest mt-0.5">Translates Markdown & Complex Data Arrays</p></div></div>
<label className="relative inline-flex items-center cursor-pointer"><input type="checkbox" name="autoTranslate" defaultChecked className="sr-only peer" /><div className="w-11 h-6 bg-black/50 border border-white/10 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-purple-500"></div></label>
</div>
</form>
<div className="p-6 border-t border-white/10 bg-[#111] rounded-b-[2rem] flex justify-end gap-3 shrink-0">
<button type="button" onClick={() => setEditingApp(null)} className="px-5 py-3 text-sm font-medium text-[#86868B] hover:text-white">Cancel</button>
<button onClick={() => (document.getElementById("edit-app-form") as HTMLFormElement)?.requestSubmit()} disabled={isSubmitting} className="flex items-center gap-2 bg-purple-500 text-white px-8 py-3 rounded-xl text-sm font-semibold hover:bg-purple-400 disabled:opacity-50 transition-colors shadow-[0_0_15px_rgba(168,85,247,0.3)]">{isSubmitting ? <><Loader2 size={18} className="animate-spin" /> Processing AI...</> : "Deploy Changes"}</button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,113 @@
"use server";
import { prisma } from "@/lib/prisma";
import bcrypt from "bcryptjs";
// 1. MÉTRICAS DEL SISTEMA (RAM y Uptime)
export async function getSystemMetrics() {
try {
await prisma.$queryRaw`SELECT 1`; // Ping a Postgres
const mem = process.memoryUsage();
return {
success: true,
dbStatus: "Connected - Optimal",
uptime: process.uptime(),
memory: {
used: Math.round(mem.heapUsed / 1024 / 1024),
total: Math.round(mem.heapTotal / 1024 / 1024),
}
};
} catch (error) {
return {
success: false,
dbStatus: "Disconnected",
uptime: process.uptime(),
memory: { used: 0, total: 0 }
};
}
}
// 2. EXPORTAR BASE DE DATOS (SNAPSHOT)
export async function exportDatabase() {
try {
const data = {
adminUsers: await prisma.adminUser.findMany(),
globalNodes: await prisma.globalNode.findMany(),
applications: await prisma.application.findMany(),
timelineEvents: await prisma.timelineEvent.findMany(),
newsArticles: await prisma.newsArticle.findMany(),
heritageSections: await prisma.heritageSection.findMany(),
spareParts: await prisma.sparePart.findMany(),
operationsSignals: await prisma.operationsSignal.findMany(),
notificationRoutes: await prisma.notificationRoute.findMany(),
pageContents: await prisma.pageContent.findMany(),
};
// Retornamos el JSON como string
return { success: true, data: JSON.stringify(data) };
} catch (error) {
console.error("Export Error:", error);
return { error: "Failed to generate database snapshot." };
}
}
// 3. RESTAURAR BASE DE DATOS (DANGER ZONE)
export async function restoreDatabase(formData: FormData) {
const username = formData.get("username") as string;
const password = formData.get("password") as string;
const confirm = formData.get("confirm") as string;
const jsonString = formData.get("jsonString") as string;
if (confirm !== "CONFIRM-RESTORE") return { error: "Security phrase is incorrect." };
if (!username || !password || !jsonString) return { error: "Missing required fields." };
try {
// A. Validar Identidad del Administrador
const admin = await prisma.adminUser.findUnique({ where: { username: username.toLowerCase().trim() } });
if (!admin) return { error: "Invalid credentials or unauthorized access." };
const isValid = await bcrypt.compare(password, admin.passwordHash);
if (!isValid) return { error: "Invalid credentials or unauthorized access." };
// B. Parsear el JSON y revivir las Fechas (Prisma necesita objetos Date, no strings)
const dateRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
const data = JSON.parse(jsonString, (key, value) => {
if (typeof value === 'string' && dateRegex.test(value)) return new Date(value);
return value;
});
if (!data.adminUsers || !data.applications) return { error: "Invalid backup file structure." };
// C. Ejecutar Restauración en Transacción (Todo o Nada)
await prisma.$transaction(async (tx: any) => {
// Borrado Total
await tx.adminUser.deleteMany();
await tx.globalNode.deleteMany();
await tx.application.deleteMany();
await tx.timelineEvent.deleteMany();
await tx.newsArticle.deleteMany();
await tx.heritageSection.deleteMany();
await tx.sparePart.deleteMany();
await tx.operationsSignal.deleteMany();
await tx.notificationRoute.deleteMany();
await tx.pageContent.deleteMany();
// Sembrado de Datos
if (data.adminUsers.length) await tx.adminUser.createMany({ data: data.adminUsers });
if (data.globalNodes.length) await tx.globalNode.createMany({ data: data.globalNodes });
if (data.applications.length) await tx.application.createMany({ data: data.applications });
if (data.timelineEvents.length) await tx.timelineEvent.createMany({ data: data.timelineEvents });
if (data.newsArticles.length) await tx.newsArticle.createMany({ data: data.newsArticles });
if (data.heritageSections.length) await tx.heritageSection.createMany({ data: data.heritageSections });
if (data.spareParts.length) await tx.sparePart.createMany({ data: data.spareParts });
if (data.operationsSignals.length) await tx.operationsSignal.createMany({ data: data.operationsSignals });
if (data.notificationRoutes.length) await tx.notificationRoute.createMany({ data: data.notificationRoutes });
if (data.pageContents.length) await tx.pageContent.createMany({ data: data.pageContents });
});
return { success: true };
} catch (error) {
console.error("Restore Error:", error);
return { error: "Database restore failed. The backup file might be corrupted." };
}
}
@@ -0,0 +1,219 @@
"use client";
import { useState, useEffect, useRef } from "react";
import Link from "next/link";
import {
ArrowLeft, Server, Activity, Database, HardDrive,
DownloadCloud, ShieldAlert, UploadCloud, Loader2, CheckCircle2
} from "lucide-react";
import { getSystemMetrics, exportDatabase, restoreDatabase } from "./actions";
export default function SystemHealth() {
const [metrics, setMetrics] = useState<any>(null);
const [isExporting, setIsExporting] = useState(false);
// Restore State
const [isRestoring, setIsRestoring] = useState(false);
const [restoreError, setRestoreError] = useState("");
const [restoreSuccess, setRestoreSuccess] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const fetchMetrics = async () => {
const res = await getSystemMetrics();
if (res.success) setMetrics(res);
};
useEffect(() => {
fetchMetrics();
// Actualizar métricas cada 10 segundos
const interval = setInterval(fetchMetrics, 10000);
return () => clearInterval(interval);
}, []);
const formatUptime = (seconds: number) => {
const d = Math.floor(seconds / (3600 * 24));
const h = Math.floor(seconds % (3600 * 24) / 3600);
const m = Math.floor(seconds % 3600 / 60);
return `${d}d ${h}h ${m}m`;
};
const handleExport = async () => {
setIsExporting(true);
const res = await exportDatabase();
if (res.success && res.data) {
// Crear un Blob y forzar la descarga del JSON
const blob = new Blob([res.data], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `flux-db-snapshot-${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} else {
alert(res.error || "Export failed.");
}
setIsExporting(false);
};
const handleRestore = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!selectedFile) return setRestoreError("Please select a JSON backup file.");
setIsRestoring(true);
setRestoreError("");
try {
const reader = new FileReader();
reader.onload = async (event) => {
const jsonString = event.target?.result as string;
const formData = new FormData(e.currentTarget as HTMLFormElement);
formData.append("jsonString", jsonString);
const res = await restoreDatabase(formData);
if (res.error) {
setRestoreError(res.error);
} else {
setRestoreSuccess(true);
// Recargar para forzar la re-lectura de la BD
setTimeout(() => window.location.href = "/hq-command/dashboard", 3000);
}
setIsRestoring(false);
};
reader.readAsText(selectedFile);
} catch (error) {
setRestoreError("Failed to read the file.");
setIsRestoring(false);
}
};
return (
<div className="min-h-screen p-6 md:p-12 max-w-5xl mx-auto">
{/* HEADER */}
<div className="mb-10">
<Link href="/hq-command/dashboard" className="inline-flex items-center gap-2 text-sm text-[#86868B] hover:text-[#00F0FF] transition-colors mb-6 group">
<ArrowLeft size={16} className="group-hover:-translate-x-1 transition-transform" /> Back to Command Center
</Link>
<div>
<h1 className="text-3xl font-light text-white flex items-center gap-3">
<Server className="text-blue-400" /> System Health & Vault
</h1>
<p className="text-[#86868B] mt-2">Server telemetry, data snapshots, and disaster recovery protocols.</p>
</div>
</div>
{/* TELEMETRÍA (MÉTRICAS) */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-10">
<div className="bg-[#111] border border-white/5 p-6 rounded-3xl relative overflow-hidden">
<div className="absolute -right-4 -bottom-4 opacity-5"><Activity size={100} /></div>
<span className="text-[10px] uppercase tracking-widest text-[#86868B] block mb-2">Node.js Uptime</span>
<p className="text-2xl font-mono text-white">
{metrics ? formatUptime(metrics.uptime) : "Loading..."}
</p>
</div>
<div className="bg-[#111] border border-white/5 p-6 rounded-3xl relative overflow-hidden">
<div className="absolute -right-4 -bottom-4 opacity-5"><HardDrive size={100} /></div>
<span className="text-[10px] uppercase tracking-widest text-[#86868B] block mb-2">Heap Memory Usage</span>
<p className="text-2xl font-mono text-white">
{metrics ? `${metrics.memory.used} MB ` : "0 MB "}
<span className="text-sm text-[#86868B]">/ {metrics ? metrics.memory.total : 0} MB</span>
</p>
</div>
<div className="bg-[#111] border border-white/5 p-6 rounded-3xl relative overflow-hidden">
<div className="absolute -right-4 -bottom-4 opacity-5"><Database size={100} /></div>
<span className="text-[10px] uppercase tracking-widest text-[#86868B] block mb-2">Postgres Connection</span>
<div className="flex items-center gap-2">
<span className="relative flex h-3 w-3">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-3 w-3 bg-emerald-500"></span>
</span>
<p className="text-lg font-medium text-emerald-400">
{metrics ? metrics.dbStatus : "Connecting..."}
</p>
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{/* ZONA VERDE: EXPORTAR (BACKUP) */}
<div className="bg-[#111] border border-white/5 rounded-3xl p-8 flex flex-col">
<div className="flex items-center gap-3 text-emerald-400 mb-4">
<DownloadCloud size={24} />
<h3 className="text-xl font-medium">Data Snapshot</h3>
</div>
<p className="text-sm text-[#86868B] leading-relaxed mb-8 flex-1">
Download a complete JSON snapshot of your PostgreSQL database. This file contains all configurations, users, and content needed to perfectly clone or restore your environment via Docker.
</p>
<button
onClick={handleExport}
disabled={isExporting}
className="w-full bg-white text-black py-4 rounded-xl text-sm font-bold flex items-center justify-center gap-2 hover:bg-gray-200 transition-colors disabled:opacity-50"
>
{isExporting ? <><Loader2 size={18} className="animate-spin" /> Compiling Data...</> : "Download Secure Backup"}
</button>
</div>
{/* ZONA ROJA: RESTAURAR (DANGER ZONE) */}
<div className="bg-rose-500/5 border border-rose-500/20 rounded-3xl p-8 relative overflow-hidden">
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-transparent via-rose-500 to-transparent"></div>
{restoreSuccess ? (
<div className="flex flex-col items-center justify-center h-full text-center animate-in fade-in zoom-in duration-500">
<CheckCircle2 size={48} className="text-emerald-400 mb-4" />
<h3 className="text-2xl font-light text-white mb-2">Restoration Complete</h3>
<p className="text-[#86868B]">The database has been successfully overwritten. Rebooting session...</p>
</div>
) : (
<>
<div className="flex items-center gap-3 text-rose-500 mb-4">
<ShieldAlert size={24} />
<h3 className="text-xl font-medium">Disaster Recovery</h3>
</div>
<p className="text-xs text-rose-400/80 leading-relaxed mb-6">
<strong>WARNING:</strong> Uploading a snapshot will instantaneously erase all current database records and overwrite them. This process is irreversible.
</p>
<form onSubmit={handleRestore} className="space-y-4">
<div className="bg-black/40 border border-white/5 p-3 rounded-xl flex items-center justify-between">
<span className="text-xs text-[#86868B] truncate max-w-[200px]">
{selectedFile ? selectedFile.name : "No backup selected"}
</span>
<button type="button" onClick={() => fileInputRef.current?.click()} className="text-xs bg-white/10 hover:bg-white/20 text-white px-3 py-1.5 rounded-lg transition-colors">
<UploadCloud size={14} className="inline mr-1" /> Select JSON
</button>
<input type="file" accept=".json" ref={fileInputRef} onChange={e => setSelectedFile(e.target.files?.[0] || null)} className="hidden" />
</div>
<div className="grid grid-cols-2 gap-3">
<input required name="username" placeholder="Admin Username" className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-white text-sm outline-none focus:border-rose-500" />
<input required name="password" type="password" placeholder="Admin Password" className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-white text-sm outline-none focus:border-rose-500" />
</div>
<input required name="confirm" placeholder="Type CONFIRM-RESTORE" className="w-full bg-black/60 border border-rose-500/30 rounded-xl px-4 py-3 text-rose-400 font-mono text-sm text-center outline-none focus:border-rose-500" />
{restoreError && <div className="p-3 bg-rose-500/10 border border-rose-500/20 rounded-lg text-rose-400 text-xs text-center">{restoreError}</div>}
<button
type="submit"
disabled={isRestoring || !selectedFile}
className="w-full bg-rose-600 text-white py-4 rounded-xl text-sm font-bold flex items-center justify-center gap-2 hover:bg-rose-500 transition-colors disabled:opacity-50"
>
{isRestoring ? <><Loader2 size={18} className="animate-spin" /> Overwriting Database...</> : "Execute Destructive Restore"}
</button>
</form>
</>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,95 @@
"use server";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
// 🔥 IMPORTAMOS EL TRADUCTOR DE IA
import { translateContentForCMS } from "@/lib/aiTranslator";
export async function getHeritageSections() {
try {
const sections = await prisma.heritageSection.findMany({
orderBy: { order: 'asc' }
});
return { success: true, sections };
} catch (error) {
return { error: "Failed to fetch heritage data." };
}
}
export async function createHeritageSection(formData: FormData) {
try {
const type = formData.get("type") as string;
const title = formData.get("title") as string;
const content = formData.get("content") as string;
const mediaUrl = formData.get("mediaUrl") as string;
const order = parseInt(formData.get("order") as string) || 0;
// 🔥 Capturamos el switch de la IA
const autoTranslate = formData.get("autoTranslate") === "on";
let translationsJson = "{}";
if (autoTranslate && (title || content)) {
const aiResult = await translateContentForCMS({
title: title || "",
content: content || ""
});
if (aiResult) translationsJson = JSON.stringify(aiResult);
}
await prisma.heritageSection.create({
data: { type, title, content, mediaUrl, order, translationsJson }
});
revalidatePath("/hq-command/dashboard/heritage");
revalidatePath("/[locale]/heritage", "layout");
return { success: true };
} catch (error) {
return { error: "Failed to add section to heritage." };
}
}
export async function updateHeritageSection(formData: FormData) {
try {
const id = formData.get("id") as string;
const type = formData.get("type") as string;
const title = formData.get("title") as string;
const content = formData.get("content") as string;
const mediaUrl = formData.get("mediaUrl") as string;
const order = parseInt(formData.get("order") as string) || 0;
const autoTranslate = formData.get("autoTranslate") === "on";
let updateData: any = { type, title, content, mediaUrl, order };
// 🔥 LA MAGIA DE LA IA EN LA EDICIÓN 🔥
if (autoTranslate && (title || content)) {
const aiResult = await translateContentForCMS({
title: title || "",
content: content || ""
});
if (aiResult) updateData.translationsJson = JSON.stringify(aiResult);
}
await prisma.heritageSection.update({
where: { id },
data: updateData
});
revalidatePath("/hq-command/dashboard/heritage");
revalidatePath("/[locale]/heritage", "layout");
return { success: true };
} catch (error) {
return { error: "Failed to update section." };
}
}
export async function deleteHeritageSection(id: string) {
try {
await prisma.heritageSection.delete({ where: { id } });
revalidatePath("/hq-command/dashboard/heritage");
revalidatePath("/[locale]/heritage", "layout");
return { success: true };
} catch (error) {
return { error: "Failed to delete section." };
}
}
@@ -0,0 +1,210 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
// 🔥 Agregamos Sparkles
import { ArrowLeft, BookOpen, Plus, Trash2, Loader2, X, Image as ImageIcon, FileText, Video, Edit3, Sparkles } from "lucide-react";
import { getHeritageSections, createHeritageSection, updateHeritageSection, deleteHeritageSection } from "./actions";
export default function HeritageManager() {
const [sections, setSections] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [editingSec, setEditingSec] = useState<any | null>(null);
const [sectionType, setSectionType] = useState("text");
const fetchSections = async () => {
setIsLoading(true);
const res = await getHeritageSections();
if (res.success && res.sections) setSections(res.sections);
setIsLoading(false);
};
useEffect(() => { fetchSections(); }, []);
const openCreateModal = () => {
setEditingSec(null);
setSectionType("text");
setIsModalOpen(true);
};
const openEditModal = (sec: any) => {
setEditingSec(sec);
setSectionType(sec.type);
setIsModalOpen(true);
};
const handleSave = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsSubmitting(true);
const formData = new FormData(e.currentTarget);
if (editingSec) {
await updateHeritageSection(formData);
} else {
await createHeritageSection(formData);
}
setIsModalOpen(false);
fetchSections();
setIsSubmitting(false);
};
const handleDelete = async (id: string) => {
if (confirm("Remove this section from the Heritage page?")) {
await deleteHeritageSection(id); fetchSections();
}
};
return (
<div className="min-h-screen p-6 md:p-12 max-w-7xl mx-auto">
<div className="mb-10">
<Link href="/hq-command/dashboard" className="inline-flex items-center gap-2 text-sm text-[#86868B] hover:text-white transition-colors mb-6 group">
<ArrowLeft size={16} className="group-hover:-translate-x-1 transition-transform" /> Back to Command Center
</Link>
<div className="flex flex-col md:flex-row justify-between items-start md:items-end gap-6">
<div>
<h1 className="text-3xl font-light text-white flex items-center gap-3">
<BookOpen className="text-white" /> The FLUX Heritage
</h1>
<p className="text-[#86868B] mt-2">Build Patrizio's deep story page block by block (Text, Images, Video).</p>
</div>
<button onClick={openCreateModal} className="flex items-center gap-2 bg-white text-black px-5 py-2.5 rounded-xl font-medium hover:bg-gray-200 transition-all">
<Plus size={18} /> Add Content Block
</button>
</div>
</div>
<div className="space-y-4">
{isLoading ? (
<div className="py-12 text-center text-[#86868B]"><Loader2 className="animate-spin mx-auto mb-2" size={24} /> Loading...</div>
) : sections.length === 0 ? (
<div className="p-12 text-center border border-white/10 rounded-3xl bg-black/20 text-[#86868B]">The Heritage page is currently empty. Add the first text block.</div>
) : (
sections.map((sec) => (
<div key={sec.id} className="bg-[#111] border border-white/5 p-6 rounded-2xl flex items-start justify-between group hover:bg-white/[0.02] transition-colors shadow-lg">
<div className="flex gap-4 w-full">
<div className="mt-1 text-[#86868B] shrink-0">
{sec.type === 'text' ? <FileText size={20}/> : sec.type === 'image' ? <ImageIcon size={20}/> : <Video size={20}/>}
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="text-[10px] uppercase tracking-widest text-white/50 border border-white/10 px-2 py-0.5 rounded bg-black/40">Order: {sec.order}</span>
<span className="text-sm font-medium text-white">{sec.title || "Untitled Block"}</span>
</div>
{sec.content && <p className="text-xs text-[#86868B] max-w-2xl line-clamp-2 mt-2 leading-relaxed">{sec.content}</p>}
{sec.mediaUrl && (
<p className="text-xs text-[#00F0FF] mt-2 font-mono">
{sec.type === 'video' ? `/heritage/videos/${sec.mediaUrl}` : `/heritage/${sec.mediaUrl}`}
</p>
)}
</div>
</div>
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-all shrink-0 ml-4">
<button onClick={() => openEditModal(sec)} className="text-[#86868B] hover:text-white p-2 bg-white/5 rounded-lg"><Edit3 size={16}/></button>
<button onClick={() => handleDelete(sec.id)} className="text-[#86868B] hover:text-red-400 p-2 bg-red-500/10 rounded-lg"><Trash2 size={16}/></button>
</div>
</div>
))
)}
</div>
{isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm">
<div className="bg-[#111] border border-white/10 w-full max-w-xl rounded-[2rem] p-0 relative shadow-2xl flex flex-col max-h-[90vh]">
<div className="p-6 md:p-8 border-b border-white/10 relative shrink-0">
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-transparent via-white to-transparent"></div>
<button onClick={() => setIsModalOpen(false)} className="absolute top-6 right-6 text-[#86868B] hover:text-white transition-colors"><X size={20} /></button>
<h3 className="text-2xl font-light text-white">{editingSec ? "Edit Story Block" : "Add Story Block"}</h3>
</div>
{/* 🔥 LE DAMOS UN ID AL FORMULARIO 🔥 */}
<form id="heritage-form" onSubmit={handleSave} className="flex-1 overflow-y-auto p-6 md:p-8 space-y-5 [scrollbar-width:none]">
<input type="hidden" name="id" value={editingSec?.id || ""} />
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5">Block Type</label>
<select name="type" value={sectionType} onChange={(e) => setSectionType(e.target.value)} className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-white text-sm focus:border-white outline-none">
<option value="text">📝 Text (Markdown)</option>
<option value="image">🖼 Large Image</option>
<option value="video"> Local Video (.mp4)</option>
</select>
</div>
<div>
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5">Display Order (1, 2...)</label>
<input name="order" type="number" required defaultValue={editingSec?.order || 1} className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-white text-sm focus:border-white outline-none" />
</div>
</div>
<div>
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5">Title (Optional)</label>
<input name="title" type="text" defaultValue={editingSec?.title} placeholder="e.g., The Early Days in Italy" className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-white text-sm focus:border-white outline-none" />
</div>
{sectionType === "text" && (
<div>
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5 flex justify-between items-center">
<span>Story Content (Markdown)</span>
</label>
<textarea name="content" defaultValue={editingSec?.content} required rows={10} className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-4 text-white text-sm focus:border-white outline-none resize-none leading-relaxed mb-3" placeholder="Write the history here..." />
<div className="bg-white/5 border border-white/10 rounded-xl p-4 text-xs text-[#86868B] font-mono leading-relaxed">
<p className="text-white font-semibold uppercase tracking-widest mb-2 text-[10px]">Markdown Cheat Sheet:</p>
<p><strong># Title 1</strong> &nbsp;|&nbsp; <strong>## Title 2</strong> &nbsp;|&nbsp; <strong>**Bold**</strong></p>
<p className="mt-1"><strong>- List Item</strong> &nbsp;|&nbsp; <strong>&gt; Blockquote</strong></p>
<p className="mt-1 text-[#00F0FF]"><strong>![Image Description](/heritage/image-name.jpg)</strong></p>
<div className="mt-3 pt-3 border-t border-white/10">
<p className="mb-1"><strong>Tables (Last column highlights automatically):</strong></p>
<p>| Year | Milestone | Innovation |</p>
<p>|---|---|---|</p>
<p>| 1980 | First Patent | Radiofrequency |</p>
</div>
</div>
</div>
)}
{sectionType !== "text" && (
<div>
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5">
{sectionType === "image" ? "Image Filename (in /public/heritage/)" : "Video Filename (in /public/heritage/videos/)"}
</label>
<input name="mediaUrl" type="text" defaultValue={editingSec?.mediaUrl} required placeholder={sectionType === "image" ? "e.g., patrizio-1980.jpg" : "e.g., history-1980.mp4"} className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-[#00F0FF] font-mono text-sm focus:border-white outline-none" />
</div>
)}
{/* 🔥 SWITCH DE LA IA AÑADIDO 🔥 */}
<div className="bg-gradient-to-r from-white/10 to-transparent border border-white/20 p-4 rounded-xl flex items-center justify-between mt-6">
<div className="flex items-center gap-3">
<div className="p-2 bg-white/20 rounded-lg text-white"><Sparkles size={18} /></div>
<div>
<p className="text-sm text-white font-medium">Flux AI Auto-Translate</p>
<p className="text-[10px] text-[#86868B] uppercase tracking-widest mt-0.5">Translates Markdown to IT, VEC, ES, DE</p>
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input type="checkbox" name="autoTranslate" defaultChecked className="sr-only peer" />
<div className="w-11 h-6 bg-black/50 border border-white/10 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-white"></div>
</label>
</div>
</form>
<div className="p-6 border-t border-white/10 bg-[#111] rounded-b-[2rem] flex justify-end gap-3 shrink-0">
<button type="button" onClick={() => setIsModalOpen(false)} className="px-5 py-3 text-sm font-medium text-[#86868B] hover:text-white transition-colors">Cancel</button>
{/* 🔥 APUNTAMOS AL FORMULARIO 🔥 */}
<button onClick={() => (document.getElementById("heritage-form") as HTMLFormElement)?.requestSubmit()} disabled={isSubmitting} className="w-full md:w-auto bg-white text-black py-3 px-8 rounded-xl text-sm font-semibold hover:bg-gray-200 transition-colors disabled:opacity-50 flex justify-center items-center gap-2">
{isSubmitting ? <Loader2 className="animate-spin mx-auto" size={18}/> : (editingSec ? "Save Changes" : "Add to Heritage Page")}
</button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,188 @@
// /src/app/hq-command/dashboard/inbox/actions.ts
"use server";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
import fs from "fs";
import path from "path";
import { sendEmail } from "@/lib/mailer";
// 1. GET ALL SIGNALS
export async function getSignals() {
try {
const signals = await prisma.operationsSignal.findMany({
orderBy: { createdAt: "desc" },
});
return { success: true, signals };
} catch (error) {
return { error: "Error loading Operations Inbox." };
}
}
// 2. GET ALL CLIENTS (CRM) 🔥 NUEVO
export async function getClients() {
try {
const clients = await prisma.clientUser.findMany({
include: {
signals: {
orderBy: { createdAt: "desc" }
}
},
orderBy: { createdAt: "desc" }
});
return { success: true, clients };
} catch (error) {
return { error: "Error loading Client Directory." };
}
}
// 3. APPROVE ACCESS REQUEST 🔥 NUEVO
export async function approveAccessRequest(signalId: string) {
try {
const signal = await prisma.operationsSignal.findUnique({ where: { id: signalId } });
if (!signal || !signal.clientId) return { error: "Ticket or Client not found." };
// Aprobar al cliente
await prisma.clientUser.update({
where: { id: signal.clientId },
data: { isApproved: true }
});
// Enviar correo de Bienvenida
const appUrl = process.env.NEXT_PUBLIC_APP_URL || "https://rf-flux.com";
const html = `
<div style="background-color: #F5F5F7; padding: 40px 20px; width: 100%; box-sizing: border-box; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;">
<div style="max-width: 600px; margin: 0 auto; background-color: #ffffff; border-radius: 16px; overflow: hidden; box-shadow: 0 8px 32px rgba(0,0,0,0.06);">
<div style="padding: 40px 32px 32px 32px; text-align: center; border-bottom: 1px solid #E5E5EA;">
<img src="${appUrl}/logoEmail.png" alt="FLUX SRL" style="height: 50px; width: auto; object-fit: contain; margin-bottom: 24px;" />
<p style="color: #10B981; font-size: 11px; letter-spacing: 2px; text-transform: uppercase; margin: 0; font-weight: 700;">Account Approved</p>
<h1 style="margin: 12px 0 16px 0; font-size: 26px; font-weight: 300; color: #1D1D1F; letter-spacing: -0.5px;">Welcome to FLUX B2B</h1>
</div>
<div style="padding: 32px; color: #1D1D1F; text-align: center;">
<p style="font-size: 16px; line-height: 1.6; color: #1D1D1F; margin-bottom: 24px;">Hello <strong>${signal.clientName}</strong>,</p>
<p style="font-size: 15px; line-height: 1.6; color: #86868B; margin-bottom: 32px;">Your corporate account for <strong>${signal.clientCompany}</strong> has been successfully verified and approved by our engineering team.</p>
<p style="font-size: 15px; line-height: 1.6; color: #86868B; margin-bottom: 32px;">You now have full access to our exclusive Component Matrix, technical datasheets, and direct engineering support.</p>
<a href="${appUrl}/parts" style="display: inline-block; padding: 14px 28px; background-color: #0066CC; color: #ffffff; text-decoration: none; border-radius: 12px; font-weight: 600; font-size: 14px;">Access B2B Portal</a>
</div>
<div style="background-color: #FAFAFA; border-top: 1px solid #E5E5EA; padding: 24px; font-size: 12px; color: #86868B; text-align: center;">
<p style="margin: 0;">Automated by <strong>FLUX Operations Command</strong></p>
</div>
</div>
</div>
`;
const emailResult = await sendEmail({
to: [signal.clientEmail],
subject: "Your FLUX B2B Account is Approved",
html,
});
// Marcar ticket como resuelto
await prisma.operationsSignal.update({
where: { id: signalId },
data: {
status: "RESOLVED",
emailSentTo: emailResult.sentTo.join(", "),
emailSentAt: emailResult.sentAt,
emailError: emailResult.error,
}
});
revalidatePath("/hq-command/dashboard/inbox");
return { success: true, emailSent: emailResult.success };
} catch (error: any) {
return { error: "Failed to approve client." };
}
}
// 4. UPDATE STATUS
export async function updateSignalStatus(id: string, newStatus: string) {
try {
await prisma.operationsSignal.update({ where: { id }, data: { status: newStatus } });
revalidatePath("/hq-command/dashboard/inbox");
return { success: true };
} catch (error) { return { error: "Failed to update status." }; }
}
// 5. RESOLVE & CLEAN FILES
export async function resolveAndCleanSignal(id: string) {
try {
const signal = await prisma.operationsSignal.findUnique({ where: { id } });
if (!signal) return { error: "Ticket not found." };
let filesCleaned = false;
if (signal.attachedFiles && signal.attachedFiles !== "[]") {
const files = JSON.parse(signal.attachedFiles);
if (files.length > 0) {
const firstFileUrl = files[0];
const parts = firstFileUrl.split('/');
if (parts.length >= 3 && parts[1] === 'operations-inbox') {
const folderName = parts[2];
const dirPath = path.join(process.cwd(), "public", "operations-inbox", folderName);
if (path.resolve(dirPath).startsWith(path.resolve(process.cwd(), "public", "operations-inbox"))) {
if (fs.existsSync(dirPath)) { fs.rmSync(dirPath, { recursive: true, force: true }); filesCleaned = true; }
}
}
}
}
await prisma.operationsSignal.update({ where: { id }, data: { status: "RESOLVED", attachedFiles: "[]" } });
revalidatePath("/hq-command/dashboard/inbox");
return { success: true, filesCleaned };
} catch (error) { return { error: "Failed to clean files." }; }
}
// 6. GET/UPDATE NOTIFICATION ROUTES
export async function getNotificationRoutes() {
try { const routes = await prisma.notificationRoute.findMany(); return { success: true, routes }; }
catch (error) { return { error: "Failed to load routing configurations." }; }
}
export async function updateNotificationRoute(routeType: string, emails: string) {
try { await prisma.notificationRoute.upsert({ where: { routeType }, update: { emails }, create: { routeType, emails, isActive: true } }); return { success: true }; }
catch (error) { return { error: "Failed to save email routing." }; }
}
// 7. DELETE RESOLVED SIGNAL
export async function deleteSignal(id: string) {
try { await prisma.operationsSignal.delete({ where: { id } }); revalidatePath("/hq-command/dashboard/inbox"); return { success: true }; }
catch (error) { return { error: "Failed to delete ticket." }; }
}
// 8. RESEND SIGNAL EMAIL
export async function resendSignalEmail(id: string) {
try {
const signal = await prisma.operationsSignal.findUnique({ where: { id } });
if (!signal) return { error: "Ticket not found." };
const route = await prisma.notificationRoute.findUnique({ where: { routeType: signal.type } });
let targetEmails = ["info@fluxsrl.com"];
if (route && route.isActive && route.emails) targetEmails = route.emails.split(",").map((e: any) => e.trim()).filter(Boolean);
// ... Lógica de HTML omitida por brevedad (Usa el mismo generador de HTML de antes)
const appUrl = process.env.NEXT_PUBLIC_APP_URL || "https://rf-flux.com";
const html = `... Tu HTML actual de reenvío ...`; // Usa el html que tenías en este script
const emailResult = await sendEmail({ to: targetEmails, subject: `[REMINDER: ${signal.type}] Signal from ${signal.clientCompany}`, html, replyTo: signal.clientEmail });
await prisma.operationsSignal.update({ where: { id }, data: { emailSentTo: emailResult.sentTo.join(", "), emailSentAt: emailResult.sentAt, emailError: emailResult.error } });
revalidatePath("/hq-command/dashboard/inbox");
return { success: true, emailSent: emailResult.success, error: emailResult.error };
} catch (error: any) { return { error: error.message || "Failed to resend email." }; }
}
// 9. DELETE CLIENT (CRM)
export async function deleteClient(id: string) {
try {
// Primero: Desvinculamos sus señales para no perder el historial contable/órdenes
await prisma.operationsSignal.updateMany({
where: { clientId: id },
data: { clientId: null }
});
// Segundo: Borramos al cliente de la base de datos
await prisma.clientUser.delete({
where: { id }
});
revalidatePath("/hq-command/dashboard/inbox");
return { success: true };
} catch (error) {
return { error: "Failed to delete client." };
}
}
+310
View File
@@ -0,0 +1,310 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import {
ArrowLeft, Radar, Inbox, CheckCircle2, Clock, AlertCircle,
Package, Video, Sparkles, Building2, Mail, Phone, ShieldAlert,
Trash2, Loader2, Settings, X, MailCheck, MailX, Users, Key
} from "lucide-react";
import { getSignals, updateSignalStatus, resolveAndCleanSignal, getNotificationRoutes, updateNotificationRoute, deleteSignal, resendSignalEmail, getClients, approveAccessRequest, deleteClient } from "./actions";
export default function OperationsInbox() {
const [viewMode, setViewMode] = useState<"SIGNALS" | "CLIENTS">("SIGNALS");
const [signals, setSignals] = useState<any[]>([]);
const [clients, setClients] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [activeSignal, setActiveSignal] = useState<any | null>(null);
const [isProcessing, setIsProcessing] = useState(false);
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [routes, setRoutes] = useState({ ORDER: "sales@fluxsrl.com", DIAGNOSTIC: "support@fluxsrl.com", CONSULTATION: "engineering@fluxsrl.com" });
const [filterType, setFilterType] = useState<string>("ALL");
const [filterStatus, setFilterStatus] = useState<string>("ALL");
const fetchInitialData = async () => {
setIsLoading(true);
const [resSignals, resRoutes, resClients] = await Promise.all([getSignals(), getNotificationRoutes(), getClients()]);
if (resSignals.success) setSignals(resSignals.signals);
if (resClients.success) setClients(resClients.clients);
if (resRoutes.success && resRoutes.routes) {
const routeMap: any = { ...routes };
resRoutes.routes.forEach((r: any) => { routeMap[r.routeType] = r.emails; });
setRoutes(routeMap);
}
setIsLoading(false);
};
useEffect(() => { fetchInitialData(); }, []);
const handleSaveRoute = async (type: string, emails: string) => { await updateNotificationRoute(type, emails); alert(`Routing for ${type} updated.`); };
const handleStatusChange = async (id: string, status: string) => { setIsProcessing(true); await updateSignalStatus(id, status); await fetchInitialData(); if (activeSignal?.id === id) setActiveSignal((p: any) => ({ ...p, status })); setIsProcessing(false); };
const handleResolveAndClean = async (id: string) => { if (!confirm("Permanently delete attached files and mark as resolved?")) return; setIsProcessing(true); const res = await resolveAndCleanSignal(id); if (res.success) { await fetchInitialData(); setActiveSignal(null); } setIsProcessing(false); };
const handleDelete = async (id: string) => { if (!confirm("Permanently delete this ticket?")) return; setIsProcessing(true); await deleteSignal(id); await fetchInitialData(); setActiveSignal(null); setIsProcessing(false); };
const handleResendEmail = async (id: string) => { setIsProcessing(true); const res = await resendSignalEmail(id); if (res.success) { alert("Email reminder sent."); await fetchInitialData(); if (activeSignal?.id === id) setActiveSignal((p: any) => ({ ...p, emailSentAt: new Date(), emailError: null })); } else { alert("Failed: " + res.error); } setIsProcessing(false); };
// APROBAR CLIENTE
const handleApproveClient = async (signalId: string) => {
if (!confirm("Approve this client for B2B portal access? They will receive an email.")) return;
setIsProcessing(true);
const res = await approveAccessRequest(signalId);
if (res.success) {
alert("Client approved and email sent!");
await fetchInitialData();
setActiveSignal(null);
} else {
alert("Failed: " + res.error);
}
setIsProcessing(false);
};
// BORRAR CLIENTE
const handleDeleteClient = async (clientId: string) => {
if (!confirm("Permanently delete this client? Their past tickets will be kept but unlinked from their account.")) return;
setIsProcessing(true);
const res = await deleteClient(clientId);
if (res.success) {
await fetchInitialData();
} else {
alert("Failed: " + res.error);
}
setIsProcessing(false);
};
const parseJSON = (str: string | null) => { try { return JSON.parse(str || "[]"); } catch { return []; } };
const getTypeStyle = (type: string) => {
switch (type) { case "ORDER": return "bg-amber-500/10 text-amber-400 border-amber-500/20"; case "DIAGNOSTIC": return "bg-rose-500/10 text-rose-400 border-rose-500/20"; case "CONSULTATION": return "bg-[#00F0FF]/10 text-[#00F0FF] border-[#00F0FF]/20"; case "ACCESS_REQUEST": return "bg-emerald-500/10 text-emerald-400 border-emerald-500/20"; default: return "bg-white/5 text-white/70"; }
};
const getStatusIcon = (status: string) => {
switch (status) { case "PENDING": return <AlertCircle size={14} className="text-rose-400" />; case "REVIEWING": return <Clock size={14} className="text-amber-400" />; case "RESOLVED": return <CheckCircle2 size={14} className="text-emerald-400" />; default: return null; }
};
const filtered = signals.filter(s => {
if (filterType !== "ALL" && s.type !== filterType) return false;
if (filterStatus !== "ALL" && s.status !== filterStatus) return false;
return true;
});
const pendingCount = signals.filter(s => s.status === "PENDING").length;
return (
<div className="min-h-screen p-6 md:p-12 max-w-[1400px] mx-auto flex flex-col h-screen">
<div className="mb-8 shrink-0 flex justify-between items-end">
<div>
<Link href="/hq-command/dashboard" className="inline-flex items-center gap-2 text-sm text-[#86868B] hover:text-rose-400 transition-colors mb-6 group"><ArrowLeft size={16} className="group-hover:-translate-x-1 transition-transform" /> Back to Command Center</Link>
<div className="flex items-center gap-4 mb-2">
<h1 className="text-3xl font-light text-white flex items-center gap-3"><Radar className="text-rose-500" /> Hub</h1>
<button onClick={() => setIsSettingsOpen(true)} className="p-2 bg-white/5 hover:bg-white/10 text-[#86868B] hover:text-white rounded-xl transition-all" title="Email Routing"><Settings size={20} /></button>
</div>
<div className="flex gap-2 p-1 bg-white/5 rounded-xl w-fit mt-4">
<button onClick={() => setViewMode("SIGNALS")} className={`px-5 py-2 rounded-lg text-sm font-medium transition-colors flex items-center gap-2 ${viewMode === "SIGNALS" ? "bg-white/10 text-white" : "text-[#86868B] hover:text-white"}`}><Inbox size={16}/> Operations Inbox</button>
<button onClick={() => { setViewMode("CLIENTS"); setActiveSignal(null); }} className={`px-5 py-2 rounded-lg text-sm font-medium transition-colors flex items-center gap-2 ${viewMode === "CLIENTS" ? "bg-white/10 text-white" : "text-[#86868B] hover:text-white"}`}><Users size={16}/> Client Directory</button>
</div>
</div>
</div>
<div className="flex flex-col md:flex-row gap-6 flex-1 min-h-0 overflow-hidden">
{viewMode === "SIGNALS" && (
<>
<div className="w-full md:w-1/3 flex flex-col bg-[#111] border border-white/5 rounded-3xl overflow-hidden shadow-2xl h-full">
<div className="p-5 border-b border-white/10 bg-black/40 shrink-0">
<div className="flex justify-between items-center mb-3">
<h2 className="text-sm font-semibold uppercase tracking-widest text-[#86868B] flex items-center gap-2"><Inbox size={16} /> Signals</h2>
<span className="bg-rose-500 text-white text-xs font-bold px-2 py-0.5 rounded-full">{pendingCount}</span>
</div>
<div className="flex gap-1.5 flex-wrap">
{["ALL", "ORDER", "DIAGNOSTIC", "CONSULTATION", "ACCESS_REQUEST"].map(t => (
<button key={t} onClick={() => setFilterType(t)} className={`text-[9px] uppercase tracking-widest font-bold px-2 py-1 rounded-lg border transition-all ${filterType === t ? "bg-white/10 border-white/20 text-white" : "border-transparent text-[#86868B] hover:text-white"}`}>{t === "ALL" ? "All Types" : t === "ACCESS_REQUEST" ? "B2B ACCESS" : t}</button>
))}
</div>
<div className="flex gap-1.5 mt-2">
{["ALL", "PENDING", "REVIEWING", "RESOLVED"].map(s => (
<button key={s} onClick={() => setFilterStatus(s)} className={`text-[9px] uppercase tracking-widest font-bold px-2 py-1 rounded-lg border transition-all ${filterStatus === s ? "bg-white/10 border-white/20 text-white" : "border-transparent text-[#86868B] hover:text-white"}`}>{s === "ALL" ? "All Status" : s}</button>
))}
</div>
</div>
<div className="flex-1 overflow-y-auto [scrollbar-width:none] p-3 space-y-2">
{isLoading ? <div className="p-10 text-center text-[#86868B]"><Loader2 size={24} className="animate-spin mx-auto mb-2" /></div>
: filtered.length === 0 ? <div className="p-10 text-center text-[#86868B] text-sm">No signals match filters.</div>
: filtered.map(signal => (
<button key={signal.id} onClick={() => setActiveSignal(signal)} className={`w-full text-left p-4 rounded-2xl border transition-all ${activeSignal?.id === signal.id ? 'bg-white/10 border-white/20' : 'bg-black/20 border-transparent hover:bg-white/5'}`}>
<div className="flex justify-between items-start mb-2">
<span className={`text-[9px] uppercase tracking-widest font-bold px-2 py-0.5 rounded border ${getTypeStyle(signal.type)}`}>{signal.type === "ACCESS_REQUEST" ? "B2B ACCESS" : signal.type}</span>
<div className="flex items-center gap-1.5">
{/* 🔥 FIX APLICADO: Título movido al <span> para evitar el error de Typescript con Lucide */}
{signal.emailSentAt ? (
<span title={`Email sent to ${signal.emailSentTo}`} className="flex items-center">
<MailCheck size={11} className="text-emerald-400" />
</span>
) : signal.emailError ? (
<span title={signal.emailError} className="flex items-center">
<MailX size={11} className="text-red-400" />
</span>
) : null}
{getStatusIcon(signal.status)}
</div>
</div>
<h3 className="text-sm font-medium text-white truncate">{signal.clientName}</h3>
<p className="text-xs text-[#86868B] truncate">{signal.clientCompany}</p>
<p className="text-[10px] text-[#86868B]/60 mt-2 font-mono">{signal.ticketId} · {new Date(signal.createdAt).toLocaleString()}</p>
</button>
))}
</div>
</div>
<div className="w-full md:w-2/3 bg-[#111] border border-white/5 rounded-3xl overflow-hidden shadow-2xl flex flex-col h-full relative">
{!activeSignal ? (
<div className="flex-1 flex flex-col items-center justify-center text-[#86868B]"><Radar size={48} className="opacity-20 mb-4" /><p>Select a signal to view details.</p></div>
) : (
<>
<div className="p-6 border-b border-white/10 bg-black/40 shrink-0 flex justify-between items-center">
<div>
<h2 className="text-xl font-light text-white mb-1">Ticket: {activeSignal.ticketId}</h2>
<div className="flex gap-2 items-center flex-wrap">
<span className={`text-[10px] uppercase tracking-widest font-bold px-2 py-0.5 rounded border ${getTypeStyle(activeSignal.type)}`}>{activeSignal.type === "ACCESS_REQUEST" ? "B2B PORTAL ACCESS" : activeSignal.type}</span>
<span className="text-xs text-[#86868B] flex items-center gap-1">{getStatusIcon(activeSignal.status)} {activeSignal.status}</span>
{activeSignal.emailSentAt ? (
<span className="text-[9px] text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 px-2 py-0.5 rounded flex items-center gap-1"><MailCheck size={10} /> Sent to {activeSignal.emailSentTo}</span>
) : activeSignal.emailError ? (
<span className="text-[9px] text-red-400 bg-red-500/10 border border-red-500/20 px-2 py-0.5 rounded flex items-center gap-1" title={activeSignal.emailError}><MailX size={10} /> Email failed</span>
) : (
<span className="text-[9px] text-[#86868B] bg-white/5 px-2 py-0.5 rounded">No email record</span>
)}
</div>
</div>
<div className="flex gap-2">
{activeSignal.type === "ACCESS_REQUEST" && activeSignal.status === "PENDING" && (
<button disabled={isProcessing} onClick={() => handleApproveClient(activeSignal.id)} className="px-5 py-2 bg-emerald-500 text-black hover:bg-emerald-400 text-xs font-bold uppercase tracking-wider rounded-xl flex items-center gap-2"><Key size={14}/> Approve & Notify</button>
)}
{activeSignal.type !== "ACCESS_REQUEST" && <button disabled={isProcessing} onClick={() => handleResendEmail(activeSignal.id)} className="px-4 py-2 bg-[#00F0FF]/10 text-[#00F0FF] hover:bg-[#00F0FF]/20 text-xs font-semibold uppercase tracking-wider rounded-xl flex items-center gap-2"><Mail size={14} /> Resend</button>}
{activeSignal.status === "PENDING" && <button disabled={isProcessing} onClick={() => handleStatusChange(activeSignal.id, "REVIEWING")} className="px-4 py-2 bg-amber-500/10 text-amber-500 hover:bg-amber-500/20 text-xs font-semibold uppercase tracking-wider rounded-xl">Reviewing</button>}
{activeSignal.status !== "RESOLVED" && <button disabled={isProcessing} onClick={() => handleResolveAndClean(activeSignal.id)} className="px-4 py-2 bg-emerald-500/10 text-emerald-400 hover:bg-emerald-500/20 border border-emerald-500/20 text-xs font-semibold uppercase tracking-wider rounded-xl flex items-center gap-2"><ShieldAlert size={14} /> Resolve</button>}
{activeSignal.status === "RESOLVED" && <button disabled={isProcessing} onClick={() => handleDelete(activeSignal.id)} className="px-4 py-2 bg-red-500/10 text-red-400 hover:bg-red-500/20 border border-red-500/20 text-xs font-semibold uppercase tracking-wider rounded-xl flex items-center gap-2"><Trash2 size={14} /> Delete</button>}
</div>
</div>
<div className="flex-1 overflow-y-auto p-6 space-y-8 [scrollbar-width:none]">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 bg-black/20 p-5 rounded-2xl border border-white/5">
<div className="min-w-0"><span className="text-[10px] uppercase text-[#86868B] block mb-1">Client</span><span className="text-sm text-white font-medium block truncate" title={activeSignal.clientName}>{activeSignal.clientName}</span></div>
<div className="min-w-0"><span className="text-[10px] uppercase text-[#86868B] block mb-1 flex items-center gap-1"><Building2 size={10}/> Company</span><span className="text-sm text-white block truncate" title={activeSignal.clientCompany}>{activeSignal.clientCompany}</span></div>
<div className="min-w-0"><span className="text-[10px] uppercase text-[#86868B] block mb-1 flex items-center gap-1"><Mail size={10}/> Email</span><a href={`mailto:${activeSignal.clientEmail}`} title={activeSignal.clientEmail} className="text-sm text-[#00F0FF] hover:underline block truncate">{activeSignal.clientEmail}</a></div>
<div className="min-w-0"><span className="text-[10px] uppercase text-[#86868B] block mb-1 flex items-center gap-1"><Phone size={10}/> Phone</span><span className="text-sm text-white font-mono block truncate" title={activeSignal.clientPhone || "N/A"}>{activeSignal.clientPhone || "N/A"}</span></div>
</div>
{activeSignal.message && <div><h3 className="text-[10px] font-bold uppercase tracking-widest text-[#86868B] mb-3 border-b border-white/10 pb-2">Notes</h3><p className="text-sm text-white/90 bg-white/5 p-4 rounded-xl border border-white/5 whitespace-pre-wrap">{activeSignal.message}</p></div>}
{activeSignal.aiAnalysis && <div><h3 className="text-[10px] font-bold uppercase tracking-widest text-[#00F0FF] mb-3 border-b border-[#00F0FF]/20 pb-2 flex items-center gap-2"><Sparkles size={12}/> AI Context</h3><div className="text-sm text-[#00F0FF]/90 bg-[#00F0FF]/5 p-4 rounded-xl border border-[#00F0FF]/20 whitespace-pre-wrap font-mono leading-relaxed">{activeSignal.aiAnalysis}</div></div>}
{activeSignal.type === "CONSULTATION" && activeSignal.aiAnalysis && (() => {
const lines = (activeSignal.aiAnalysis || "").split("\n");
const ext = (tag: string) => { const l = lines.find((l: string) => l.startsWith(`[${tag}]`)); return l ? l.replace(`[${tag}] `, "").trim() : null; };
const extBlock = (tag: string) => { const si = lines.findIndex((l: string) => l.startsWith(`[${tag}]`)); if (si === -1) return []; const r: string[] = []; for (let i = si+1; i < lines.length; i++) { const l = lines[i].trim(); if (l.startsWith("[") || l === "") break; r.push(l.replace(/^[•→]\s*/, "")); } return r; };
const industry = ext("INDUSTRY"), savings = ext("ESTIMATED SAVINGS"), volume = ext("PRODUCTION VOLUME"), preferred = ext("PREFERRED CONTACT"), timeframe = ext("TIMEFRAME");
const insights = extBlock("AI DISCUSSION POINTS"), topics = extBlock("SUGGESTED ENGINEERING TOPICS");
return (
<div className="space-y-6">
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{industry && <div className="bg-[#00F0FF]/5 border border-[#00F0FF]/10 p-3 rounded-xl"><span className="text-[9px] uppercase text-[#00F0FF] block mb-1">Industry</span><span className="text-sm text-white font-medium">{industry}</span></div>}
{savings && <div className="bg-emerald-500/5 border border-emerald-500/10 p-3 rounded-xl"><span className="text-[9px] uppercase text-emerald-400 block mb-1">Savings</span><span className="text-sm text-emerald-400 font-medium">{savings}</span></div>}
{volume && <div className="bg-white/5 border border-white/5 p-3 rounded-xl"><span className="text-[9px] uppercase text-[#86868B] block mb-1">Volume</span><span className="text-sm text-white">{volume}</span></div>}
{preferred && <div className="bg-white/5 border border-white/5 p-3 rounded-xl"><span className="text-[9px] uppercase text-[#86868B] block mb-1">Contact · {timeframe || ""}</span><span className="text-sm text-white">{preferred}</span></div>}
</div>
{insights.length > 0 && <div><h3 className="text-[10px] font-bold uppercase tracking-widest text-[#00F0FF] mb-3 border-b border-[#00F0FF]/20 pb-2 flex items-center gap-2"><Sparkles size={12}/> Discussion Points</h3><div className="space-y-2">{insights.map((item: string, i: number) => (<div key={i} className="flex items-start gap-2 bg-[#00F0FF]/5 px-4 py-2.5 rounded-xl border border-[#00F0FF]/10"><div className="w-1.5 h-1.5 rounded-full bg-[#00F0FF] mt-1.5 shrink-0" /><span className="text-sm text-white/90">{item}</span></div>))}</div></div>}
{topics.length > 0 && <div><h3 className="text-[10px] font-bold uppercase tracking-widest text-amber-500 mb-3 border-b border-amber-500/20 pb-2 flex items-center gap-2"><Package size={12}/> Prep Topics</h3><div className="space-y-2">{topics.map((t: string, i: number) => (<div key={i} className="flex items-start gap-2 bg-amber-500/5 px-4 py-2.5 rounded-xl border border-amber-500/10"><span className="text-amber-400 font-mono text-xs mt-0.5 shrink-0">{i+1}.</span><span className="text-sm text-white/90">{t}</span></div>))}</div></div>}
</div>
);
})()}
{activeSignal.type === "ORDER" && (
<div><h3 className="text-[10px] font-bold uppercase tracking-widest text-amber-500 mb-3 border-b border-amber-500/20 pb-2 flex items-center gap-2"><Package size={12}/> Requested Components</h3><div className="bg-black/40 border border-white/5 rounded-2xl overflow-hidden">{parseJSON(activeSignal.cartPayload).map((item: any, idx: number) => (<div key={idx} className="flex justify-between items-center p-4 border-b border-white/5 last:border-0"><div className="min-w-0 pr-4"><p className="text-sm font-medium text-white truncate">{item.title}</p><p className="text-[10px] font-mono text-amber-400 mt-1">SKU: {item.sku}</p></div><div className="text-right shrink-0"><span className="text-xs text-[#86868B] uppercase tracking-widest">QTY</span><p className="text-lg font-mono text-white">{item.quantity}</p></div></div>))}</div></div>
)}
{parseJSON(activeSignal.attachedFiles).length > 0 && (
<div><h3 className="text-[10px] font-bold uppercase tracking-widest text-rose-400 mb-3 border-b border-rose-500/20 pb-2 flex items-center gap-2"><Video size={12}/> Diagnostic Files</h3><div className="grid grid-cols-1 md:grid-cols-2 gap-4">{parseJSON(activeSignal.attachedFiles).map((fileUrl: string, idx: number) => { const isVideo = fileUrl.endsWith(".mp4") || fileUrl.endsWith(".mov"); return (<div key={idx} className="bg-black/60 border border-white/10 rounded-xl overflow-hidden group relative">{isVideo ? <video src={fileUrl} controls className="w-full h-48 object-cover bg-black" /> : <a href={fileUrl} target="_blank" rel="noreferrer" className="block w-full h-48"><img src={fileUrl} alt="Diagnostic" className="w-full h-full object-cover" /></a>}<div className="absolute top-2 right-2 bg-black/80 text-[9px] text-white px-2 py-1 rounded font-mono border border-white/20">{isVideo ? "VIDEO" : "IMAGE"}</div></div>); })}</div></div>
)}
</div>
</>
)}
</div>
</>
)}
{viewMode === "CLIENTS" && (
<div className="w-full bg-[#111] border border-white/5 rounded-3xl overflow-hidden shadow-2xl flex flex-col h-full">
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="border-b border-white/5 text-[10px] uppercase tracking-widest text-[#86868B] bg-black/40">
<th className="p-6 font-semibold">Client / Company</th>
<th className="p-6 font-semibold">Email</th>
<th className="p-6 font-semibold">Status</th>
<th className="p-6 font-semibold text-center">Purchase History</th>
<th className="p-6 font-semibold text-right">Actions</th>
</tr>
</thead>
<tbody>
{isLoading ? <tr><td colSpan={5} className="p-12 text-center text-[#86868B]"><Loader2 className="animate-spin mx-auto mb-2" /></td></tr> :
clients.length === 0 ? <tr><td colSpan={5} className="p-12 text-center text-[#86868B]">No clients registered yet.</td></tr> :
clients.map(client => {
const orders = client.signals.filter((s:any) => s.type === "ORDER");
return (
<tr key={client.id} className="border-b border-white/5 hover:bg-white/[0.02] transition-colors">
<td className="p-6"><p className="text-sm font-medium text-white">{client.fullName}</p><p className="text-xs text-[#86868B] mt-1 flex items-center gap-1"><Building2 size={12}/> {client.companyName}</p></td>
<td className="p-6 text-sm text-[#00F0FF]">{client.email}</td>
<td className="p-6">
{client.isApproved ? <span className="px-2 py-1 bg-emerald-500/10 text-emerald-400 text-[10px] uppercase tracking-widest font-bold rounded">Approved</span> : <span className="px-2 py-1 bg-rose-500/10 text-rose-400 text-[10px] uppercase tracking-widest font-bold rounded">Pending</span>}
</td>
<td className="p-6 text-center">
<div className="text-xs text-[#86868B]">
<span className="font-bold text-white">{orders.length}</span> Orders placed
</div>
</td>
<td className="p-6 text-right">
<button
disabled={isProcessing}
onClick={() => handleDeleteClient(client.id)}
className="p-2 text-[#86868B] hover:text-red-400 hover:bg-red-400/10 rounded-lg transition-colors"
title="Delete Client"
>
<Trash2 size={16} />
</button>
</td>
</tr>
)})}
</tbody>
</table>
</div>
</div>
)}
</div>
{isSettingsOpen && (
<div className="fixed inset-0 z-[100] bg-black/80 backdrop-blur-sm flex items-center justify-center p-4">
<div className="bg-[#111] border border-white/10 w-full max-w-lg rounded-[2rem] p-8 relative shadow-2xl">
<button onClick={() => setIsSettingsOpen(false)} className="absolute top-6 right-6 text-[#86868B] hover:text-white"><X size={20} /></button>
<div className="flex items-center gap-3 mb-2"><Mail className="text-rose-500" size={24} /><h3 className="text-2xl font-light text-white">Email Routing</h3></div>
<p className="text-sm text-[#86868B] mb-6">Configure which team members receive alerts. Separate emails with commas.</p>
<div className="space-y-5">
{[{ id: "ORDER", label: "Spare Part Orders", color: "text-amber-500" }, { id: "DIAGNOSTIC", label: "Tech Support / Diagnostics", color: "text-rose-500" }, { id: "CONSULTATION", label: "Engineering Consultations", color: "text-[#00F0FF]" }].map(route => (
<div key={route.id} className="bg-black/40 border border-white/5 rounded-xl p-4">
<label className={`block text-[10px] uppercase tracking-widest font-bold mb-2 ${route.color}`}>{route.label}</label>
<div className="flex gap-2">
<input type="text" value={(routes as any)[route.id]} onChange={e => setRoutes({...routes, [route.id]: e.target.value})} className="flex-1 bg-transparent border-b border-white/20 text-white text-sm pb-1 outline-none focus:border-white font-mono" placeholder="e.g. sales@flux.com, ceo@flux.com" />
<button onClick={() => handleSaveRoute(route.id, (routes as any)[route.id])} className="text-xs bg-white/10 hover:bg-white/20 text-white px-3 py-1 rounded-lg font-medium">Save</button>
</div>
</div>
))}
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,147 @@
//src/app/hq-command/dashboard/network/actions.ts
"use server";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
// 🔥 Importamos el motor de traducción robusto
import { translateContentForCMS } from "@/lib/aiTranslator";
// 1. OBTENER TODOS LOS NODOS
export async function getNodes() {
try {
const nodes = await prisma.globalNode.findMany({
orderBy: { createdAt: "desc" }
});
return { success: true, nodes };
} catch (error) {
return { error: "Failed to fetch map nodes." };
}
}
// 2. CREAR UN NUEVO NODO
export async function createNode(formData: FormData) {
try {
const title = formData.get("title") as string;
const location = formData.get("location") as string;
const nodeType = formData.get("nodeType") as string;
const application = formData.get("application") as string;
const stats = formData.get("stats") as string;
const lat = parseFloat(formData.get("lat") as string);
const lon = parseFloat(formData.get("lon") as string);
if (!title || !location || !application || !stats || isNaN(lat) || isNaN(lon) || !nodeType) {
return { error: "All fields are required and coordinates must be valid numbers." };
}
await prisma.globalNode.create({
data: {
title, location, nodeType, application, stats, lat, lon, isActive: true
}
});
revalidatePath("/hq-command/dashboard/network");
revalidatePath("/[locale]", "layout");
return { success: true };
} catch (error) {
return { error: "Failed to create map node." };
}
}
// 3. ELIMINAR UN NODO
export async function deleteNode(id: string) {
try {
await prisma.globalNode.delete({ where: { id } });
revalidatePath("/hq-command/dashboard/network");
revalidatePath("/[locale]", "layout");
return { success: true };
} catch (error) {
return { error: "Failed to delete node." };
}
}
// 4. CAMBIAR ESTADO
export async function toggleNodeStatus(id: string, currentStatus: boolean) {
try {
await prisma.globalNode.update({
where: { id },
data: { isActive: !currentStatus }
});
revalidatePath("/hq-command/dashboard/network");
revalidatePath("/[locale]", "layout");
return { success: true };
} catch (error) {
return { error: "Failed to update node status." };
}
}
// 5. ACTUALIZAR GEO-BLOG CON IA
export async function updateNodeCaseStudy(formData: FormData) {
try {
const id = formData.get("id") as string;
const projectOverview = formData.get("projectOverview") as string;
const energySavings = formData.get("energySavings") as string;
const mediaFileName = formData.get("mediaFileName") as string;
const eventDateStr = formData.get("eventDate") as string;
const galleryJson = formData.get("galleryJson") as string;
const videosJson = formData.get("videosJson") as string;
const rendersJson = formData.get("rendersJson") as string;
const specificDatasheetJson = formData.get("specificDatasheetJson") as string;
const model3DPath = formData.get("model3DPath") as string;
const model3DDimsJson = formData.get("model3DDimsJson") as string;
// 🔥 Atrapamos si Patrizio activó la IA 🔥
const autoTranslate = formData.get("autoTranslate") === "on";
const eventDate = eventDateStr ? new Date(eventDateStr) : null;
let updateData: any = {
projectOverview, energySavings, mediaFileName, eventDate,
galleryJson: galleryJson || "[]",
videosJson: videosJson || "[]",
rendersJson: rendersJson || "[]",
specificDatasheetJson: specificDatasheetJson || "[]",
model3DPath,
model3DDimsJson: model3DDimsJson || null
};
// 🔥 LA MAGIA DE LA IA 🔥
if (autoTranslate) {
// Solo le pasamos a OpenAI los textos que el usuario realmente va a leer (Markdown y Métrica)
const aiResult = await translateContentForCMS({
projectOverview: projectOverview || "",
energySavings: energySavings || ""
});
if (aiResult) {
updateData.translationsJson = JSON.stringify(aiResult);
}
}
await prisma.globalNode.update({ where: { id }, data: updateData });
revalidatePath("/hq-command/dashboard/network");
revalidatePath("/[locale]", "layout");
return { success: true };
} catch (error) {
console.error("Error updating chronicle:", error);
return { error: "Failed to update case study data." };
}
}
// 6. BÚSQUEDA SATELITAL
export async function searchSatelliteLocation(query: string) {
try {
const res = await fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}&featuretype=city&limit=5`, {
headers: { 'User-Agent': 'FluxCMS-Architecture/1.0 (davidherran@dreamhousestudios.co)' }
});
if (!res.ok) throw new Error("Satellite rejected request");
const data = await res.json();
return { success: true, data };
} catch (error) {
console.error("Satellite search failed:", error);
return { error: "Satellite connection failed" };
}
}
@@ -0,0 +1,720 @@
//src/app/hq-command/dashboard/network/page.tsx
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import Link from "next/link";
import {
ArrowLeft, Globe, Plus, Trash2, Loader2, X, MapPin, Eye, EyeOff, Search, BookOpen, Calendar,
Image as ImageIcon, Video, Box, Cpu, FileText, Sparkles,
Bold, Italic, Heading1, Heading2, Heading3, List, ListOrdered, Quote, Table, Minus,
RotateCcw, RotateCw, Maximize2, Minimize2, ChevronDown,
FolderOpen, Upload, FolderPlus, ChevronRight, File, ArrowUpFromLine,
Grid3X3, LayoutList, Copy, Check
} from "lucide-react";
import { getNodes, createNode, deleteNode, toggleNodeStatus, updateNodeCaseStudy, searchSatelliteLocation } from "./actions";
import { getApplications } from "../applications/actions";
// ─────────────────────────────────────────────────────────────────────────────
// ASSET MANAGER — Reusable file browser for /public/cases/{slug}/
// ─────────────────────────────────────────────────────────────────────────────
interface AssetItem {
name: string; type: "file" | "folder"; mediaType?: string; extension?: string;
path: string; publicUrl?: string; size?: string; childCount?: number;
}
interface AssetManagerProps {
slug: string;
scope?: string;
isOpen: boolean;
onClose: () => void;
onSelect: (item: { name: string; publicUrl: string; mediaType: string; path: string }) => void;
accentColor?: string;
initialPath?: string;
}
function AssetManager({ slug, scope = "cases", isOpen, onClose, onSelect, accentColor = "#00F0FF", initialPath = "" }: AssetManagerProps) {
const [currentPath, setCurrentPath] = useState(initialPath);
const [items, setItems] = useState<AssetItem[]>([]);
const [breadcrumbs, setBreadcrumbs] = useState<{name: string; path: string}[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState("");
const fileInputRef = useRef<HTMLInputElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [showNewFolder, setShowNewFolder] = useState(false);
const [newFolderName, setNewFolderName] = useState("");
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
const [searchQuery, setSearchQuery] = useState("");
const [copiedPath, setCopiedPath] = useState<string | null>(null);
const fetchAssets = useCallback(async (dirPath: string = "") => {
setIsLoading(true); setError(null);
try {
const params = new URLSearchParams({ scope, slug, path: dirPath });
const res = await fetch(`/api/assets?${params}`);
const data = await res.json();
if (data.success) { setItems(data.items); setBreadcrumbs(data.breadcrumbs); setCurrentPath(dirPath); }
else setError(data.error || "Failed to load");
} catch { setError("Connection error — check /api/assets/route.ts"); }
setIsLoading(false);
}, [scope, slug]);
useEffect(() => { if (isOpen) { fetchAssets(initialPath || currentPath); setSearchQuery(""); } }, [isOpen]); // eslint-disable-line
const uploadFile = async (file: File) => {
setIsUploading(true); setUploadProgress(`Uploading ${file.name}...`);
try {
const fd = new FormData();
fd.append("scope", scope); fd.append("slug", slug); fd.append("path", currentPath); fd.append("file", file);
const res = await fetch("/api/assets", { method: "POST", body: fd });
const data = await res.json();
if (data.success) { setUploadProgress("✓ " + data.file.name); await fetchAssets(currentPath); setTimeout(() => setUploadProgress(""), 2000); }
else { setUploadProgress("✗ " + data.error); setTimeout(() => setUploadProgress(""), 4000); }
} catch { setUploadProgress("✗ Failed"); setTimeout(() => setUploadProgress(""), 3000); }
setIsUploading(false);
};
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => { if (e.target.files) Array.from(e.target.files).forEach(uploadFile); e.target.value = ""; };
const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); setIsDragging(true); };
const handleDragLeave = (e: React.DragEvent) => { e.preventDefault(); setIsDragging(false); };
const handleDrop = (e: React.DragEvent) => { e.preventDefault(); setIsDragging(false); if (e.dataTransfer.files.length > 0) Array.from(e.dataTransfer.files).forEach(uploadFile); };
const createFolder = async () => {
if (!newFolderName.trim()) return;
try {
const res = await fetch("/api/assets", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ scope, slug, folderName: newFolderName, parentPath: currentPath }) });
const data = await res.json();
if (data.success) { setShowNewFolder(false); setNewFolderName(""); await fetchAssets(currentPath); }
else alert(data.error);
} catch { alert("Connection error"); }
};
const deleteFile = async (filePath: string, fileName: string) => {
if (!confirm('Delete "' + fileName + '"?')) return;
try {
const res = await fetch("/api/assets", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ scope, slug, filePath }) });
const data = await res.json(); if (data.success) await fetchAssets(currentPath); else alert(data.error);
} catch { alert("Failed to delete"); }
};
const handleSelect = (item: AssetItem, e?: React.MouseEvent) => {
if (e) { e.preventDefault(); e.stopPropagation(); }
if (item.type === "folder") { fetchAssets(item.path); return; }
onSelect({ name: item.name, publicUrl: item.publicUrl || "/" + scope + "/" + slug + "/" + item.path, mediaType: item.mediaType || "unknown", path: item.path });
onClose();
};
const copyPath = (item: AssetItem) => {
navigator.clipboard.writeText(item.publicUrl || "/" + scope + "/" + slug + "/" + item.path);
setCopiedPath(item.path); setTimeout(() => setCopiedPath(null), 1500);
};
const filtered = searchQuery ? items.filter(i => i.name.toLowerCase().includes(searchQuery.toLowerCase())) : items;
const typeBadge = (mt?: string) => {
const s: Record<string, string> = { image: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20", video: "bg-blue-500/10 text-blue-400 border-blue-500/20", model: "bg-purple-500/10 text-purple-400 border-purple-500/20" };
return s[mt || ""] || "bg-white/5 text-[#86868B] border-white/10";
};
const renderThumb = (item: AssetItem) => {
if (item.type === "folder") return <div className="w-full h-full flex items-center justify-center bg-white/[0.02]"><FolderOpen size={28} style={{ color: accentColor, opacity: 0.7 }} /></div>;
if (item.mediaType === "image" && item.publicUrl) return <img src={item.publicUrl} alt={item.name} className="w-full h-full object-cover" loading="lazy" />;
if (item.mediaType === "video") return <div className="w-full h-full flex items-center justify-center bg-blue-500/5"><Video size={24} className="text-blue-400/60" /></div>;
if (item.mediaType === "model") return <div className="w-full h-full flex items-center justify-center bg-purple-500/5"><Box size={24} className="text-purple-400/60" /></div>;
return <div className="w-full h-full flex items-center justify-center bg-white/[0.02]"><File size={24} className="text-[#86868B]/50" /></div>;
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[70] flex items-center justify-center p-4 bg-black/85 backdrop-blur-md" onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>
<div className="bg-[#0D0D0D] border border-white/10 w-full max-w-4xl rounded-[2rem] shadow-2xl flex flex-col max-h-[85vh] relative overflow-hidden" onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop} onClick={(e) => e.stopPropagation()}>
{isDragging && (
<div className="absolute inset-0 z-50 border-2 border-dashed rounded-[2rem] flex items-center justify-center backdrop-blur-sm" style={{ borderColor: accentColor + "80", background: accentColor + "15" }}>
<div className="text-center">
<ArrowUpFromLine size={48} style={{ color: accentColor }} className="mx-auto mb-3 animate-bounce" />
<p style={{ color: accentColor }} className="font-medium text-lg">Drop files to upload</p>
<p className="text-[#86868B] text-sm mt-1">to /{scope}/{slug}/{currentPath || "root"}</p>
</div>
</div>
)}
<div className="px-6 py-5 border-b border-white/10 shrink-0">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="p-2 rounded-xl" style={{ background: accentColor + "20", color: accentColor }}><FolderOpen size={20} /></div>
<div><h3 className="text-lg font-medium text-white">Asset Manager</h3><p className="text-[10px] text-[#86868B] uppercase tracking-widest font-mono">/public/{scope}/{slug}/</p></div>
</div>
<button onClick={onClose} className="text-[#86868B] hover:text-white p-2 hover:bg-white/5 rounded-xl transition-all"><X size={20} /></button>
</div>
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-1 text-sm overflow-x-auto [scrollbar-width:none] flex-1 min-w-0">
{breadcrumbs.map((crumb, idx) => (
<span key={idx} className="flex items-center shrink-0">
{idx > 0 && <ChevronRight size={12} className="text-[#86868B]/50 mx-0.5" />}
<button onClick={() => fetchAssets(crumb.path)} className={`px-2 py-1 rounded-lg transition-colors text-xs ${idx === breadcrumbs.length - 1 ? "bg-white/10" : "text-[#86868B] hover:text-white hover:bg-white/5"}`} style={idx === breadcrumbs.length - 1 ? { color: accentColor } : {}}>{crumb.name}</button>
</span>
))}
</div>
<div className="flex items-center gap-2 shrink-0">
<div className="relative"><Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[#86868B]" /><input value={searchQuery} onChange={e => setSearchQuery(e.target.value)} placeholder="Filter..." className="bg-white/5 border border-white/10 rounded-lg pl-8 pr-3 py-1.5 text-xs text-white w-32 focus:w-48 transition-all outline-none" /></div>
<div className="flex bg-white/5 rounded-lg border border-white/10 overflow-hidden">
<button onClick={() => setViewMode("grid")} className={`p-1.5 transition-colors ${viewMode === "grid" ? "text-[#00F0FF] bg-white/10" : "text-[#86868B]"}`}><Grid3X3 size={14} /></button>
<button onClick={() => setViewMode("list")} className={`p-1.5 transition-colors ${viewMode === "list" ? "text-[#00F0FF] bg-white/10" : "text-[#86868B]"}`}><LayoutList size={14} /></button>
</div>
<button onClick={() => setShowNewFolder(!showNewFolder)} className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-[#86868B] hover:text-white bg-white/5 border border-white/10 rounded-lg"><FolderPlus size={13} /> Folder</button>
<button onClick={() => fileInputRef.current?.click()} disabled={isUploading} className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-black rounded-lg font-medium disabled:opacity-50" style={{ background: accentColor }}><Upload size={13} /> Upload</button>
<input ref={fileInputRef} type="file" multiple accept=".jpg,.jpeg,.png,.webp,.gif,.svg,.avif,.mp4,.webm,.mov,.glb,.gltf,.usdz,.pdf" onChange={handleFileSelect} className="hidden" />
</div>
</div>
{showNewFolder && (
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-white/5">
<FolderPlus size={14} style={{ color: accentColor }} />
<input value={newFolderName} onChange={e => setNewFolderName(e.target.value)} onKeyDown={e => { if (e.key === "Enter") createFolder(); if (e.key === "Escape") { setShowNewFolder(false); setNewFolderName(""); } }} placeholder="folder-name" autoFocus className="flex-1 bg-black/40 border border-white/10 rounded-lg px-3 py-1.5 text-sm text-white outline-none font-mono" />
<button onClick={createFolder} className="px-3 py-1.5 text-xs text-black rounded-lg font-medium" style={{ background: accentColor }}>Create</button>
<button onClick={() => { setShowNewFolder(false); setNewFolderName(""); }} className="px-3 py-1.5 text-xs text-[#86868B]">Cancel</button>
</div>
)}
{uploadProgress && <div className="mt-3 pt-3 border-t border-white/5 flex items-center gap-2 text-xs">{isUploading && <Loader2 size={12} className="animate-spin" style={{ color: accentColor }} />}<span className={uploadProgress.startsWith("✓") ? "text-emerald-400" : uploadProgress.startsWith("✗") ? "text-red-400" : ""} style={isUploading ? { color: accentColor } : {}}>{uploadProgress}</span></div>}
</div>
<div className="flex-1 overflow-y-auto p-4 [scrollbar-width:none]">
{isLoading ? <div className="flex items-center justify-center py-20"><Loader2 size={24} className="animate-spin" style={{ color: accentColor }} /></div>
: error ? <div className="flex flex-col items-center justify-center py-20 text-center"><X size={32} className="text-red-400/50 mb-3" /><p className="text-red-400/80 text-sm">{error}</p></div>
: filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-center">
<FolderOpen size={48} className="text-[#86868B]/20 mb-4" />
{searchQuery ? <p className="text-[#86868B] text-sm">No matches</p> : (
<><p className="text-[#86868B] text-sm mb-2">Empty directory</p>
<div className="flex gap-2 mt-4">{["images", "videos", "models", "renders"].map(f => (
<button key={f} onClick={async () => { await fetch("/api/assets", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ scope, slug, folderName: f, parentPath: currentPath }) }); fetchAssets(currentPath); }} className="px-3 py-2 text-xs border rounded-lg" style={{ color: accentColor, borderColor: accentColor + "30" }}>+ {f}/</button>
))}</div></>
)}
</div>
) : viewMode === "grid" ? (
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-3">
{filtered.map(item => (
<div key={item.path} className="group relative bg-[#111] border border-white/5 rounded-xl overflow-hidden hover:border-white/20 transition-all cursor-pointer" onClick={(e) => handleSelect(item, e)}>
<div className="aspect-square overflow-hidden bg-black/20">{renderThumb(item)}</div>
<div className="p-2"><p className="text-[11px] text-white truncate font-medium">{item.name}</p>
<div className="flex items-center justify-between mt-1">
{item.type === "folder" ? <span className="text-[9px] text-[#86868B]">{item.childCount} items</span> : <span className={`text-[9px] px-1.5 py-0.5 rounded border ${typeBadge(item.mediaType)}`}>{item.mediaType}</span>}
{item.size && <span className="text-[9px] text-[#86868B]">{item.size}</span>}
</div>
</div>
{item.type === "file" && <div className="absolute top-1 right-1 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity"><button onClick={e => { e.stopPropagation(); copyPath(item); }} className="p-1.5 bg-black/70 backdrop-blur-sm rounded-lg text-[#86868B] hover:text-white">{copiedPath === item.path ? <Check size={11} className="text-emerald-400" /> : <Copy size={11} />}</button><button onClick={e => { e.stopPropagation(); deleteFile(item.path, item.name); }} className="p-1.5 bg-black/70 backdrop-blur-sm rounded-lg text-[#86868B] hover:text-red-400"><Trash2 size={11} /></button></div>}
</div>
))}
</div>
) : (
<div className="space-y-1">{filtered.map(item => (
<div key={item.path} className="group flex items-center gap-3 px-3 py-2.5 rounded-xl hover:bg-white/[0.03] cursor-pointer" onClick={(e) => handleSelect(item, e)}>
<div className="w-8 h-8 rounded-lg overflow-hidden shrink-0 border border-white/5">{renderThumb(item)}</div>
<span className="text-sm text-white flex-1 truncate">{item.name}</span>
{item.type === "file" && <span className={`text-[9px] px-2 py-0.5 rounded border shrink-0 ${typeBadge(item.mediaType)}`}>{item.extension}</span>}
{item.type === "folder" && <><span className="text-[9px] text-[#86868B]">{item.childCount} items</span><ChevronRight size={14} className="text-[#86868B]/50" /></>}
{item.size && <span className="text-[10px] text-[#86868B] w-16 text-right shrink-0">{item.size}</span>}
</div>
))}</div>
)}
</div>
<div className="px-6 py-3 border-t border-white/10 flex items-center justify-between text-[10px] text-[#86868B] shrink-0 bg-black/20"><span>{filtered.length} items Click to select</span><span className="font-mono">Drag & drop supported</span></div>
</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// MARKDOWN EDITOR — Cyan-themed for Network/Cases
// ─────────────────────────────────────────────────────────────────────────────
function MarkdownEditorCyan({ name, defaultValue = "", required, rows = 10, placeholder, slug }: {
name: string; defaultValue?: string; required?: boolean; rows?: number; placeholder?: string; slug?: string;
}) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [value, setValue] = useState(defaultValue);
const [isExpanded, setIsExpanded] = useState(false);
const [showInsertMenu, setShowInsertMenu] = useState(false);
const [history, setHistory] = useState<string[]>([defaultValue]);
const [historyIndex, setHistoryIndex] = useState(0);
const insertMenuRef = useRef<HTMLDivElement>(null);
const [isAssetManagerOpen, setIsAssetManagerOpen] = useState(false);
useEffect(() => {
const h = (e: MouseEvent) => { if (insertMenuRef.current && !insertMenuRef.current.contains(e.target as Node)) setShowInsertMenu(false); };
document.addEventListener("mousedown", h); return () => document.removeEventListener("mousedown", h);
}, []);
const historyTimeout = useRef<NodeJS.Timeout | null>(null);
const pushHistory = useCallback((v: string) => {
if (historyTimeout.current) clearTimeout(historyTimeout.current);
historyTimeout.current = setTimeout(() => { setHistory(p => [...p.slice(0, historyIndex + 1), v].slice(-50)); setHistoryIndex(p => Math.min(p + 1, 49)); }, 500);
}, [historyIndex]);
const handleChange = (v: string) => { setValue(v); pushHistory(v); };
const undo = () => { if (historyIndex > 0) { const i = historyIndex - 1; setHistoryIndex(i); setValue(history[i]); } };
const redo = () => { if (historyIndex < history.length - 1) { const i = historyIndex + 1; setHistoryIndex(i); setValue(history[i]); } };
const getSelection = () => {
const ta = textareaRef.current;
if (!ta) return { start: 0, end: 0, selected: "", before: "", after: "" };
return { start: ta.selectionStart, end: ta.selectionEnd, selected: value.substring(ta.selectionStart, ta.selectionEnd), before: value.substring(0, ta.selectionStart), after: value.substring(ta.selectionEnd) };
};
const replaceSelection = (t: string, o?: number) => {
const { before, after } = getSelection();
handleChange(before + t + after);
const p = o !== undefined ? before.length + o : before.length + t.length;
setTimeout(() => { const ta = textareaRef.current; if (ta) { ta.focus(); ta.setSelectionRange(p, p); } }, 0);
};
const wrapSelection = (pre: string, suf: string) => { const { selected } = getSelection(); if (selected) replaceSelection(pre + selected + suf); else replaceSelection(pre + "text" + suf, pre.length); };
const insertAtCursor = (t: string, o?: number) => replaceSelection(t, o);
const prependLine = (pre: string) => {
const { start, selected } = getSelection();
const ls = value.lastIndexOf('\n', start - 1) + 1;
const bef = value.substring(0, ls);
const line = selected || value.substring(ls).split('\n')[0];
const aft = value.substring(ls + line.length);
handleChange(bef + pre + line + aft);
setTimeout(() => { const ta = textareaRef.current; if (ta) { ta.focus(); ta.setSelectionRange(ls + pre.length, ls + pre.length + line.length); } }, 0);
};
const handleAssetInsert = (item: { publicUrl: string; mediaType: string; name: string }) => {
let syntax = "";
switch (item.mediaType) {
case "image": syntax = "![" + item.name + "](" + item.publicUrl + ")"; break;
case "video": syntax = "[VIDEO:" + item.publicUrl + "]"; break;
case "model": syntax = "[3D:" + item.publicUrl + "]"; break;
default: syntax = "[" + item.name + "](" + item.publicUrl + ")";
}
insertAtCursor("\n" + syntax + "\n");
};
const basePath = "/cases/" + (slug || "slug");
const actions = {
bold: () => wrapSelection("**", "**"), italic: () => wrapSelection("*", "*"),
h1: () => prependLine("# "), h2: () => prependLine("## "), h3: () => prependLine("### "),
quote: () => prependLine("> "),
ul: () => insertAtCursor("\n- Item 1\n- Item 2\n- Item 3\n", 3),
ol: () => insertAtCursor("\n1. First\n2. Second\n3. Third\n", 4),
hr: () => insertAtCursor("\n---\n"),
table: () => insertAtCursor("\n| Column A | Column B | Highlight |\n|---|---|---|\n| Data 1 | Data 2 | Value |\n", 2),
image: () => insertAtCursor("\n![Image description](" + basePath + "/images/photo.jpg)\n", 3),
video: () => insertAtCursor("\n[VIDEO:" + basePath + "/videos/clip.mp4]\n", 8),
model3d: () => insertAtCursor("\n[3D:" + basePath + "/models/machine.glb]\n", 5),
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
const m = e.metaKey || e.ctrlKey;
if (m && e.key === 'b') { e.preventDefault(); actions.bold(); }
if (m && e.key === 'i') { e.preventDefault(); actions.italic(); }
if (m && e.key === 'z') { e.preventDefault(); e.shiftKey ? redo() : undo(); }
if (e.key === 'Tab') { e.preventDefault(); insertAtCursor(" "); }
};
const ToolBtn = ({ icon: Icon, label, onClick, className = "" }: { icon: any; label: string; onClick: () => void; className?: string }) => (
<button type="button" onClick={onClick} title={label} className={"p-1.5 rounded-lg text-[#86868B] hover:text-white hover:bg-white/10 transition-all active:scale-90 " + className}><Icon size={15} strokeWidth={2} /></button>
);
const Divider = () => <div className="w-px h-5 bg-white/10 mx-1" />;
return (
<div className={"flex flex-col " + (isExpanded ? "fixed inset-0 z-[60] bg-[#0A0A0A] p-6" : "")}>
<input type="hidden" name={name} value={value} />
<div className={"flex flex-wrap items-center gap-0.5 px-2 py-1.5 bg-black/60 border border-white/10 rounded-t-xl " + (isExpanded ? "border-b-0 rounded-t-2xl" : "")}>
<ToolBtn icon={Bold} label="Bold" onClick={actions.bold} />
<ToolBtn icon={Italic} label="Italic" onClick={actions.italic} />
<Divider />
<ToolBtn icon={Heading1} label="H1" onClick={actions.h1} />
<ToolBtn icon={Heading2} label="H2" onClick={actions.h2} />
<ToolBtn icon={Heading3} label="H3" onClick={actions.h3} />
<Divider />
<ToolBtn icon={List} label="Bullet" onClick={actions.ul} />
<ToolBtn icon={ListOrdered} label="Numbered" onClick={actions.ol} />
<ToolBtn icon={Quote} label="Quote" onClick={actions.quote} />
<ToolBtn icon={Table} label="Table" onClick={actions.table} />
<ToolBtn icon={Minus} label="HR" onClick={actions.hr} />
<Divider />
<div className="relative" ref={insertMenuRef}>
<button type="button" onClick={() => setShowInsertMenu(!showInsertMenu)} className="flex items-center gap-1 px-2 py-1.5 rounded-lg text-[#00F0FF] hover:bg-[#00F0FF]/10 transition-all text-[11px] font-semibold uppercase tracking-wider">
<Plus size={13} /> Insert <ChevronDown size={11} className={"transition-transform " + (showInsertMenu ? "rotate-180" : "")} />
</button>
{showInsertMenu && (
<div className="absolute top-full left-0 mt-1 w-56 bg-[#1A1A1A] border border-white/10 rounded-xl shadow-2xl z-50 overflow-hidden">
<div className="p-1">
<button type="button" onClick={() => { actions.image(); setShowInsertMenu(false); }} className="w-full flex items-center gap-3 px-3 py-2.5 text-white/80 hover:text-white hover:bg-white/5 rounded-lg text-left"><div className="p-1.5 bg-emerald-500/15 rounded-lg text-emerald-400"><ImageIcon size={14} /></div><div><p className="text-xs font-medium">Image</p><p className="text-[10px] text-[#86868B]">.jpg .png .webp</p></div></button>
<button type="button" onClick={() => { actions.video(); setShowInsertMenu(false); }} className="w-full flex items-center gap-3 px-3 py-2.5 text-white/80 hover:text-white hover:bg-white/5 rounded-lg text-left"><div className="p-1.5 bg-blue-500/15 rounded-lg text-blue-400"><Video size={14} /></div><div><p className="text-xs font-medium">Video</p><p className="text-[10px] text-[#86868B]">.mp4 local</p></div></button>
<button type="button" onClick={() => { actions.model3d(); setShowInsertMenu(false); }} className="w-full flex items-center gap-3 px-3 py-2.5 text-white/80 hover:text-white hover:bg-white/5 rounded-lg text-left"><div className="p-1.5 bg-purple-500/15 rounded-lg text-purple-400"><Box size={14} /></div><div><p className="text-xs font-medium">3D Model</p><p className="text-[10px] text-[#86868B]">.glb / .usdz</p></div></button>
<div className="border-t border-white/5 mt-1 pt-1">
<button type="button" onClick={() => { actions.table(); setShowInsertMenu(false); }} className="w-full flex items-center gap-3 px-3 py-2.5 text-white/80 hover:text-white hover:bg-white/5 rounded-lg text-left"><div className="p-1.5 bg-amber-500/15 rounded-lg text-amber-400"><Table size={14} /></div><div><p className="text-xs font-medium">Data Table</p><p className="text-[10px] text-[#86868B]">Auto-highlight</p></div></button>
</div>
</div>
</div>
)}
</div>
{slug && (<><Divider /><button type="button" onClick={() => setIsAssetManagerOpen(true)} className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-emerald-400 hover:text-emerald-300 hover:bg-emerald-500/10 transition-all text-[11px] font-semibold uppercase tracking-wider"><FolderOpen size={14} /> Assets</button></>)}
<div className="flex-1" />
<ToolBtn icon={RotateCcw} label="Undo" onClick={undo} className={historyIndex <= 0 ? "opacity-30 pointer-events-none" : ""} />
<ToolBtn icon={RotateCw} label="Redo" onClick={redo} className={historyIndex >= history.length - 1 ? "opacity-30 pointer-events-none" : ""} />
<Divider />
<ToolBtn icon={isExpanded ? Minimize2 : Maximize2} label="Fullscreen" onClick={() => setIsExpanded(!isExpanded)} />
</div>
<textarea ref={textareaRef} value={value} onChange={e => handleChange(e.target.value)} onKeyDown={handleKeyDown} required={required} rows={isExpanded ? 40 : rows} placeholder={placeholder} className={"w-full bg-black/40 border border-white/10 border-t-0 p-4 text-white font-mono text-sm focus:border-[#00F0FF] outline-none resize-none leading-relaxed " + (isExpanded ? "flex-1 rounded-b-2xl text-base leading-loose" : "rounded-b-xl")} style={{ tabSize: 2 }} />
<div className="flex items-center justify-between mt-1.5 px-1">
<div className="flex items-center gap-3 text-[10px] text-[#86868B] font-mono"><span>{value.split('\n').length} lines</span><span className="text-white/10">|</span><span>{value.length} chars</span></div>
<div className="flex items-center gap-2 text-[10px] text-[#86868B]"><span className="opacity-60">B</span><span className="opacity-60">I</span><span className="opacity-60">Tab</span></div>
</div>
{!isExpanded && (
<div className="bg-[#00F0FF]/5 border border-[#00F0FF]/20 rounded-xl p-4 text-xs text-[#86868B] font-mono leading-relaxed mt-3">
<p className="text-[#00F0FF] font-semibold uppercase tracking-widest mb-2 text-[10px]">Markdown Cheat Sheet:</p>
<p><strong># Title 1</strong> &nbsp;|&nbsp; <strong>## Title 2</strong> &nbsp;|&nbsp; <strong>**Bold**</strong></p>
<p className="mt-1"><strong>- List Item</strong> &nbsp;|&nbsp; <strong>&gt; Blockquote</strong></p>
<p className="mt-1 text-white"><strong>![Image](/cases/{slug || "slug"}/images/photo.jpg)</strong></p>
</div>
)}
{slug && <AssetManager scope="cases" slug={slug} isOpen={isAssetManagerOpen} onClose={() => setIsAssetManagerOpen(false)} onSelect={handleAssetInsert} accentColor="#00F0FF" />}
</div>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// MAIN PAGE — Network Manager (Global Network)
// ─────────────────────────────────────────────────────────────────────────────
export default function NetworkManager() {
const [nodes, setNodes] = useState<any[]>([]);
const [appsList, setAppsList] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState("");
const [createNodeType, setCreateNodeType] = useState("installation");
const [editingNode, setEditingNode] = useState<any | null>(null);
const [isSavingCaseStudy, setIsSavingCaseStudy] = useState(false);
const [activeTab, setActiveTab] = useState<"story" | "tech" | "media" | "3d">("story");
const [gallery, setGallery] = useState<string[]>([]);
const [videos, setVideos] = useState<string[]>([]);
const [renders, setRenders] = useState<string[]>([]);
const [datasheet, setDatasheet] = useState<{model?: string, specs?: any[]}>({});
const [eventDate, setEventDate] = useState("");
const [model3DDims, setModel3DDims] = useState<{w?:string,h?:string,d?:string,unit?:string,weight?:string}>({});
const [locationQuery, setLocationQuery] = useState("");
const [searchResults, setSearchResults] = useState<any[]>([]);
const [isSearchingMap, setIsSearchingMap] = useState(false);
const [showResults, setShowResults] = useState(false);
const [selectedLat, setSelectedLat] = useState("");
const [selectedLon, setSelectedLon] = useState("");
const [mediaAssetsOpen, setMediaAssetsOpen] = useState(false);
const [threeDAssetsOpen, setThreeDAssetsOpen] = useState(false);
const [mediaAssetTarget, setMediaAssetTarget] = useState<"video" | "image">("video");
const [threeDAssetTarget, setThreeDAssetTarget] = useState<"model" | "render">("model");
const [coverAssetsOpen, setCoverAssetsOpen] = useState(false);
const nodeSlug = editingNode?.title?.toLowerCase().trim().replace(/[^\w\s-]/g, '').replace(/[\s_-]+/g, '-').replace(/^-+|-+$/g, '') || "untitled";
const fetchNodesAndApps = async () => {
setIsLoading(true);
const resNodes = await getNodes(); if (resNodes.success && resNodes.nodes) setNodes(resNodes.nodes);
const resApps = await getApplications(); if (resApps.success && resApps.apps) setAppsList(resApps.apps);
setIsLoading(false);
};
useEffect(() => { fetchNodesAndApps(); }, []);
useEffect(() => {
const t = setTimeout(async () => {
if (locationQuery.length > 2 && showResults) {
setIsSearchingMap(true);
const res = await searchSatelliteLocation(locationQuery);
if (res.success && res.data) setSearchResults(res.data); else setSearchResults([]);
setIsSearchingMap(false);
} else setSearchResults([]);
}, 500);
return () => clearTimeout(t);
}, [locationQuery, showResults]);
const selectLocation = (place: any) => { setLocationQuery(place.display_name); setSelectedLat(place.lat); setSelectedLon(place.lon); setShowResults(false); };
const openGeoBlogModal = (node: any) => {
setEditingNode(node); setActiveTab("story");
try { setGallery(JSON.parse(node.galleryJson || "[]")); } catch { setGallery([]); }
try { setVideos(JSON.parse(node.videosJson || "[]")); } catch { setVideos([]); }
try { setRenders(JSON.parse(node.rendersJson || "[]")); } catch { setRenders([]); }
try { const ds = JSON.parse(node.specificDatasheetJson || "{}"); setDatasheet(Array.isArray(ds) ? {} : ds); } catch { setDatasheet({}); }
try { setModel3DDims(JSON.parse(node.model3DDimsJson || "{}")); } catch { setModel3DDims({}); }
if (node.eventDate) setEventDate(new Date(node.eventDate).toISOString().split('T')[0]); else setEventDate("");
};
const handleCreate = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); setIsSubmitting(true); setError("");
const formData = new FormData(e.currentTarget);
formData.set("lat", selectedLat); formData.set("lon", selectedLon); formData.set("location", locationQuery);
const res = await createNode(formData);
if (res.error) setError(res.error);
else { setIsModalOpen(false); setLocationQuery(""); setSelectedLat(""); setSelectedLon(""); fetchNodesAndApps(); }
setIsSubmitting(false);
};
const handleSaveCaseStudy = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); setIsSavingCaseStudy(true); setError("");
const formData = new FormData(e.currentTarget);
formData.append("galleryJson", JSON.stringify(gallery.filter(i => i.trim())));
formData.append("videosJson", JSON.stringify(videos.filter(v => v.trim())));
formData.append("rendersJson", JSON.stringify(renders.filter(r => r.trim())));
formData.append("specificDatasheetJson", JSON.stringify(datasheet));
formData.append("model3DDimsJson", JSON.stringify(model3DDims));
const res = await updateNodeCaseStudy(formData);
if (res.error) setError(res.error); else { setEditingNode(null); fetchNodesAndApps(); }
setIsSavingCaseStudy(false);
};
const handleDelete = async (id: string) => { if (confirm("Delete this deployment?")) { await deleteNode(id); fetchNodesAndApps(); } };
const handleToggle = async (id: string, status: boolean) => { await toggleNodeStatus(id, status); fetchNodesAndApps(); };
const availableTabs = [
{ id: "story", label: "The Story", icon: FileText, hideForEvent: false },
{ id: "tech", label: "Datasheet", icon: Cpu, hideForEvent: true },
{ id: "media", label: "Media & Video", icon: Video, hideForEvent: false },
{ id: "3d", label: "3D & Renders", icon: Box, hideForEvent: true }
].filter(t => !(editingNode?.nodeType === "event" && t.hideForEvent));
return (
<div className="min-h-screen p-6 md:p-12 max-w-7xl mx-auto">
<div className="mb-10">
<Link href="/hq-command/dashboard" className="inline-flex items-center gap-2 text-sm text-[#86868B] hover:text-[#00F0FF] transition-colors mb-6 group"><ArrowLeft size={16} className="group-hover:-translate-x-1 transition-transform" /> Back to Command Center</Link>
<div className="flex flex-col md:flex-row justify-between items-start md:items-end gap-6">
<div><h1 className="text-3xl font-light text-white flex items-center gap-3"><Globe className="text-[#00F0FF]" /> Global Network</h1><p className="text-[#86868B] mt-2">Manage 3D Map coordinates, Installations, and Deep Case Studies.</p></div>
<button onClick={() => setIsModalOpen(true)} className="flex items-center gap-2 bg-[#00F0FF]/10 text-[#00F0FF] border border-[#00F0FF]/20 px-5 py-2.5 rounded-xl font-medium hover:bg-[#00F0FF] hover:text-black transition-all"><Plus size={18} /> Add Deployment</button>
</div>
</div>
{/* TABLE */}
<div className="bg-[#111] border border-white/5 rounded-3xl overflow-hidden shadow-2xl"><div className="overflow-x-auto"><table className="w-full text-left border-collapse"><thead><tr className="border-b border-white/5 text-[10px] uppercase tracking-widest text-[#86868B] bg-black/40"><th className="p-6 font-semibold">Deployment Title & Location</th><th className="p-6 font-semibold">Type & Application</th><th className="p-6 font-semibold">Geo-Chronicle</th><th className="p-6 font-semibold">Status</th><th className="p-6 font-semibold text-right">Actions</th></tr></thead>
<tbody>
{isLoading ? <tr><td colSpan={5} className="p-8 text-center text-[#86868B]"><Loader2 className="animate-spin mx-auto mb-2" size={24} /> Syncing...</td></tr>
: nodes.length === 0 ? <tr><td colSpan={5} className="p-8 text-center text-[#86868B]">No map nodes. Add the first deployment.</td></tr>
: nodes.map(node => (
<tr key={node.id} className={`border-b border-white/5 transition-colors group ${!node.isActive ? 'opacity-50' : 'hover:bg-white/[0.02]'}`}>
<td className="p-6"><p className="font-medium text-white">{node.title}</p><p className="text-xs text-[#86868B] flex items-center gap-1 mt-1"><MapPin size={10} /> {node.location}</p></td>
<td className="p-6"><div className="flex flex-col gap-1 items-start"><span className="bg-[#00F0FF]/10 text-[#00F0FF] px-2 py-0.5 rounded text-[10px] uppercase tracking-wider border border-[#00F0FF]/20">{node.nodeType}</span><span className="bg-white/10 text-white/80 px-2 py-0.5 rounded text-[10px] uppercase tracking-wider">{node.application.replace("-", " ")}</span></div></td>
<td className="p-6">{node.projectOverview ? <span className="inline-flex items-center gap-1 text-[10px] text-[#00F0FF] border border-[#00F0FF]/30 bg-[#00F0FF]/10 px-2 py-1 rounded uppercase tracking-widest"><BookOpen size={10} /> Case Study Active</span> : <span className="text-[10px] text-[#86868B] uppercase tracking-widest">No Deep Data</span>}</td>
<td className="p-6"><button onClick={() => handleToggle(node.id, node.isActive)} className={`text-xs font-medium px-3 py-1 rounded-full flex items-center gap-1.5 transition-colors ${node.isActive ? 'bg-emerald-500/10 text-emerald-400' : 'bg-red-500/10 text-red-400'}`}>{node.isActive ? <><Eye size={12} /> Visible</> : <><EyeOff size={12} /> Hidden</>}</button></td>
<td className="p-6 text-right"><div className="flex justify-end gap-2"><button onClick={() => openGeoBlogModal(node)} className="text-[#86868B] hover:text-[#00F0FF] hover:bg-[#00F0FF]/10 p-2 rounded-lg transition-colors opacity-0 group-hover:opacity-100"><BookOpen size={18} /></button><button onClick={() => handleDelete(node.id)} className="text-[#86868B] hover:text-red-400 hover:bg-red-400/10 p-2 rounded-lg transition-colors opacity-0 group-hover:opacity-100"><Trash2 size={18} /></button></div></td>
</tr>
))}
</tbody></table></div></div>
{/* SOLUTION EDITOR MODAL */}
{editingNode && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm">
<div className="bg-[#111] border border-white/10 w-full max-w-4xl rounded-[2rem] p-0 relative shadow-2xl flex flex-col max-h-[90vh]">
<div className="p-6 md:p-8 border-b border-white/10 relative pb-0">
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-transparent via-[#00F0FF] to-transparent"></div>
<button onClick={() => setEditingNode(null)} className="absolute top-6 right-6 text-[#86868B] hover:text-white"><X size={20} /></button>
<div className="flex items-center gap-3 mb-1"><BookOpen size={24} className="text-[#00F0FF]" /><h3 className="text-2xl font-light text-white">Solution Editor</h3></div>
<p className="text-[#86868B] text-xs font-mono uppercase tracking-widest mb-6">Target: {editingNode.title}</p>
<div className="flex gap-6 border-b border-white/10 overflow-x-auto">
{availableTabs.map(t => (
<button key={t.id} type="button" onClick={() => setActiveTab(t.id as any)} className={`pb-4 text-xs uppercase tracking-widest font-semibold flex items-center gap-2 transition-all border-b-2 whitespace-nowrap ${activeTab === t.id ? "text-[#00F0FF] border-[#00F0FF]" : "text-[#86868B] border-transparent hover:text-white"}`}><t.icon size={14} /> {t.label}</button>
))}
</div>
</div>
<form id="network-form" onSubmit={handleSaveCaseStudy} className="flex-1 overflow-y-auto p-6 md:p-8 [scrollbar-width:none]">
<input type="hidden" name="id" value={editingNode.id} />
{/* TAB: THE STORY */}
<div className={activeTab === "story" ? "block animate-in fade-in" : "hidden"}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
<div>
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5 flex items-center gap-1"><ImageIcon size={12}/> Cover Image</label>
<div className="flex gap-2">
<input name="mediaFileName" type="text" defaultValue={editingNode.mediaFileName || ""} placeholder="e.g., medellin-machine.jpg" className="flex-1 bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-[#00F0FF] font-mono text-sm focus:border-[#00F0FF] outline-none" />
<button type="button" onClick={() => setCoverAssetsOpen(true)} className="flex items-center gap-1.5 px-3 py-3 text-xs text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 rounded-xl hover:bg-emerald-500/20 shrink-0"><FolderOpen size={14} /></button>
</div>
</div>
<div><label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5 flex items-center gap-1"><Calendar size={12}/> Date</label><input name="eventDate" type="date" value={eventDate} onChange={e => setEventDate(e.target.value)} className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-white text-sm focus:border-[#00F0FF] outline-none [color-scheme:dark]" /></div>
</div>
<div className="mb-6"><label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5">Energy Savings / Highlight</label><input name="energySavings" type="text" defaultValue={editingNode.energySavings || ""} placeholder="e.g., -45% Energy vs Steam" className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-emerald-400 font-semibold text-sm focus:border-[#00F0FF] outline-none" /></div>
<div>
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-2 flex justify-between"><span>Project Chronicle (Markdown)</span><span className="text-[#00F0FF]/60 text-[9px] font-normal normal-case">Rich Editor + Assets</span></label>
<MarkdownEditorCyan name="projectOverview" defaultValue={editingNode.projectOverview || ""} rows={10} placeholder="Write the full technical article..." slug={nodeSlug} />
</div>
<div className="bg-gradient-to-r from-[#00F0FF]/10 to-transparent border border-[#00F0FF]/20 p-4 rounded-xl flex items-center justify-between mt-6">
<div className="flex items-center gap-3"><div className="p-2 bg-[#00F0FF]/20 rounded-lg text-[#00F0FF]"><Sparkles size={18} /></div><div><p className="text-sm text-white font-medium">Flux AI Auto-Translate</p><p className="text-[10px] text-[#86868B] uppercase tracking-widest mt-0.5">IT, VEC, ES, DE</p></div></div>
<label className="relative inline-flex items-center cursor-pointer"><input type="checkbox" name="autoTranslate" defaultChecked className="sr-only peer" /><div className="w-11 h-6 bg-black/50 border border-white/10 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-[#00F0FF]"></div></label>
</div>
</div>
{/* TAB: DATASHEET */}
{editingNode?.nodeType !== "event" && (
<div className={activeTab === "tech" ? "block animate-in fade-in" : "hidden"}>
<div className="bg-[#00F0FF]/5 border border-[#00F0FF]/20 p-4 rounded-xl mb-6 flex items-center gap-3"><Cpu className="text-[#00F0FF]" size={20} /><p className="text-xs text-[#00F0FF]/80">Dynamic Terminal Datasheet. Check to make a stat glow large.</p></div>
<div className="space-y-4">
<div><label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5">Machine Model</label><input value={datasheet.model || ""} onChange={e => setDatasheet({...datasheet, model: e.target.value})} placeholder="e.g. Tiffany 20" className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-white text-lg font-medium focus:border-[#00F0FF] outline-none" /></div>
<div className="space-y-3 pt-4 border-t border-white/5">
<label className="block text-[10px] uppercase tracking-widest text-[#86868B]">Specifications</label>
{(datasheet.specs || []).map((spec: any, idx: number) => (
<div key={idx} className={`flex gap-3 items-center p-3 rounded-xl border ${spec.highlight ? 'bg-[#00F0FF]/5 border-[#00F0FF]/30' : 'bg-black/40 border-white/10'}`}>
<input value={spec.label} onChange={e => { const n = [...(datasheet.specs||[])]; n[idx].label = e.target.value; setDatasheet({...datasheet, specs: n}); }} placeholder="Spec Name" className="w-1/3 bg-transparent text-[#86868B] text-[10px] uppercase tracking-wider font-semibold outline-none" />
<input value={spec.value} onChange={e => { const n = [...(datasheet.specs||[])]; n[idx].value = e.target.value; setDatasheet({...datasheet, specs: n}); }} placeholder="Value" className="flex-1 bg-transparent text-white font-medium text-sm outline-none" />
<label className="flex items-center gap-1.5 text-[10px] uppercase text-[#00F0FF] cursor-pointer shrink-0 border-l border-white/10 pl-3"><input type="checkbox" checked={spec.highlight} onChange={e => { const n = [...(datasheet.specs||[])]; n[idx].highlight = e.target.checked; setDatasheet({...datasheet, specs: n}); }} className="accent-[#00F0FF] w-4 h-4" /> Big</label>
<button type="button" onClick={() => { const n = [...(datasheet.specs||[])]; n.splice(idx, 1); setDatasheet({...datasheet, specs: n}); }} className="text-[#86868B] hover:text-red-400 pl-2"><Trash2 size={16}/></button>
</div>
))}
<button type="button" onClick={() => setDatasheet({...datasheet, specs: [...(datasheet.specs||[]), {label:"",value:"",highlight:false}]})} className="w-full border border-dashed border-white/20 text-[#86868B] hover:text-white hover:border-[#00F0FF] py-3.5 rounded-xl flex justify-center items-center gap-2 text-xs uppercase tracking-widest font-semibold"><Plus size={14} /> Add Spec</button>
</div>
</div>
</div>
)}
{/* TAB: MEDIA & VIDEO */}
<div className={activeTab === "media" ? "block animate-in fade-in" : "hidden"}>
<p className="text-xs text-[#86868B] mb-6">Videos and photos for this case study.</p>
<div className="mb-8">
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-3 flex items-center gap-2"><Video size={12}/> Videos public/cases/videos</label>
{videos.map((vid, idx) => (<div key={idx} className="flex gap-2 mb-3"><input value={vid} onChange={e => { const n = [...videos]; n[idx] = e.target.value; setVideos(n); }} placeholder="e.g., videoDemo.mp4" className="flex-1 bg-black/60 border border-white/10 rounded-lg px-4 py-2.5 text-[#00F0FF] font-mono text-sm focus:border-[#00F0FF] outline-none" /><button type="button" onClick={() => setVideos(videos.filter((_, i) => i !== idx))} className="text-[#86868B] hover:text-red-400 px-3 bg-black/40 rounded-lg border border-white/10"><Trash2 size={14}/></button></div>))}
<div className="flex gap-2">
<button type="button" onClick={() => setVideos([...videos, ""])} className="flex-1 border border-dashed border-white/20 text-[#86868B] hover:text-white py-3 rounded-lg flex justify-center items-center gap-2 text-xs"><Plus size={14} /> Add Video</button>
<button type="button" onClick={() => { setMediaAssetTarget("video"); setMediaAssetsOpen(true); }} className="flex items-center gap-1.5 px-4 py-3 text-xs text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 rounded-lg hover:bg-emerald-500/20 font-medium shrink-0"><FolderOpen size={14} /> Browse Assets</button>
</div>
</div>
<div>
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-3 flex items-center gap-2"><ImageIcon size={12}/> Photo Gallery public/cases</label>
{gallery.map((img, idx) => (<div key={idx} className="flex gap-2 mb-3"><input value={img} onChange={e => { const n = [...gallery]; n[idx] = e.target.value; setGallery(n); }} placeholder="e.g., install-1.jpg" className="flex-1 bg-black/60 border border-white/10 rounded-lg px-4 py-2.5 text-white text-sm focus:border-[#00F0FF] outline-none" /><button type="button" onClick={() => setGallery(gallery.filter((_, i) => i !== idx))} className="text-[#86868B] hover:text-red-400 px-3 bg-black/40 rounded-lg border border-white/10"><Trash2 size={14}/></button></div>))}
<div className="flex gap-2">
<button type="button" onClick={() => setGallery([...gallery, ""])} className="flex-1 border border-dashed border-white/20 text-[#86868B] hover:text-white py-3 rounded-lg flex justify-center items-center gap-2 text-xs"><Plus size={14} /> Add Photo</button>
<button type="button" onClick={() => { setMediaAssetTarget("image"); setMediaAssetsOpen(true); }} className="flex items-center gap-1.5 px-4 py-3 text-xs text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 rounded-lg hover:bg-emerald-500/20 font-medium shrink-0"><FolderOpen size={14} /> Browse Assets</button>
</div>
</div>
</div>
{/* TAB: 3D & RENDERS */}
{editingNode?.nodeType !== "event" && (
<div className={activeTab === "3d" ? "block animate-in fade-in" : "hidden"}>
<p className="text-xs text-[#86868B] mb-6">3D models, dimensions, and renders for the AR viewer.</p>
<div className="mb-8">
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5 flex items-center gap-2"><Box size={12}/> 3D Model (AR) public/cases/models</label>
<div className="flex gap-2">
<input name="model3DPath" defaultValue={editingNode.model3DPath || ""} placeholder="e.g., flxd60a.glb" className="flex-1 bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-purple-400 font-mono text-sm focus:border-purple-400 outline-none" />
<button type="button" onClick={() => { setThreeDAssetTarget("model"); setThreeDAssetsOpen(true); }} className="flex items-center gap-1.5 px-4 py-3 text-xs text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 rounded-xl hover:bg-emerald-500/20 font-medium shrink-0"><FolderOpen size={14} /> Browse</button>
</div>
<p className="text-[9px] text-[#86868B] mt-1.5">GLB for Android/desktop. USDZ (iOS) auto-derived.</p>
</div>
{/* DIMENSIONS PANEL */}
<div className="mb-8 bg-white/[0.03] border border-white/8 rounded-2xl p-5">
<div className="flex items-center justify-between mb-4">
<label className="text-[10px] uppercase tracking-widest text-[#00F0FF] font-semibold flex items-center gap-2">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-[#00F0FF]"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg>
Physical Dimensions AR Scale
</label>
{model3DDims.w && model3DDims.d && (<div className="flex items-center gap-1.5 bg-[#00F0FF]/10 border border-[#00F0FF]/20 rounded-lg px-3 py-1"><span className="text-[9px] text-[#00F0FF] font-bold uppercase">Footprint</span><span className="text-[#00F0FF] font-mono text-sm font-bold">{((Number(model3DDims.w)/1000) * (Number(model3DDims.d)/1000)).toFixed(2)}</span><span className="text-[9px] text-[#86868B]">m²</span></div>)}
</div>
<p className="text-[10px] text-[#86868B] mb-4">These values feed the AR viewer HUD, space notes, and human scale reference.</p>
<div className="grid grid-cols-3 gap-3 mb-4">
{[{key:'w',label:'Width (W)',ph:'9200',color:'#f472b6',hint:'Largo'},{key:'h',label:'Height (H)',ph:'3600',color:'#34d399',hint:'Alto'},{key:'d',label:'Depth (D)',ph:'2100',color:'#60a5fa',hint:'Fondo'}].map(dim => (
<div key={dim.key}>
<label className="block text-[9px] uppercase tracking-widest mb-1.5" style={{color:dim.color}}>{dim.label}</label>
<div className="flex items-center bg-black/60 border border-white/10 rounded-xl overflow-hidden">
<input type="number" min="0" value={(model3DDims as any)[dim.key] || ''} onChange={e => setModel3DDims({...model3DDims, [dim.key]: e.target.value})} placeholder={dim.ph} className="flex-1 bg-transparent px-3 py-2.5 font-mono text-sm outline-none" style={{color:dim.color}} />
<span className="pr-3 text-[10px] text-[#86868B] font-mono">{model3DDims.unit || 'mm'}</span>
</div>
<span className="text-[9px] text-[#86868B] mt-0.5 block">{dim.hint}</span>
</div>
))}
</div>
<div className="grid grid-cols-2 gap-3">
<div><label className="block text-[9px] uppercase tracking-widest text-[#86868B] mb-1.5">Unit</label><select value={model3DDims.unit || 'mm'} onChange={e => setModel3DDims({...model3DDims, unit: e.target.value})} className="w-full bg-black/60 border border-white/10 rounded-xl px-3 py-2.5 text-white text-sm outline-none"><option value="mm">mm</option><option value="cm">cm</option><option value="m">m</option></select></div>
<div><label className="block text-[9px] uppercase tracking-widest text-[#86868B] mb-1.5">Weight</label><input type="text" value={model3DDims.weight || ''} onChange={e => setModel3DDims({...model3DDims, weight: e.target.value})} placeholder="e.g. 4200 kg" className="w-full bg-black/60 border border-white/10 rounded-xl px-3 py-2.5 text-white font-mono text-sm outline-none" /></div>
</div>
{(model3DDims.w || model3DDims.h || model3DDims.d) && (
<div className="mt-4 pt-4 border-t border-white/5 flex flex-wrap gap-3 items-center">
<span className="text-[9px] text-[#86868B] uppercase tracking-widest font-semibold">Preview:</span>
{[{l:'W',v:model3DDims.w,c:'#f472b6'},{l:'H',v:model3DDims.h,c:'#34d399'},{l:'D',v:model3DDims.d,c:'#60a5fa'}].filter(d=>d.v).map(d=>(<div key={d.l} className="flex items-baseline gap-1 bg-white/5 border border-white/10 rounded-lg px-3 py-1.5"><span className="text-[9px] font-bold" style={{color:d.c}}>{d.l}</span><span className="text-white font-mono text-sm">{d.v}</span><span className="text-[9px] text-[#86868B]">{model3DDims.unit||'mm'}</span></div>))}
{model3DDims.w && model3DDims.d && <div className="flex items-baseline gap-1 bg-[#00F0FF]/8 border border-[#00F0FF]/15 rounded-lg px-3 py-1.5"><span className="text-[9px] font-bold text-[#00F0FF]">Area</span><span className="text-[#00F0FF] font-mono text-sm">{((Number(model3DDims.w)/1000)*(Number(model3DDims.d)/1000)).toFixed(2)}</span><span className="text-[9px] text-[#86868B]">m²</span></div>}
{model3DDims.h && <div className="text-[9px] text-[#86868B]">{Number(model3DDims.h) > 1750 ? (Number(model3DDims.h)/1750).toFixed(1) + '× taller' : (1750/Number(model3DDims.h)).toFixed(1) + '× shorter'} than 1.75m</div>}
</div>
)}
</div>
<div>
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-3 flex items-center gap-2"><ImageIcon size={12}/> Studio Renders</label>
{renders.map((ren, idx) => (<div key={idx} className="flex gap-2 mb-3"><input value={ren} onChange={e => { const n = [...renders]; n[idx] = e.target.value; setRenders(n); }} placeholder="render-front.jpg" className="flex-1 bg-black/60 border border-white/10 rounded-lg px-4 py-2.5 text-white text-sm focus:border-[#00F0FF] outline-none" /><button type="button" onClick={() => setRenders(renders.filter((_, i) => i !== idx))} className="text-[#86868B] hover:text-red-400 px-3 bg-black/40 rounded-lg border border-white/10"><Trash2 size={14}/></button></div>))}
<div className="flex gap-2">
<button type="button" onClick={() => setRenders([...renders, ""])} className="flex-1 border border-dashed border-white/20 text-[#86868B] hover:text-white py-3 rounded-lg flex justify-center items-center gap-2 text-xs"><Plus size={14} /> Add Render</button>
<button type="button" onClick={() => { setThreeDAssetTarget("render"); setThreeDAssetsOpen(true); }} className="flex items-center gap-1.5 px-4 py-3 text-xs text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 rounded-lg hover:bg-emerald-500/20 font-medium shrink-0"><FolderOpen size={14} /> Browse Assets</button>
</div>
</div>
</div>
)}
</form>
{/* Asset Managers OUTSIDE the form to prevent submit propagation */}
<AssetManager scope="cases" slug={nodeSlug} isOpen={coverAssetsOpen} onClose={() => setCoverAssetsOpen(false)} onSelect={(item) => {
const inp = document.querySelector('input[name="mediaFileName"]') as HTMLInputElement;
if (inp) { inp.value = item.name; inp.dispatchEvent(new Event('input', { bubbles: true })); }
}} accentColor="#00F0FF" />
<AssetManager scope="cases" slug={nodeSlug} isOpen={mediaAssetsOpen} onClose={() => setMediaAssetsOpen(false)} onSelect={(item) => {
if (mediaAssetTarget === "video") setVideos(p => [...p, item.name]);
else setGallery(p => [...p, item.name]);
}} accentColor="#00F0FF" />
<AssetManager scope="cases" slug={nodeSlug} isOpen={threeDAssetsOpen} onClose={() => setThreeDAssetsOpen(false)} onSelect={(item) => {
if (threeDAssetTarget === "model") {
const inp = document.querySelector('input[name="model3DPath"]') as HTMLInputElement;
if (inp) { inp.value = item.name; inp.dispatchEvent(new Event('input', { bubbles: true })); }
} else {
setRenders(p => [...p, item.name]);
}
}} accentColor="#00F0FF" initialPath={threeDAssetTarget === "model" ? "models" : "renders"} />
<div className="p-6 border-t border-white/10 bg-[#111] rounded-b-[2rem] flex justify-end gap-3">
<button type="button" onClick={() => setEditingNode(null)} className="px-5 py-3 text-sm font-medium text-[#86868B] hover:text-white">Cancel</button>
<button onClick={() => (document.getElementById("network-form") as HTMLFormElement)?.requestSubmit()} disabled={isSavingCaseStudy} className="flex items-center gap-2 bg-[#00F0FF] text-black px-8 py-3 rounded-xl text-sm font-semibold hover:bg-white disabled:opacity-50 shadow-[0_0_15px_rgba(0,240,255,0.3)]">{isSavingCaseStudy ? <><Loader2 size={18} className="animate-spin" /> Processing AI...</> : "Save Complete Chronicle"}</button>
</div>
</div>
</div>
)}
{/* ADD DEPLOYMENT MODAL */}
{isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm">
<div className="bg-[#111] border border-white/10 w-full max-w-lg rounded-[2rem] p-8 relative shadow-2xl overflow-visible">
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-transparent via-[#00F0FF] to-transparent"></div>
<button onClick={() => setIsModalOpen(false)} className="absolute top-6 right-6 text-[#86868B] hover:text-white"><X size={20} /></button>
<h3 className="text-2xl font-light mb-2 text-[#00F0FF]">Add Deployment</h3>
<p className="text-[#86868B] text-sm mb-6">Search for a global city to auto-calculate coordinates.</p>
<form onSubmit={handleCreate} className="space-y-4">
<div><label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5">Company / Facility Name</label><input name="title" type="text" required placeholder="e.g., Advanced Fabrics Inc." className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-white text-sm focus:border-[#00F0FF] outline-none" /></div>
<div className="relative">
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5 flex items-center gap-1"><Search size={10} className="text-[#00F0FF]" /> City Search (Satellite)</label>
<input type="text" value={locationQuery} onChange={e => { setLocationQuery(e.target.value); setShowResults(true); }} required placeholder="Type a city name..." className="w-full bg-black/60 border border-[#00F0FF]/30 rounded-xl px-4 py-3 text-white text-sm focus:border-[#00F0FF] outline-none" autoComplete="off"/>
{showResults && locationQuery.length > 2 && (
<div className="absolute z-50 top-full mt-2 w-full bg-[#1A1A1A] border border-white/10 rounded-xl shadow-2xl overflow-hidden max-h-48 overflow-y-auto">
{isSearchingMap ? <div className="p-4 text-center text-[#86868B] text-xs flex justify-center items-center gap-2"><Loader2 className="animate-spin" size={14} /> Scanning...</div>
: searchResults.length > 0 ? searchResults.map((place, idx) => (
<button key={idx} type="button" onClick={() => selectLocation(place)} className="w-full text-left px-4 py-3 border-b border-white/5 hover:bg-[#00F0FF]/10 hover:text-[#00F0FF] transition-colors text-xs text-white">{place.display_name}</button>
)) : <div className="p-4 text-center text-[#86868B] text-xs">No coordinates found.</div>}
</div>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5">Latitude</label><input readOnly value={selectedLat} placeholder="Auto" className="w-full bg-black/20 border border-white/5 rounded-xl px-4 py-3 text-[#00F0FF] font-mono text-sm opacity-60 cursor-not-allowed" /></div>
<div><label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5">Longitude</label><input readOnly value={selectedLon} placeholder="Auto" className="w-full bg-black/20 border border-white/5 rounded-xl px-4 py-3 text-[#00F0FF] font-mono text-sm opacity-60 cursor-not-allowed" /></div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div><label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5">Deployment Type</label><select name="nodeType" value={createNodeType} onChange={e => setCreateNodeType(e.target.value)} required className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-[#00F0FF] text-sm focus:border-[#00F0FF] outline-none appearance-none"><option value="installation">📍 Field Installation</option><option value="event">🗓 Event</option><option value="hq">🏢 FLUX Legacy HQ</option></select></div>
<div><label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5">Application Category</label><select name="application" required className={"w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-white text-sm focus:border-[#00F0FF] outline-none appearance-none " + (createNodeType === "hq" ? "opacity-50" : "")}><option value="all">🌐 All Applications</option>{appsList.filter(a => a.isActive).map(a => <option key={a.slug} value={a.slug}>{a.title}</option>)}</select></div>
<div className="md:col-span-2"><label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5">{createNodeType === "event" ? "Event Location / Stand" : "Key Stat / Metric"}</label><input name="stats" type="text" required placeholder={createNodeType === "event" ? "e.g., Hall 4, Stand B12" : "e.g., 50% Energy Savings"} className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-white text-sm focus:border-[#00F0FF] outline-none" /></div>
</div>
{error && <p className="text-red-400 text-xs text-center bg-red-400/10 py-2 rounded-lg">{error}</p>}
<button type="submit" disabled={isSubmitting || !selectedLat} className="w-full flex items-center justify-center gap-2 bg-[#00F0FF] text-black py-3.5 mt-4 rounded-xl text-sm font-semibold hover:bg-white transition-colors disabled:opacity-30 disabled:cursor-not-allowed">{isSubmitting ? <Loader2 size={18} className="animate-spin" /> : "Deploy to Map"}</button>
</form>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,105 @@
"use server";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
// 🔥 Importamos nuestra nueva IA traductora
import { translateContentForCMS } from "@/lib/aiTranslator";
export async function getNewsArticles() {
try {
const articles = await prisma.newsArticle.findMany({
orderBy: [{ order: 'asc' }, { publishedAt: 'desc' }]
});
return { success: true, articles };
} catch (error: any) {
return { error: error.message };
}
}
export async function createNewsArticle(formData: FormData) {
try {
const title = formData.get("title") as string;
const excerpt = formData.get("excerpt") as string;
const content = formData.get("content") as string;
const coverImage = formData.get("coverImage") as string;
const category = formData.get("category") as string;
const linkedinUrl = formData.get("linkedinUrl") as string;
const order = parseInt(formData.get("order") as string) || 0;
const galleryJson = formData.get("galleryJson") as string;
// Capturamos si Patrizio pidió traducción por IA
const autoTranslate = formData.get("autoTranslate") === "on";
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)+/g, '');
// 🔥 LA MAGIA DE LA IA 🔥
let translationsJson = "{}";
if (autoTranslate) {
// Solo le mandamos a la IA los textos que se deben leer (no mandamos URLs ni números de orden)
const aiResult = await translateContentForCMS({ title, excerpt, content, category });
if (aiResult) {
translationsJson = JSON.stringify(aiResult);
}
}
await prisma.newsArticle.create({
data: {
title, slug, excerpt, content, coverImage, category,
linkedinUrl, order, galleryJson, translationsJson
}
});
revalidatePath("/news");
revalidatePath("/[locale]/news", "layout");
return { success: true };
} catch (error: any) {
return { error: error.message };
}
}
export async function updateNewsArticle(formData: FormData) {
try {
const id = formData.get("id") as string;
const title = formData.get("title") as string;
const excerpt = formData.get("excerpt") as string;
const content = formData.get("content") as string;
const coverImage = formData.get("coverImage") as string;
const category = formData.get("category") as string;
const linkedinUrl = formData.get("linkedinUrl") as string;
const order = parseInt(formData.get("order") as string) || 0;
const galleryJson = formData.get("galleryJson") as string;
const autoTranslate = formData.get("autoTranslate") === "on";
let updateData: any = {
title, excerpt, content, coverImage, category, linkedinUrl, order, galleryJson
};
// 🔥 LA MAGIA DE LA IA (Actualización) 🔥
if (autoTranslate) {
const aiResult = await translateContentForCMS({ title, excerpt, content, category });
if (aiResult) {
updateData.translationsJson = JSON.stringify(aiResult);
}
}
await prisma.newsArticle.update({ where: { id }, data: updateData });
revalidatePath("/news");
revalidatePath("/[locale]/news", "layout");
return { success: true };
} catch (error: any) {
return { error: error.message };
}
}
export async function deleteNewsArticle(id: string) {
try {
await prisma.newsArticle.delete({ where: { id } });
revalidatePath("/news");
revalidatePath("/[locale]/news", "layout");
return { success: true };
} catch (error: any) {
return { error: error.message };
}
}
+485
View File
@@ -0,0 +1,485 @@
//src/app/hq-command/dashboard/news/page.tsx
"use client";
export const dynamic = "force-dynamic";
import { useState, useEffect, useRef, useCallback } from "react";
import Link from "next/link";
import {
ArrowLeft, Newspaper, Plus, Trash2, Loader2, X, Linkedin, Edit3, Sparkles,
Bold, Italic, Heading1, Heading2, Heading3, List, ListOrdered, Quote, Table, Minus,
RotateCcw, RotateCw, Maximize2, Minimize2, ChevronDown,
FolderOpen, Upload, FolderPlus, ChevronRight, File, ArrowUpFromLine,
Grid3X3, LayoutList, Copy, Check, Image as ImageIcon, Video, Box, Search
} from "lucide-react";
import { getNewsArticles, createNewsArticle, updateNewsArticle, deleteNewsArticle } from "./actions";
// ─────────────────────────────────────────────────────────────────────────────
// ASSET MANAGER — Reusable file browser (same as Network but for news scope)
// ─────────────────────────────────────────────────────────────────────────────
interface AssetItem {
name: string; type: "file" | "folder"; mediaType?: string; extension?: string;
path: string; publicUrl?: string; size?: string; childCount?: number;
}
interface AssetManagerProps {
slug: string; scope?: string; isOpen: boolean; onClose: () => void;
onSelect: (item: { name: string; publicUrl: string; mediaType: string; path: string }) => void;
accentColor?: string; initialPath?: string;
}
function AssetManager({ slug, scope = "news", isOpen, onClose, onSelect, accentColor = "#00F0FF", initialPath = "" }: AssetManagerProps) {
const [currentPath, setCurrentPath] = useState(initialPath);
const [items, setItems] = useState<AssetItem[]>([]);
const [breadcrumbs, setBreadcrumbs] = useState<{name: string; path: string}[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState("");
const fileInputRef = useRef<HTMLInputElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [showNewFolder, setShowNewFolder] = useState(false);
const [newFolderName, setNewFolderName] = useState("");
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
const [searchQuery, setSearchQuery] = useState("");
const [copiedPath, setCopiedPath] = useState<string | null>(null);
const fetchAssets = useCallback(async (dirPath: string = "") => {
setIsLoading(true); setError(null);
try {
const params = new URLSearchParams({ scope, slug, path: dirPath });
const res = await fetch(`/api/assets?${params}`);
const data = await res.json();
if (data.success) { setItems(data.items); setBreadcrumbs(data.breadcrumbs); setCurrentPath(dirPath); }
else setError(data.error || "Failed to load");
} catch { setError("Connection error — check /api/assets/route.ts"); }
setIsLoading(false);
}, [scope, slug]);
useEffect(() => { if (isOpen) { fetchAssets(initialPath || currentPath); setSearchQuery(""); } }, [isOpen]); // eslint-disable-line
const uploadFile = async (file: File) => {
setIsUploading(true); setUploadProgress(`Uploading ${file.name}...`);
try {
const fd = new FormData();
fd.append("scope", scope); fd.append("slug", slug); fd.append("path", currentPath); fd.append("file", file);
const res = await fetch("/api/assets", { method: "POST", body: fd });
const data = await res.json();
if (data.success) { setUploadProgress("✓ " + data.file.name); await fetchAssets(currentPath); setTimeout(() => setUploadProgress(""), 2000); }
else { setUploadProgress("✗ " + data.error); setTimeout(() => setUploadProgress(""), 4000); }
} catch { setUploadProgress("✗ Failed"); setTimeout(() => setUploadProgress(""), 3000); }
setIsUploading(false);
};
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => { if (e.target.files) Array.from(e.target.files).forEach(uploadFile); e.target.value = ""; };
const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); setIsDragging(true); };
const handleDragLeave = (e: React.DragEvent) => { e.preventDefault(); setIsDragging(false); };
const handleDrop = (e: React.DragEvent) => { e.preventDefault(); setIsDragging(false); if (e.dataTransfer.files.length > 0) Array.from(e.dataTransfer.files).forEach(uploadFile); };
const createFolder = async () => {
if (!newFolderName.trim()) return;
try {
const res = await fetch("/api/assets", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ scope, slug, folderName: newFolderName, parentPath: currentPath }) });
const data = await res.json();
if (data.success) { setShowNewFolder(false); setNewFolderName(""); await fetchAssets(currentPath); }
else alert(data.error);
} catch { alert("Connection error"); }
};
const deleteFile = async (filePath: string, fileName: string) => {
if (!confirm('Delete "' + fileName + '"?')) return;
try {
const res = await fetch("/api/assets", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ scope, slug, filePath }) });
const data = await res.json(); if (data.success) await fetchAssets(currentPath); else alert(data.error);
} catch { alert("Failed"); }
};
const handleSelect = (item: AssetItem, e?: React.MouseEvent) => {
if (e) { e.preventDefault(); e.stopPropagation(); }
if (item.type === "folder") { fetchAssets(item.path); return; }
onSelect({ name: item.name, publicUrl: item.publicUrl || "/" + scope + "/" + slug + "/" + item.path, mediaType: item.mediaType || "unknown", path: item.path });
onClose();
};
const copyPath = (item: AssetItem) => {
navigator.clipboard.writeText(item.publicUrl || "/" + scope + "/" + slug + "/" + item.path);
setCopiedPath(item.path); setTimeout(() => setCopiedPath(null), 1500);
};
const filtered = searchQuery ? items.filter(i => i.name.toLowerCase().includes(searchQuery.toLowerCase())) : items;
const typeBadge = (mt?: string) => {
const s: Record<string, string> = { image: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20", video: "bg-blue-500/10 text-blue-400 border-blue-500/20", model: "bg-purple-500/10 text-purple-400 border-purple-500/20" };
return s[mt || ""] || "bg-white/5 text-[#86868B] border-white/10";
};
const renderThumb = (item: AssetItem) => {
if (item.type === "folder") return <div className="w-full h-full flex items-center justify-center bg-white/[0.02]"><FolderOpen size={28} style={{ color: accentColor, opacity: 0.7 }} /></div>;
if (item.mediaType === "image" && item.publicUrl) return <img src={item.publicUrl} alt={item.name} className="w-full h-full object-cover" loading="lazy" />;
if (item.mediaType === "video") return <div className="w-full h-full flex items-center justify-center bg-blue-500/5"><Video size={24} className="text-blue-400/60" /></div>;
return <div className="w-full h-full flex items-center justify-center bg-white/[0.02]"><File size={24} className="text-[#86868B]/50" /></div>;
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[70] flex items-center justify-center p-4 bg-black/85 backdrop-blur-md" onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>
<div className="bg-[#0D0D0D] border border-white/10 w-full max-w-4xl rounded-[2rem] shadow-2xl flex flex-col max-h-[85vh] relative overflow-hidden" onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop} onClick={(e) => e.stopPropagation()}>
{isDragging && (
<div className="absolute inset-0 z-50 border-2 border-dashed rounded-[2rem] flex items-center justify-center backdrop-blur-sm" style={{ borderColor: accentColor + "80", background: accentColor + "15" }}>
<div className="text-center"><ArrowUpFromLine size={48} style={{ color: accentColor }} className="mx-auto mb-3 animate-bounce" /><p style={{ color: accentColor }} className="font-medium text-lg">Drop files to upload</p></div>
</div>
)}
<div className="px-6 py-5 border-b border-white/10 shrink-0">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3"><div className="p-2 rounded-xl" style={{ background: accentColor + "20", color: accentColor }}><FolderOpen size={20} /></div><div><h3 className="text-lg font-medium text-white">Asset Manager</h3><p className="text-[10px] text-[#86868B] uppercase tracking-widest font-mono">/public/{scope}/{slug}/</p></div></div>
<button onClick={onClose} className="text-[#86868B] hover:text-white p-2 hover:bg-white/5 rounded-xl"><X size={20} /></button>
</div>
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-1 text-sm overflow-x-auto [scrollbar-width:none] flex-1 min-w-0">
{breadcrumbs.map((crumb, idx) => (<span key={idx} className="flex items-center shrink-0">{idx > 0 && <ChevronRight size={12} className="text-[#86868B]/50 mx-0.5" />}<button onClick={() => fetchAssets(crumb.path)} className={`px-2 py-1 rounded-lg text-xs ${idx === breadcrumbs.length - 1 ? "bg-white/10" : "text-[#86868B] hover:text-white hover:bg-white/5"}`} style={idx === breadcrumbs.length - 1 ? { color: accentColor } : {}}>{crumb.name}</button></span>))}
</div>
<div className="flex items-center gap-2 shrink-0">
<div className="relative"><Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[#86868B]" /><input value={searchQuery} onChange={e => setSearchQuery(e.target.value)} placeholder="Filter..." className="bg-white/5 border border-white/10 rounded-lg pl-8 pr-3 py-1.5 text-xs text-white w-32 focus:w-48 transition-all outline-none" /></div>
<div className="flex bg-white/5 rounded-lg border border-white/10 overflow-hidden">
<button onClick={() => setViewMode("grid")} className={`p-1.5 ${viewMode === "grid" ? "text-[#00F0FF] bg-white/10" : "text-[#86868B]"}`}><Grid3X3 size={14} /></button>
<button onClick={() => setViewMode("list")} className={`p-1.5 ${viewMode === "list" ? "text-[#00F0FF] bg-white/10" : "text-[#86868B]"}`}><LayoutList size={14} /></button>
</div>
<button onClick={() => setShowNewFolder(!showNewFolder)} className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-[#86868B] hover:text-white bg-white/5 border border-white/10 rounded-lg"><FolderPlus size={13} /> Folder</button>
<button onClick={() => fileInputRef.current?.click()} disabled={isUploading} className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-black rounded-lg font-medium disabled:opacity-50" style={{ background: accentColor }}><Upload size={13} /> Upload</button>
<input ref={fileInputRef} type="file" multiple accept=".jpg,.jpeg,.png,.webp,.gif,.svg,.avif,.mp4,.webm,.mov,.pdf" onChange={handleFileSelect} className="hidden" />
</div>
</div>
{showNewFolder && (<div className="flex items-center gap-2 mt-3 pt-3 border-t border-white/5"><FolderPlus size={14} style={{ color: accentColor }} /><input value={newFolderName} onChange={e => setNewFolderName(e.target.value)} onKeyDown={e => { if (e.key === "Enter") createFolder(); if (e.key === "Escape") { setShowNewFolder(false); setNewFolderName(""); } }} placeholder="folder-name" autoFocus className="flex-1 bg-black/40 border border-white/10 rounded-lg px-3 py-1.5 text-sm text-white outline-none font-mono" /><button onClick={createFolder} className="px-3 py-1.5 text-xs text-black rounded-lg font-medium" style={{ background: accentColor }}>Create</button><button onClick={() => { setShowNewFolder(false); setNewFolderName(""); }} className="px-3 py-1.5 text-xs text-[#86868B]">Cancel</button></div>)}
{uploadProgress && <div className="mt-3 pt-3 border-t border-white/5 flex items-center gap-2 text-xs">{isUploading && <Loader2 size={12} className="animate-spin" style={{ color: accentColor }} />}<span className={uploadProgress.startsWith("✓") ? "text-emerald-400" : uploadProgress.startsWith("✗") ? "text-red-400" : ""} style={isUploading ? { color: accentColor } : {}}>{uploadProgress}</span></div>}
</div>
<div className="flex-1 overflow-y-auto p-4 [scrollbar-width:none]">
{isLoading ? <div className="flex items-center justify-center py-20"><Loader2 size={24} className="animate-spin" style={{ color: accentColor }} /></div>
: error ? <div className="flex flex-col items-center justify-center py-20 text-center"><X size={32} className="text-red-400/50 mb-3" /><p className="text-red-400/80 text-sm">{error}</p></div>
: filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-center"><FolderOpen size={48} className="text-[#86868B]/20 mb-4" />
{searchQuery ? <p className="text-[#86868B] text-sm">No matches</p> : <><p className="text-[#86868B] text-sm mb-2">Empty directory</p><div className="flex gap-2 mt-4">{["images", "gallery"].map(f => (<button key={f} onClick={async () => { await fetch("/api/assets", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ scope, slug, folderName: f, parentPath: currentPath }) }); fetchAssets(currentPath); }} className="px-3 py-2 text-xs border rounded-lg" style={{ color: accentColor, borderColor: accentColor + "30" }}>+ {f}/</button>))}</div></>}
</div>
) : viewMode === "grid" ? (
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-3">
{filtered.map(item => (
<div key={item.path} className="group relative bg-[#111] border border-white/5 rounded-xl overflow-hidden hover:border-white/20 transition-all cursor-pointer" onClick={(e) => handleSelect(item, e)}>
<div className="aspect-square overflow-hidden bg-black/20">{renderThumb(item)}</div>
<div className="p-2"><p className="text-[11px] text-white truncate font-medium">{item.name}</p>
<div className="flex items-center justify-between mt-1">{item.type === "folder" ? <span className="text-[9px] text-[#86868B]">{item.childCount} items</span> : <span className={`text-[9px] px-1.5 py-0.5 rounded border ${typeBadge(item.mediaType)}`}>{item.mediaType}</span>}{item.size && <span className="text-[9px] text-[#86868B]">{item.size}</span>}</div>
</div>
{item.type === "file" && <div className="absolute top-1 right-1 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity"><button onClick={e => { e.stopPropagation(); copyPath(item); }} className="p-1.5 bg-black/70 backdrop-blur-sm rounded-lg text-[#86868B] hover:text-white">{copiedPath === item.path ? <Check size={11} className="text-emerald-400" /> : <Copy size={11} />}</button><button onClick={e => { e.stopPropagation(); deleteFile(item.path, item.name); }} className="p-1.5 bg-black/70 backdrop-blur-sm rounded-lg text-[#86868B] hover:text-red-400"><Trash2 size={11} /></button></div>}
</div>
))}
</div>
) : (
<div className="space-y-1">{filtered.map(item => (
<div key={item.path} className="group flex items-center gap-3 px-3 py-2.5 rounded-xl hover:bg-white/[0.03] cursor-pointer" onClick={(e) => handleSelect(item, e)}>
<div className="w-8 h-8 rounded-lg overflow-hidden shrink-0 border border-white/5">{renderThumb(item)}</div>
<span className="text-sm text-white flex-1 truncate">{item.name}</span>
{item.type === "file" && <span className={`text-[9px] px-2 py-0.5 rounded border shrink-0 ${typeBadge(item.mediaType)}`}>{item.extension}</span>}
{item.type === "folder" && <><span className="text-[9px] text-[#86868B]">{item.childCount} items</span><ChevronRight size={14} className="text-[#86868B]/50" /></>}
{item.size && <span className="text-[10px] text-[#86868B] w-16 text-right shrink-0">{item.size}</span>}
</div>
))}</div>
)}
</div>
<div className="px-6 py-3 border-t border-white/10 flex items-center justify-between text-[10px] text-[#86868B] shrink-0 bg-black/20"><span>{filtered.length} items Click to select</span><span className="font-mono">Drag & drop</span></div>
</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// MARKDOWN EDITOR — Cyan-themed for News articles
// ─────────────────────────────────────────────────────────────────────────────
function MarkdownEditorCyan({ name, defaultValue = "", required, rows = 12, placeholder, slug }: {
name: string; defaultValue?: string; required?: boolean; rows?: number; placeholder?: string; slug?: string;
}) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [value, setValue] = useState(defaultValue);
const [isExpanded, setIsExpanded] = useState(false);
const [showInsertMenu, setShowInsertMenu] = useState(false);
const [history, setHistory] = useState<string[]>([defaultValue]);
const [historyIndex, setHistoryIndex] = useState(0);
const insertMenuRef = useRef<HTMLDivElement>(null);
const [isAssetManagerOpen, setIsAssetManagerOpen] = useState(false);
useEffect(() => {
const h = (e: MouseEvent) => { if (insertMenuRef.current && !insertMenuRef.current.contains(e.target as Node)) setShowInsertMenu(false); };
document.addEventListener("mousedown", h); return () => document.removeEventListener("mousedown", h);
}, []);
const historyTimeout = useRef<NodeJS.Timeout | null>(null);
const pushHistory = useCallback((v: string) => {
if (historyTimeout.current) clearTimeout(historyTimeout.current);
historyTimeout.current = setTimeout(() => { setHistory(p => [...p.slice(0, historyIndex + 1), v].slice(-50)); setHistoryIndex(p => Math.min(p + 1, 49)); }, 500);
}, [historyIndex]);
const handleChange = (v: string) => { setValue(v); pushHistory(v); };
const undo = () => { if (historyIndex > 0) { const i = historyIndex - 1; setHistoryIndex(i); setValue(history[i]); } };
const redo = () => { if (historyIndex < history.length - 1) { const i = historyIndex + 1; setHistoryIndex(i); setValue(history[i]); } };
const getSelection = () => { const ta = textareaRef.current; if (!ta) return { start: 0, end: 0, selected: "", before: "", after: "" }; return { start: ta.selectionStart, end: ta.selectionEnd, selected: value.substring(ta.selectionStart, ta.selectionEnd), before: value.substring(0, ta.selectionStart), after: value.substring(ta.selectionEnd) }; };
const replaceSelection = (t: string, o?: number) => { const { before, after } = getSelection(); handleChange(before + t + after); const p = o !== undefined ? before.length + o : before.length + t.length; setTimeout(() => { const ta = textareaRef.current; if (ta) { ta.focus(); ta.setSelectionRange(p, p); } }, 0); };
const wrapSelection = (pre: string, suf: string) => { const { selected } = getSelection(); if (selected) replaceSelection(pre + selected + suf); else replaceSelection(pre + "text" + suf, pre.length); };
const insertAtCursor = (t: string, o?: number) => replaceSelection(t, o);
const prependLine = (pre: string) => { const { start, selected } = getSelection(); const ls = value.lastIndexOf('\n', start - 1) + 1; const bef = value.substring(0, ls); const line = selected || value.substring(ls).split('\n')[0]; const aft = value.substring(ls + line.length); handleChange(bef + pre + line + aft); setTimeout(() => { const ta = textareaRef.current; if (ta) { ta.focus(); ta.setSelectionRange(ls + pre.length, ls + pre.length + line.length); } }, 0); };
const handleAssetInsert = (item: { publicUrl: string; mediaType: string; name: string }) => {
const syntax = item.mediaType === "image" ? "![" + item.name + "](" + item.publicUrl + ")" : "[" + item.name + "](" + item.publicUrl + ")";
insertAtCursor("\n" + syntax + "\n");
};
const basePath = "/news/" + (slug || "slug");
const actions = {
bold: () => wrapSelection("**", "**"), italic: () => wrapSelection("*", "*"),
h1: () => prependLine("# "), h2: () => prependLine("## "), h3: () => prependLine("### "),
quote: () => prependLine("> "),
ul: () => insertAtCursor("\n- Item 1\n- Item 2\n- Item 3\n", 3),
ol: () => insertAtCursor("\n1. First\n2. Second\n3. Third\n", 4),
hr: () => insertAtCursor("\n---\n"),
table: () => insertAtCursor("\n| Feature | Previous | FLUX Update |\n|---|---|---|\n| Speed | 10 mt/min | 20 mt/min |\n", 2),
image: () => insertAtCursor("\n![Image description](" + basePath + "/image.jpg)\n", 3),
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
const m = e.metaKey || e.ctrlKey;
if (m && e.key === 'b') { e.preventDefault(); actions.bold(); }
if (m && e.key === 'i') { e.preventDefault(); actions.italic(); }
if (m && e.key === 'z') { e.preventDefault(); e.shiftKey ? redo() : undo(); }
if (e.key === 'Tab') { e.preventDefault(); insertAtCursor(" "); }
};
const ToolBtn = ({ icon: Icon, label, onClick, className = "" }: { icon: any; label: string; onClick: () => void; className?: string }) => (
<button type="button" onClick={onClick} title={label} className={"p-1.5 rounded-lg text-[#86868B] hover:text-white hover:bg-white/10 transition-all active:scale-90 " + className}><Icon size={15} strokeWidth={2} /></button>
);
const Divider = () => <div className="w-px h-5 bg-white/10 mx-1" />;
return (
<div className={"flex flex-col " + (isExpanded ? "fixed inset-0 z-[60] bg-[#0A0A0A] p-6" : "")}>
<input type="hidden" name={name} value={value} />
<div className={"flex flex-wrap items-center gap-0.5 px-2 py-1.5 bg-black/60 border border-white/10 rounded-t-xl " + (isExpanded ? "border-b-0 rounded-t-2xl" : "")}>
<ToolBtn icon={Bold} label="Bold" onClick={actions.bold} />
<ToolBtn icon={Italic} label="Italic" onClick={actions.italic} />
<Divider />
<ToolBtn icon={Heading1} label="H1" onClick={actions.h1} />
<ToolBtn icon={Heading2} label="H2" onClick={actions.h2} />
<ToolBtn icon={Heading3} label="H3" onClick={actions.h3} />
<Divider />
<ToolBtn icon={List} label="Bullet" onClick={actions.ul} />
<ToolBtn icon={ListOrdered} label="Numbered" onClick={actions.ol} />
<ToolBtn icon={Quote} label="Quote" onClick={actions.quote} />
<ToolBtn icon={Table} label="Table" onClick={actions.table} />
<ToolBtn icon={Minus} label="HR" onClick={actions.hr} />
<Divider />
<div className="relative" ref={insertMenuRef}>
<button type="button" onClick={() => setShowInsertMenu(!showInsertMenu)} className="flex items-center gap-1 px-2 py-1.5 rounded-lg text-[#00F0FF] hover:bg-[#00F0FF]/10 text-[11px] font-semibold uppercase tracking-wider">
<Plus size={13} /> Insert <ChevronDown size={11} className={"transition-transform " + (showInsertMenu ? "rotate-180" : "")} />
</button>
{showInsertMenu && (
<div className="absolute top-full left-0 mt-1 w-52 bg-[#1A1A1A] border border-white/10 rounded-xl shadow-2xl z-50 overflow-hidden">
<div className="p-1">
<button type="button" onClick={() => { actions.image(); setShowInsertMenu(false); }} className="w-full flex items-center gap-3 px-3 py-2.5 text-white/80 hover:text-white hover:bg-white/5 rounded-lg text-left"><div className="p-1.5 bg-emerald-500/15 rounded-lg text-emerald-400"><ImageIcon size={14} /></div><div><p className="text-xs font-medium">Image</p><p className="text-[10px] text-[#86868B]">.jpg .png .webp</p></div></button>
<button type="button" onClick={() => { actions.table(); setShowInsertMenu(false); }} className="w-full flex items-center gap-3 px-3 py-2.5 text-white/80 hover:text-white hover:bg-white/5 rounded-lg text-left"><div className="p-1.5 bg-amber-500/15 rounded-lg text-amber-400"><Table size={14} /></div><div><p className="text-xs font-medium">Table</p><p className="text-[10px] text-[#86868B]">Auto-highlight</p></div></button>
</div>
</div>
)}
</div>
{slug && (<><Divider /><button type="button" onClick={() => setIsAssetManagerOpen(true)} className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-emerald-400 hover:text-emerald-300 hover:bg-emerald-500/10 text-[11px] font-semibold uppercase tracking-wider"><FolderOpen size={14} /> Assets</button></>)}
<div className="flex-1" />
<ToolBtn icon={RotateCcw} label="Undo" onClick={undo} className={historyIndex <= 0 ? "opacity-30 pointer-events-none" : ""} />
<ToolBtn icon={RotateCw} label="Redo" onClick={redo} className={historyIndex >= history.length - 1 ? "opacity-30 pointer-events-none" : ""} />
<Divider />
<ToolBtn icon={isExpanded ? Minimize2 : Maximize2} label="Fullscreen" onClick={() => setIsExpanded(!isExpanded)} />
</div>
<textarea ref={textareaRef} value={value} onChange={e => handleChange(e.target.value)} onKeyDown={handleKeyDown} required={required} rows={isExpanded ? 40 : rows} placeholder={placeholder} className={"w-full bg-black/40 border border-white/10 border-t-0 p-4 text-white font-mono text-sm focus:border-[#00F0FF] outline-none resize-none leading-relaxed " + (isExpanded ? "flex-1 rounded-b-2xl text-base leading-loose" : "rounded-b-xl")} style={{ tabSize: 2 }} />
<div className="flex items-center justify-between mt-1.5 px-1">
<div className="flex items-center gap-3 text-[10px] text-[#86868B] font-mono"><span>{value.split('\n').length} lines</span><span className="text-white/10">|</span><span>{value.length} chars</span></div>
<div className="flex items-center gap-2 text-[10px] text-[#86868B]"><span className="opacity-60">B</span><span className="opacity-60">I</span><span className="opacity-60">Tab</span></div>
</div>
{!isExpanded && (
<div className="bg-[#00F0FF]/5 border border-[#00F0FF]/20 rounded-xl p-4 text-xs text-[#86868B] font-mono leading-relaxed mt-3">
<p className="text-[#00F0FF] font-semibold uppercase tracking-widest mb-2 text-[10px]">Markdown Cheat Sheet:</p>
<p><strong># Title 1</strong> &nbsp;|&nbsp; <strong>## Title 2</strong> &nbsp;|&nbsp; <strong>**Bold**</strong></p>
<p className="mt-1"><strong>- List Item</strong> &nbsp;|&nbsp; <strong>&gt; Blockquote</strong></p>
<p className="mt-1 text-white"><strong>![Image](/news/{slug || "slug"}/image.jpg)</strong></p>
<div className="mt-3 pt-3 border-t border-[#00F0FF]/10">
<p><strong>Tables (Last column highlights):</strong></p>
<p>| Feature | Previous | FLUX Update |</p>
<p>|---|---|---|</p>
<p>| Speed | 10 mt/min | 20 mt/min |</p>
</div>
</div>
)}
{slug && <AssetManager scope="news" slug={slug} isOpen={isAssetManagerOpen} onClose={() => setIsAssetManagerOpen(false)} onSelect={handleAssetInsert} accentColor="#00F0FF" />}
</div>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// MAIN PAGE — News Manager (Inside Flux / Editorial Desk)
// ─────────────────────────────────────────────────────────────────────────────
export default function NewsManager() {
const [articles, setArticles] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState("");
const [editingArticle, setEditingArticle] = useState<any | null>(null);
const [gallery, setGallery] = useState<string[]>([]);
// Asset Manager states
const [coverAssetsOpen, setCoverAssetsOpen] = useState(false);
const [galleryAssetsOpen, setGalleryAssetsOpen] = useState(false);
// Derive slug for folder naming
const articleSlug = editingArticle?.title?.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)+/g, '') || "new-article";
const fetchArticles = async () => {
setIsLoading(true);
const res = await getNewsArticles();
if (res.success && res.articles) setArticles(res.articles);
setIsLoading(false);
};
useEffect(() => { fetchArticles(); }, []);
const openCreateModal = () => { setEditingArticle(null); setGallery([]); setIsModalOpen(true); };
const openEditModal = (article: any) => {
setEditingArticle(article);
try { setGallery(JSON.parse(article.galleryJson || "[]")); } catch { setGallery([]); }
setIsModalOpen(true);
};
const handleSave = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); setIsSubmitting(true); setError("");
const formData = new FormData(e.currentTarget);
formData.append("galleryJson", JSON.stringify(gallery.filter(img => img.trim() !== "")));
let res;
if (editingArticle) res = await updateNewsArticle(formData);
else res = await createNewsArticle(formData);
if (res.error) setError(res.error);
else { setIsModalOpen(false); fetchArticles(); }
setIsSubmitting(false);
};
const handleDelete = async (id: string) => {
if (confirm("Delete this article?")) { await deleteNewsArticle(id); fetchArticles(); }
};
return (
<div className="min-h-screen p-6 md:p-12 max-w-7xl mx-auto">
<div className="mb-10">
<Link href="/hq-command/dashboard" className="inline-flex items-center gap-2 text-sm text-[#86868B] hover:text-[#00F0FF] transition-colors mb-6 group"><ArrowLeft size={16} className="group-hover:-translate-x-1 transition-transform" /> Back to Command Center</Link>
<div className="flex flex-col md:flex-row justify-between items-start md:items-end gap-6">
<div><h1 className="text-3xl font-light text-white flex items-center gap-3"><Newspaper className="text-[#00F0FF]" /> Inside Flux</h1><p className="text-[#86868B] mt-2">Manage company news, tech updates, and behind-the-scenes articles.</p></div>
<button onClick={openCreateModal} className="flex items-center gap-2 bg-[#00F0FF] text-black px-5 py-2.5 rounded-xl font-medium hover:bg-white transition-all"><Plus size={18} /> Write Article</button>
</div>
</div>
{error && <div className="mb-8 p-4 bg-red-500/10 border border-red-500/20 rounded-xl text-red-400 text-sm">{error}</div>}
<div className="bg-[#111] border border-white/5 rounded-3xl overflow-hidden shadow-2xl"><div className="overflow-x-auto"><table className="w-full text-left border-collapse"><thead><tr className="border-b border-white/5 text-[10px] uppercase tracking-widest text-[#86868B] bg-black/40"><th className="p-6 font-semibold">Article / Date</th><th className="p-6 font-semibold text-center">Order</th><th className="p-6 font-semibold">Category</th><th className="p-6 font-semibold text-right">Actions</th></tr></thead>
<tbody>
{isLoading ? <tr><td colSpan={4} className="p-12 text-center text-[#86868B]"><Loader2 className="animate-spin mx-auto mb-2" size={24} /> Loading editorial database...</td></tr>
: articles.length === 0 ? <tr><td colSpan={4} className="p-12 text-center text-[#86868B]">No articles published yet.</td></tr>
: articles.map(article => (
<tr key={article.id} className="border-b border-white/5 hover:bg-white/[0.02] transition-colors group">
<td className="p-6">
<div className="flex items-center gap-2 mb-1"><p className="font-medium text-white text-base">{article.title}</p>{article.linkedinUrl && <Linkedin size={14} className="text-[#0A66C2]" />}</div>
<p className="text-xs text-[#86868B] max-w-md truncate">{article.excerpt}</p>
<span className="text-[10px] text-white/30 uppercase tracking-widest mt-2 block font-mono">{new Date(article.publishedAt).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}</span>
</td>
<td className="p-6 text-center"><span className="text-white/50 bg-white/5 px-3 py-1 rounded font-mono text-sm">{article.order}</span></td>
<td className="p-6"><span className="bg-[#00F0FF]/10 text-[#00F0FF] border border-[#00F0FF]/20 px-3 py-1 rounded-full text-[10px] uppercase tracking-wider">{article.category}</span></td>
<td className="p-6 text-right"><div className="flex justify-end gap-2"><button onClick={() => openEditModal(article)} className="text-[#86868B] hover:text-[#00F0FF] hover:bg-[#00F0FF]/10 p-2 rounded-lg transition-colors opacity-0 group-hover:opacity-100"><Edit3 size={18} /></button><button onClick={() => handleDelete(article.id)} className="text-[#86868B] hover:text-red-400 hover:bg-red-400/10 p-2 rounded-lg transition-colors opacity-0 group-hover:opacity-100"><Trash2 size={18} /></button></div></td>
</tr>
))}
</tbody></table></div></div>
{/* EDITORIAL DESK MODAL */}
{isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm">
<div className="bg-[#111] border border-white/10 w-full max-w-4xl rounded-[2rem] p-0 relative shadow-2xl flex flex-col max-h-[90vh]">
<div className="p-6 md:p-8 border-b border-white/10 relative">
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-transparent via-[#00F0FF] to-transparent"></div>
<button onClick={() => setIsModalOpen(false)} className="absolute top-6 right-6 text-[#86868B] hover:text-white"><X size={20} /></button>
<h3 className="text-2xl font-light mb-1 text-[#00F0FF] flex items-center gap-2"><Newspaper size={24} /> {editingArticle ? "Edit Article" : "Editorial Desk"}</h3>
</div>
<form id="news-form" onSubmit={handleSave} className="flex-1 overflow-y-auto p-6 md:p-8 [scrollbar-width:none]">
<input type="hidden" name="id" value={editingArticle?.id || ""} />
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
<div className="md:col-span-3"><label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5">Article Title</label><input name="title" defaultValue={editingArticle?.title} required className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-white text-lg font-medium focus:border-[#00F0FF] outline-none" /></div>
<div><label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5">Display Order</label><input name="order" type="number" defaultValue={editingArticle?.order || 0} required className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-white text-lg font-medium focus:border-[#00F0FF] outline-none text-center" /></div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div><label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5">Category</label><select name="category" defaultValue={editingArticle?.category} className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-white text-sm focus:border-[#00F0FF] outline-none appearance-none"><option value="Inside Flux">Inside Flux</option><option value="Tech Update">Tech Update</option><option value="Event">Event / Tradeshow</option></select></div>
<div>
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5">Cover Image public/news</label>
<div className="flex gap-2">
<input name="coverImage" defaultValue={editingArticle?.coverImage} className="flex-1 bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-[#00F0FF] font-mono text-sm focus:border-[#00F0FF] outline-none" />
<button type="button" onClick={() => setCoverAssetsOpen(true)} className="flex items-center gap-1.5 px-3 py-3 text-xs text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 rounded-xl hover:bg-emerald-500/20 shrink-0"><FolderOpen size={14} /></button>
</div>
</div>
<div><label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5 flex items-center gap-1"><Linkedin size={10}/> LinkedIn URL</label><input name="linkedinUrl" defaultValue={editingArticle?.linkedinUrl} placeholder="https://linkedin.com/..." className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-white text-sm focus:border-[#00F0FF] outline-none" /></div>
</div>
<div><label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5">Short Excerpt (Summary)</label><textarea name="excerpt" defaultValue={editingArticle?.excerpt} required rows={2} className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-white text-sm focus:border-[#00F0FF] outline-none resize-none" /></div>
{/* RICH MARKDOWN EDITOR */}
<div>
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-2 flex justify-between"><span>Full Content (Markdown)</span><span className="text-[#00F0FF]/60 text-[9px] font-normal normal-case">Rich Editor + Assets</span></label>
<MarkdownEditorCyan name="content" defaultValue={editingArticle?.content || ""} required rows={12} placeholder="Write the article here..." slug={articleSlug} />
</div>
{/* MEDIA GALLERY */}
<div className="bg-black/20 border border-white/5 p-4 rounded-xl">
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-3">Media Gallery public/news</label>
<div className="space-y-3 mb-3">
{gallery.map((img, idx) => (
<div key={idx} className="flex gap-2">
<input value={img} onChange={e => { const n = [...gallery]; n[idx] = e.target.value; setGallery(n); }} className="flex-1 bg-black/60 border border-white/10 rounded-lg px-4 py-2 text-[#00F0FF] font-mono text-sm focus:border-[#00F0FF] outline-none" />
<button type="button" onClick={() => setGallery(gallery.filter((_, i) => i !== idx))} className="text-[#86868B] hover:text-red-400 px-3 bg-black/40 rounded-lg"><Trash2 size={14}/></button>
</div>
))}
</div>
<div className="flex gap-2">
<button type="button" onClick={() => setGallery([...gallery, ""])} className="flex-1 border border-dashed border-white/20 text-[#86868B] hover:text-white hover:border-[#00F0FF] py-3 rounded-lg flex justify-center items-center gap-2 text-xs uppercase tracking-widest"><Plus size={14} /> Add Gallery Image</button>
<button type="button" onClick={() => setGalleryAssetsOpen(true)} className="flex items-center gap-1.5 px-4 py-3 text-xs text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 rounded-lg hover:bg-emerald-500/20 font-medium shrink-0"><FolderOpen size={14} /> Browse Assets</button>
</div>
</div>
{/* AI SWITCH */}
<div className="bg-gradient-to-r from-[#00F0FF]/10 to-transparent border border-[#00F0FF]/20 p-4 rounded-xl flex items-center justify-between mt-6">
<div className="flex items-center gap-3"><div className="p-2 bg-[#00F0FF]/20 rounded-lg text-[#00F0FF]"><Sparkles size={18} /></div><div><p className="text-sm text-white font-medium">Flux AI Auto-Translate</p><p className="text-[10px] text-[#86868B] uppercase tracking-widest mt-0.5">IT, VEC, ES, DE</p></div></div>
<label className="relative inline-flex items-center cursor-pointer"><input type="checkbox" name="autoTranslate" defaultChecked className="sr-only peer" /><div className="w-11 h-6 bg-black/50 border border-white/10 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-[#00F0FF]"></div></label>
</div>
</div>
</form>
{/* Asset Managers OUTSIDE the form */}
<AssetManager scope="news" slug={articleSlug} isOpen={coverAssetsOpen} onClose={() => setCoverAssetsOpen(false)} onSelect={(item) => {
const inp = document.querySelector('input[name="coverImage"]') as HTMLInputElement;
if (inp) { inp.value = item.name; inp.dispatchEvent(new Event('input', { bubbles: true })); }
}} accentColor="#00F0FF" />
<AssetManager scope="news" slug={articleSlug} isOpen={galleryAssetsOpen} onClose={() => setGalleryAssetsOpen(false)} onSelect={(item) => {
setGallery(prev => [...prev, item.name]);
}} accentColor="#00F0FF" />
<div className="p-6 border-t border-white/10 bg-[#111] rounded-b-[2rem] flex justify-end gap-3">
<button type="button" onClick={() => setIsModalOpen(false)} className="px-5 py-3 text-sm font-medium text-[#86868B] hover:text-white">Cancel</button>
<button onClick={() => (document.getElementById("news-form") as HTMLFormElement) ?.requestSubmit()} disabled={isSubmitting} className="flex items-center gap-2 bg-[#00F0FF] text-black px-8 py-3 rounded-xl text-sm font-semibold hover:bg-white disabled:opacity-50 shadow-[0_0_15px_rgba(0,240,255,0.3)]">{isSubmitting ? <><Loader2 size={18} className="animate-spin" /> Processing AI...</> : (editingArticle ? "Save & Sync" : "Publish to World")}</button>
</div>
</div>
</div>
)}
</div>
);
}
+193
View File
@@ -0,0 +1,193 @@
// src/app/hq-command/dashboard/page.tsx
// ✅ CORRECCIÓN: Forzamos renderizado dinámico para que Prisma funcione en producción
export const dynamic = "force-dynamic";
import Link from "next/link";
import {
Globe,
Layers,
Users,
ShieldCheck,
Activity,
History,
Newspaper,
BookOpen,
LogOut,
Radar,
Wrench,
Server
} from "lucide-react";
import { prisma } from "@/lib/prisma";
import { logoutAdmin } from "@/app/hq-command/login/actions";
export const revalidate = 0;
export default async function DashboardPage() {
const nodesCount = await prisma.globalNode.count({ where: { isActive: true } });
const appsCount = await prisma.application.count({ where: { isActive: true } });
const modules = [
{
title: "Global Network",
description: "Manage physical installations, nodes, and events on the 3D Holographic Map.",
icon: Globe,
href: "/hq-command/dashboard/network",
color: "text-[#00F0FF]",
bg: "bg-[#00F0FF]/10",
border: "hover:border-[#00F0FF]/50"
},
{
title: "Knowledge Base",
description: "Edit technical literature, datasheets, and applications content dynamically.",
icon: Layers,
href: "/hq-command/dashboard/applications",
color: "text-purple-400",
bg: "bg-purple-500/10",
border: "hover:border-purple-500/50"
},
{
title: "Company Legacy",
description: "Manage the historical timeline, milestones, and Patrizio's story.",
icon: History,
href: "/hq-command/dashboard/timeline",
color: "text-amber-400",
bg: "bg-amber-400/10",
border: "hover:border-amber-400/50"
},
{
title: "Inside Flux",
description: "Manage company news, tech updates, and broadcast to LinkedIn.",
icon: Newspaper,
href: "/hq-command/dashboard/news",
color: "text-[#0A66C2]",
bg: "bg-[#0A66C2]/10",
border: "hover:border-[#0A66C2]/50"
},
{
title: "Our Heritage",
description: "Build the deep history page block by block (Text, Images, Video).",
icon: BookOpen,
href: "/hq-command/dashboard/heritage",
color: "text-white",
bg: "bg-white/10",
border: "hover:border-white/50"
},
{
title: "Component Matrix",
description: "Manage the spare parts catalog, pricing, and SKUs.",
icon: Wrench,
href: "/hq-command/dashboard/parts",
color: "text-amber-500",
bg: "bg-amber-500/10",
border: "hover:border-amber-500/50"
},
{
title: "Signal Hub (Inbox)",
description: "Manage component orders, technical diagnostics, and AI consultations.",
icon: Radar,
href: "/hq-command/dashboard/inbox",
color: "text-rose-500",
bg: "bg-rose-500/10",
border: "hover:border-rose-500/50"
},
{
title: "Access Control",
description: "Create and manage administrator accounts and security credentials.",
icon: Users,
href: "/hq-command/dashboard/users",
color: "text-emerald-400",
bg: "bg-emerald-500/10",
border: "hover:border-emerald-500/50"
},
{
title: "System Health",
description: "Monitor server metrics, database connection, and manage secure data backups.",
icon: Server,
href: "/hq-command/dashboard/health",
color: "text-blue-400",
bg: "bg-blue-400/10",
border: "hover:border-blue-400/50"
}
];
return (
<div className="min-h-screen p-6 md:p-12 max-w-7xl mx-auto">
<div className="flex flex-col md:flex-row justify-between items-start md:items-end gap-6 mb-10">
<div>
<div className="flex items-center gap-2 text-[#00F0FF] mb-2">
<ShieldCheck size={16} />
<span className="text-[10px] uppercase tracking-widest font-bold">Secure Connection</span>
</div>
<h1 className="text-3xl md:text-4xl font-light text-white tracking-tight">
Welcome back, <span className="font-medium">Davidherran.</span>
</h1>
<p className="text-[#86868B] mt-2">FLUX Central Command System is online.</p>
</div>
<form action={logoutAdmin}>
<button type="submit" className="flex items-center gap-2 text-sm text-[#86868B] hover:text-white border border-white/10 px-4 py-2 rounded-lg transition-colors hover:bg-white/5">
<LogOut size={16} /> Terminate Session
</button>
</form>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-12">
<div className="bg-[#111] border border-white/5 p-6 rounded-2xl flex items-center justify-between shadow-lg">
<div>
<span className="text-[10px] uppercase tracking-widest text-[#86868B] block mb-1">System Status</span>
<div className="flex items-center gap-2 text-emerald-400">
<span className="relative flex h-2.5 w-2.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-emerald-500"></span>
</span>
<span className="text-xl font-medium">Optimal</span>
</div>
</div>
<Activity size={32} className="text-white/5" />
</div>
<div className="bg-[#111] border border-white/5 p-6 rounded-2xl flex items-center justify-between shadow-lg">
<div>
<span className="text-[10px] uppercase tracking-widest text-[#86868B] block mb-1">Active Map Nodes</span>
<span className="text-xl font-medium text-white">{nodesCount} Deployments</span>
</div>
<Globe size={32} className="text-white/5" />
</div>
<div className="bg-[#111] border border-white/5 p-6 rounded-2xl flex items-center justify-between shadow-lg">
<div>
<span className="text-[10px] uppercase tracking-widest text-[#86868B] block mb-1">Total Applications</span>
<span className="text-xl font-medium text-white">{appsCount} Categories</span>
</div>
<Layers size={32} className="text-white/5" />
</div>
</div>
<div className="mb-6">
<span className="text-[10px] uppercase tracking-widest text-[#86868B] font-semibold">Core Modules</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{modules.map((module, idx) => (
<Link
key={idx}
href={module.href}
className="group bg-[#111] border border-white/5 p-8 rounded-3xl transition-all duration-300 hover:-translate-y-1 hover:shadow-2xl"
>
<div className={`w-12 h-12 rounded-2xl ${module.bg} ${module.color} flex items-center justify-center mb-6 transition-transform group-hover:scale-110`}>
<module.icon size={24} />
</div>
<h3 className="text-xl font-medium text-white mb-2 flex items-center gap-2">
{module.title}
</h3>
<p className="text-sm text-[#86868B] leading-relaxed">
{module.description}
</p>
</Link>
))}
</div>
</div>
);
}
@@ -0,0 +1,152 @@
"use server";
import { prisma } from "@/lib/prisma";
import { translateContentForCMS } from "@/lib/aiTranslator";
import { revalidatePath } from "next/cache";
export async function getParts() {
try {
const parts = await prisma.sparePart.findMany({
orderBy: { createdAt: "desc" },
});
return { success: true, parts };
} catch (error) {
console.error("Error fetching parts:", error);
return { error: "Error loading component matrix." };
}
}
export async function createPart(formData: FormData) {
try {
const sku = formData.get("sku") as string;
const title = formData.get("title") as string;
if (!sku || !title) return { error: "SKU and Title are required" };
const newPart = await prisma.sparePart.create({
data: {
sku: sku.toUpperCase().replace(/\s+/g, '-'),
title,
description: "Draft description...", // Texto por defecto
},
});
revalidatePath("/hq-command/dashboard/parts");
return { success: true, part: newPart };
} catch (error: any) {
if (error.code === 'P2002') return { error: "A component with this SKU already exists." };
return { error: "Error creating component." };
}
}
export async function updatePart(formData: FormData) {
try {
const id = formData.get("id") as string;
const title = formData.get("title") as string;
const sku = formData.get("sku") as string;
const description = formData.get("description") as string;
// Lógica de Pricing
const rawPrice = formData.get("price") as string;
const price = rawPrice ? parseFloat(rawPrice) : null;
const showPrice = formData.get("showPrice") === "on";
const autoTranslate = formData.get("autoTranslate") === "on";
// JSONs
const mediaJson = formData.get("mediaJson") as string;
const specsJson = formData.get("specsJson") as string;
let updateData: any = {
title,
sku: sku.toUpperCase().replace(/\s+/g, '-'),
description,
price,
showPrice,
mediaJson,
specsJson,
};
// 🔥 MAGIA: Autotraducción con Flux AI si está activada
if (autoTranslate) {
const translations = await translateContentForCMS({ title, description });
if (translations) {
updateData.translationsJson = JSON.stringify(translations);
}
}
await prisma.sparePart.update({
where: { id },
data: updateData,
});
revalidatePath("/hq-command/dashboard/parts");
return { success: true };
} catch (error) {
console.error("Error updating part:", error);
return { error: "Failed to update component data." };
}
}
export async function deletePart(id: string) {
try {
await prisma.sparePart.delete({ where: { id } });
revalidatePath("/hq-command/dashboard/parts");
return { success: true };
} catch (error) {
return { error: "Failed to delete component." };
}
}
export async function togglePartStatus(id: string, currentStatus: boolean) {
try {
await prisma.sparePart.update({
where: { id },
data: { isActive: !currentStatus },
});
revalidatePath("/hq-command/dashboard/parts");
return { success: true };
} catch (error) {
return { error: "Failed to toggle status." };
}
}
// 6. OBTENER EL ENCABEZADO DEL CATÁLOGO
export async function getPartsCatalogHero() {
try {
const content = await prisma.pageContent.findUnique({ where: { slug: "parts-catalog" } });
return { success: true, content };
} catch (error) {
return { error: "Failed to load hero content." };
}
}
// 7. GUARDAR EL ENCABEZADO DEL CATÁLOGO (CON TRADUCCIÓN IA)
export async function updatePartsCatalogHero(formData: FormData) {
const title = formData.get("title") as string;
const subtitle = formData.get("subtitle") as string;
const description = formData.get("description") as string;
const autoTranslate = formData.get("autoTranslate") === "on";
try {
let updateData: any = { title, subtitle, description };
// Si la IA está activa, traducimos los 3 campos a los 4 idiomas
if (autoTranslate) {
const translations = await translateContentForCMS({ title, subtitle, description });
if (translations) updateData.translationsJson = JSON.stringify(translations);
}
// Usamos Upsert: Crea el registro si no existe, o lo actualiza si ya existe
await prisma.pageContent.upsert({
where: { slug: "parts-catalog" },
update: updateData,
create: { slug: "parts-catalog", ...updateData }
});
revalidatePath("/hq-command/dashboard/parts");
revalidatePath("/[locale]/parts", "page"); // Limpia la caché de la vista pública
return { success: true };
} catch (error) {
return { error: "Failed to update hero content." };
}
}
+428
View File
@@ -0,0 +1,428 @@
//src/app/hq-command/dashboard/parts/page.tsx
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import Link from "next/link";
import {
ArrowLeft, Wrench, Plus, Trash2, Loader2, X, Eye, EyeOff, Edit3, Sparkles, DollarSign,
Bold, Italic, Heading1, Heading2, Heading3, List, ListOrdered, Quote, Table, Minus,
RotateCcw, RotateCw, Maximize2, Minimize2, ChevronDown,
FolderOpen, Upload, FolderPlus, ChevronRight, File, ArrowUpFromLine,
Grid3X3, LayoutList, Copy, Check, Image as ImageIcon, Video, Box, Search, Tag
} from "lucide-react";
import { getParts, createPart, updatePart, deletePart, togglePartStatus, getPartsCatalogHero, updatePartsCatalogHero } from "./actions";
// ─────────────────────────────────────────────────────────────────────────────
// ASSET MANAGER — Reusable (scope=parts, /public/parts/{sku}/)
// ─────────────────────────────────────────────────────────────────────────────
interface AssetItem { name: string; type: "file"|"folder"; mediaType?: string; extension?: string; path: string; publicUrl?: string; size?: string; childCount?: number; }
interface AssetManagerProps { slug: string; scope?: string; isOpen: boolean; onClose: () => void; onSelect: (item: { name: string; publicUrl: string; mediaType: string; path: string }) => void; accentColor?: string; initialPath?: string; }
function AssetManager({ slug, scope = "parts", isOpen, onClose, onSelect, accentColor = "#f59e0b", initialPath = "" }: AssetManagerProps) {
const [currentPath, setCurrentPath] = useState(initialPath);
const [items, setItems] = useState<AssetItem[]>([]);
const [breadcrumbs, setBreadcrumbs] = useState<{name: string; path: string}[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string|null>(null);
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState("");
const fileInputRef = useRef<HTMLInputElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [showNewFolder, setShowNewFolder] = useState(false);
const [newFolderName, setNewFolderName] = useState("");
const [viewMode, setViewMode] = useState<"grid"|"list">("grid");
const [searchQuery, setSearchQuery] = useState("");
const [copiedPath, setCopiedPath] = useState<string|null>(null);
const fetchAssets = useCallback(async (dirPath: string = "") => {
setIsLoading(true); setError(null);
try {
const params = new URLSearchParams({ scope, slug, path: dirPath });
const res = await fetch(`/api/assets?${params}`);
const data = await res.json();
if (data.success) { setItems(data.items); setBreadcrumbs(data.breadcrumbs); setCurrentPath(dirPath); }
else setError(data.error);
} catch { setError("Connection error"); }
setIsLoading(false);
}, [scope, slug]);
useEffect(() => { if (isOpen) { fetchAssets(initialPath || currentPath); setSearchQuery(""); } }, [isOpen]); // eslint-disable-line
const uploadFile = async (file: File) => {
setIsUploading(true); setUploadProgress(`Uploading ${file.name}...`);
try {
const fd = new FormData(); fd.append("scope", scope); fd.append("slug", slug); fd.append("path", currentPath); fd.append("file", file);
const res = await fetch("/api/assets", { method: "POST", body: fd });
const data = await res.json();
if (data.success) { setUploadProgress("✓ " + data.file.name); await fetchAssets(currentPath); setTimeout(() => setUploadProgress(""), 2000); }
else { setUploadProgress("✗ " + data.error); setTimeout(() => setUploadProgress(""), 4000); }
} catch { setUploadProgress("✗ Failed"); setTimeout(() => setUploadProgress(""), 3000); }
setIsUploading(false);
};
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => { if (e.target.files) Array.from(e.target.files).forEach(uploadFile); e.target.value = ""; };
const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); setIsDragging(true); };
const handleDragLeave = (e: React.DragEvent) => { e.preventDefault(); setIsDragging(false); };
const handleDrop = (e: React.DragEvent) => { e.preventDefault(); setIsDragging(false); if (e.dataTransfer.files.length > 0) Array.from(e.dataTransfer.files).forEach(uploadFile); };
const createFolder = async () => { if (!newFolderName.trim()) return; try { const res = await fetch("/api/assets", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ scope, slug, folderName: newFolderName, parentPath: currentPath }) }); const data = await res.json(); if (data.success) { setShowNewFolder(false); setNewFolderName(""); await fetchAssets(currentPath); } else alert(data.error); } catch { alert("Error"); } };
const deleteFile = async (fp: string, fn: string) => { if (!confirm('Delete "'+fn+'"?')) return; try { const res = await fetch("/api/assets", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ scope, slug, filePath: fp }) }); const data = await res.json(); if (data.success) await fetchAssets(currentPath); else alert(data.error); } catch { alert("Failed"); } };
const handleSelect = (item: AssetItem, e?: React.MouseEvent) => {
if (e) { e.preventDefault(); e.stopPropagation(); }
if (item.type === "folder") { fetchAssets(item.path); return; }
onSelect({ name: item.name, publicUrl: item.publicUrl || "/"+scope+"/"+slug+"/"+item.path, mediaType: item.mediaType || "unknown", path: item.path });
onClose();
};
const copyPath = (item: AssetItem) => { navigator.clipboard.writeText(item.publicUrl || "/"+scope+"/"+slug+"/"+item.path); setCopiedPath(item.path); setTimeout(() => setCopiedPath(null), 1500); };
const filtered = searchQuery ? items.filter(i => i.name.toLowerCase().includes(searchQuery.toLowerCase())) : items;
const typeBadge = (mt?: string) => ({ image: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20", video: "bg-blue-500/10 text-blue-400 border-blue-500/20", model: "bg-purple-500/10 text-purple-400 border-purple-500/20" }[mt || ""] || "bg-white/5 text-[#86868B] border-white/10");
const renderThumb = (item: AssetItem) => {
if (item.type === "folder") return <div className="w-full h-full flex items-center justify-center bg-white/[0.02]"><FolderOpen size={28} style={{ color: accentColor, opacity: 0.7 }} /></div>;
if (item.mediaType === "image" && item.publicUrl) return <img src={item.publicUrl} alt={item.name} className="w-full h-full object-cover" loading="lazy" />;
return <div className="w-full h-full flex items-center justify-center bg-white/[0.02]"><File size={24} className="text-[#86868B]/50" /></div>;
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[70] flex items-center justify-center p-4 bg-black/85 backdrop-blur-md" onClick={e => { e.preventDefault(); e.stopPropagation(); }}>
<div className="bg-[#0D0D0D] border border-white/10 w-full max-w-4xl rounded-[2rem] shadow-2xl flex flex-col max-h-[85vh] relative overflow-hidden" onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop} onClick={e => e.stopPropagation()}>
{isDragging && <div className="absolute inset-0 z-50 border-2 border-dashed rounded-[2rem] flex items-center justify-center backdrop-blur-sm" style={{ borderColor: accentColor+"80", background: accentColor+"15" }}><div className="text-center"><ArrowUpFromLine size={48} style={{ color: accentColor }} className="mx-auto mb-3 animate-bounce" /><p style={{ color: accentColor }} className="font-medium text-lg">Drop files</p></div></div>}
<div className="px-6 py-5 border-b border-white/10 shrink-0">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3"><div className="p-2 rounded-xl" style={{ background: accentColor+"20", color: accentColor }}><FolderOpen size={20} /></div><div><h3 className="text-lg font-medium text-white">Asset Manager</h3><p className="text-[10px] text-[#86868B] uppercase tracking-widest font-mono">/public/{scope}/{slug}/</p></div></div>
<button onClick={onClose} className="text-[#86868B] hover:text-white p-2 hover:bg-white/5 rounded-xl"><X size={20} /></button>
</div>
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-1 text-sm overflow-x-auto [scrollbar-width:none] flex-1 min-w-0">{breadcrumbs.map((c, i) => (<span key={i} className="flex items-center shrink-0">{i > 0 && <ChevronRight size={12} className="text-[#86868B]/50 mx-0.5" />}<button onClick={() => fetchAssets(c.path)} className={`px-2 py-1 rounded-lg text-xs ${i === breadcrumbs.length-1 ? "bg-white/10" : "text-[#86868B] hover:text-white hover:bg-white/5"}`} style={i === breadcrumbs.length-1 ? { color: accentColor } : {}}>{c.name}</button></span>))}</div>
<div className="flex items-center gap-2 shrink-0">
<div className="relative"><Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[#86868B]" /><input value={searchQuery} onChange={e => setSearchQuery(e.target.value)} placeholder="Filter..." className="bg-white/5 border border-white/10 rounded-lg pl-8 pr-3 py-1.5 text-xs text-white w-32 focus:w-48 transition-all outline-none" /></div>
<button onClick={() => setShowNewFolder(!showNewFolder)} className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-[#86868B] hover:text-white bg-white/5 border border-white/10 rounded-lg"><FolderPlus size={13} /> Folder</button>
<button onClick={() => fileInputRef.current?.click()} disabled={isUploading} className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-black rounded-lg font-medium disabled:opacity-50" style={{ background: accentColor }}><Upload size={13} /> Upload</button>
<input ref={fileInputRef} type="file" multiple accept=".jpg,.jpeg,.png,.webp,.gif,.svg,.avif,.pdf" onChange={handleFileSelect} className="hidden" />
</div>
</div>
{showNewFolder && <div className="flex items-center gap-2 mt-3 pt-3 border-t border-white/5"><FolderPlus size={14} style={{ color: accentColor }} /><input value={newFolderName} onChange={e => setNewFolderName(e.target.value)} onKeyDown={e => { if (e.key === "Enter") createFolder(); if (e.key === "Escape") { setShowNewFolder(false); setNewFolderName(""); } }} placeholder="folder-name" autoFocus className="flex-1 bg-black/40 border border-white/10 rounded-lg px-3 py-1.5 text-sm text-white outline-none font-mono" /><button onClick={createFolder} className="px-3 py-1.5 text-xs text-black rounded-lg font-medium" style={{ background: accentColor }}>Create</button><button onClick={() => { setShowNewFolder(false); setNewFolderName(""); }} className="px-3 py-1.5 text-xs text-[#86868B]">Cancel</button></div>}
{uploadProgress && <div className="mt-3 pt-3 border-t border-white/5 flex items-center gap-2 text-xs">{isUploading && <Loader2 size={12} className="animate-spin" style={{ color: accentColor }} />}<span className={uploadProgress.startsWith("✓") ? "text-emerald-400" : uploadProgress.startsWith("✗") ? "text-red-400" : ""} style={isUploading ? { color: accentColor } : {}}>{uploadProgress}</span></div>}
</div>
<div className="flex-1 overflow-y-auto p-4 [scrollbar-width:none]">
{isLoading ? <div className="flex items-center justify-center py-20"><Loader2 size={24} className="animate-spin" style={{ color: accentColor }} /></div>
: error ? <div className="text-center py-20"><X size={32} className="text-red-400/50 mx-auto mb-3" /><p className="text-red-400/80 text-sm">{error}</p></div>
: filtered.length === 0 ? <div className="text-center py-20"><FolderOpen size={48} className="text-[#86868B]/20 mx-auto mb-4" />{searchQuery ? <p className="text-[#86868B] text-sm">No matches</p> : <p className="text-[#86868B] text-sm">Empty upload product photos here</p>}</div>
: <div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-3">{filtered.map(item => (
<div key={item.path} className="group relative bg-[#111] border border-white/5 rounded-xl overflow-hidden hover:border-white/20 cursor-pointer" onClick={e => handleSelect(item, e)}>
<div className="aspect-square overflow-hidden bg-black/20">{renderThumb(item)}</div>
<div className="p-2"><p className="text-[11px] text-white truncate font-medium">{item.name}</p><div className="flex items-center justify-between mt-1">{item.type === "folder" ? <span className="text-[9px] text-[#86868B]">{item.childCount} items</span> : <span className={`text-[9px] px-1.5 py-0.5 rounded border ${typeBadge(item.mediaType)}`}>{item.mediaType}</span>}{item.size && <span className="text-[9px] text-[#86868B]">{item.size}</span>}</div></div>
{item.type === "file" && <div className="absolute top-1 right-1 flex gap-1 opacity-0 group-hover:opacity-100"><button onClick={e => { e.stopPropagation(); copyPath(item); }} className="p-1.5 bg-black/70 rounded-lg text-[#86868B] hover:text-white">{copiedPath === item.path ? <Check size={11} className="text-emerald-400" /> : <Copy size={11} />}</button><button onClick={e => { e.stopPropagation(); deleteFile(item.path, item.name); }} className="p-1.5 bg-black/70 rounded-lg text-[#86868B] hover:text-red-400"><Trash2 size={11} /></button></div>}
</div>
))}</div>}
</div>
<div className="px-6 py-3 border-t border-white/10 flex items-center justify-between text-[10px] text-[#86868B] shrink-0 bg-black/20"><span>{filtered.length} items Click to select</span><span className="font-mono">Drag & drop</span></div>
</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// MARKDOWN EDITOR — Amber-themed for Parts descriptions
// ─────────────────────────────────────────────────────────────────────────────
function MarkdownEditorAmber({ name, defaultValue = "", required, rows = 8, placeholder, slug }: {
name: string; defaultValue?: string; required?: boolean; rows?: number; placeholder?: string; slug?: string;
}) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [value, setValue] = useState(defaultValue);
const [isExpanded, setIsExpanded] = useState(false);
const [showInsertMenu, setShowInsertMenu] = useState(false);
const [history, setHistory] = useState<string[]>([defaultValue]);
const [historyIndex, setHistoryIndex] = useState(0);
const insertMenuRef = useRef<HTMLDivElement>(null);
const [isAssetManagerOpen, setIsAssetManagerOpen] = useState(false);
useEffect(() => { const h = (e: MouseEvent) => { if (insertMenuRef.current && !insertMenuRef.current.contains(e.target as Node)) setShowInsertMenu(false); }; document.addEventListener("mousedown", h); return () => document.removeEventListener("mousedown", h); }, []);
const historyTimeout = useRef<NodeJS.Timeout | null>(null);
const pushHistory = useCallback((v: string) => { if (historyTimeout.current) clearTimeout(historyTimeout.current); historyTimeout.current = setTimeout(() => { setHistory(p => [...p.slice(0, historyIndex + 1), v].slice(-50)); setHistoryIndex(p => Math.min(p + 1, 49)); }, 500); }, [historyIndex]);
const handleChange = (v: string) => { setValue(v); pushHistory(v); };
const undo = () => { if (historyIndex > 0) { const i = historyIndex-1; setHistoryIndex(i); setValue(history[i]); } };
const redo = () => { if (historyIndex < history.length-1) { const i = historyIndex+1; setHistoryIndex(i); setValue(history[i]); } };
const getSelection = () => { const ta = textareaRef.current; if (!ta) return { start: 0, end: 0, selected: "", before: "", after: "" }; return { start: ta.selectionStart, end: ta.selectionEnd, selected: value.substring(ta.selectionStart, ta.selectionEnd), before: value.substring(0, ta.selectionStart), after: value.substring(ta.selectionEnd) }; };
const replaceSelection = (t: string, o?: number) => { const { before, after } = getSelection(); handleChange(before + t + after); const p = o !== undefined ? before.length + o : before.length + t.length; setTimeout(() => { const ta = textareaRef.current; if (ta) { ta.focus(); ta.setSelectionRange(p, p); } }, 0); };
const wrapSelection = (pre: string, suf: string) => { const { selected } = getSelection(); if (selected) replaceSelection(pre+selected+suf); else replaceSelection(pre+"text"+suf, pre.length); };
const insertAtCursor = (t: string, o?: number) => replaceSelection(t, o);
const prependLine = (pre: string) => { const { start, selected } = getSelection(); const ls = value.lastIndexOf('\n', start-1)+1; const bef = value.substring(0, ls); const line = selected || value.substring(ls).split('\n')[0]; const aft = value.substring(ls+line.length); handleChange(bef+pre+line+aft); setTimeout(() => { const ta = textareaRef.current; if (ta) { ta.focus(); ta.setSelectionRange(ls+pre.length, ls+pre.length+line.length); } }, 0); };
const handleAssetInsert = (item: { publicUrl: string; mediaType: string; name: string }) => { insertAtCursor("\n!["+item.name+"]("+item.publicUrl+")\n"); };
const actions = {
bold: () => wrapSelection("**","**"), italic: () => wrapSelection("*","*"),
h1: () => prependLine("# "), h2: () => prependLine("## "), h3: () => prependLine("### "),
quote: () => prependLine("> "),
ul: () => insertAtCursor("\n- Item 1\n- Item 2\n", 3),
ol: () => insertAtCursor("\n1. First\n2. Second\n", 4),
hr: () => insertAtCursor("\n---\n"),
table: () => insertAtCursor("\n| Spec | Value |\n|---|---|\n| Material | Steel |\n", 2),
image: () => insertAtCursor("\n![Photo](/parts/"+(slug||"sku")+"/photo.jpg)\n", 3),
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => { const m = e.metaKey||e.ctrlKey; if (m && e.key==='b') { e.preventDefault(); actions.bold(); } if (m && e.key==='i') { e.preventDefault(); actions.italic(); } if (m && e.key==='z') { e.preventDefault(); e.shiftKey ? redo() : undo(); } if (e.key === 'Tab') { e.preventDefault(); insertAtCursor(" "); } };
const ToolBtn = ({ icon: Icon, label, onClick, className="" }: { icon: any; label: string; onClick: () => void; className?: string }) => (<button type="button" onClick={onClick} title={label} className={"p-1.5 rounded-lg text-[#86868B] hover:text-white hover:bg-white/10 transition-all active:scale-90 "+className}><Icon size={15} strokeWidth={2} /></button>);
const Divider = () => <div className="w-px h-5 bg-white/10 mx-1" />;
return (
<div className={"flex flex-col "+(isExpanded ? "fixed inset-0 z-[60] bg-[#0A0A0A] p-6" : "")}>
<input type="hidden" name={name} value={value} />
<div className={"flex flex-wrap items-center gap-0.5 px-2 py-1.5 bg-black/60 border border-white/10 rounded-t-xl "+(isExpanded ? "border-b-0 rounded-t-2xl" : "")}>
<ToolBtn icon={Bold} label="Bold" onClick={actions.bold} /><ToolBtn icon={Italic} label="Italic" onClick={actions.italic} /><Divider />
<ToolBtn icon={Heading2} label="H2" onClick={actions.h2} /><ToolBtn icon={Heading3} label="H3" onClick={actions.h3} /><Divider />
<ToolBtn icon={List} label="Bullet" onClick={actions.ul} /><ToolBtn icon={ListOrdered} label="Numbered" onClick={actions.ol} />
<ToolBtn icon={Quote} label="Quote" onClick={actions.quote} /><ToolBtn icon={Table} label="Table" onClick={actions.table} /><Divider />
<div className="relative" ref={insertMenuRef}>
<button type="button" onClick={() => setShowInsertMenu(!showInsertMenu)} className="flex items-center gap-1 px-2 py-1.5 rounded-lg text-amber-400 hover:bg-amber-500/10 text-[11px] font-semibold uppercase tracking-wider"><Plus size={13} /> Insert <ChevronDown size={11} className={"transition-transform "+(showInsertMenu ? "rotate-180" : "")} /></button>
{showInsertMenu && <div className="absolute top-full left-0 mt-1 w-52 bg-[#1A1A1A] border border-white/10 rounded-xl shadow-2xl z-50 overflow-hidden"><div className="p-1">
<button type="button" onClick={() => { actions.image(); setShowInsertMenu(false); }} className="w-full flex items-center gap-3 px-3 py-2.5 text-white/80 hover:bg-white/5 rounded-lg text-left"><div className="p-1.5 bg-emerald-500/15 rounded-lg text-emerald-400"><ImageIcon size={14} /></div><div><p className="text-xs font-medium">Image</p></div></button>
<button type="button" onClick={() => { actions.table(); setShowInsertMenu(false); }} className="w-full flex items-center gap-3 px-3 py-2.5 text-white/80 hover:bg-white/5 rounded-lg text-left"><div className="p-1.5 bg-amber-500/15 rounded-lg text-amber-400"><Table size={14} /></div><div><p className="text-xs font-medium">Spec Table</p></div></button>
</div></div>}
</div>
{slug && (<><Divider /><button type="button" onClick={() => setIsAssetManagerOpen(true)} className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-emerald-400 hover:bg-emerald-500/10 text-[11px] font-semibold uppercase tracking-wider"><FolderOpen size={14} /> Assets</button></>)}
<div className="flex-1" />
<ToolBtn icon={RotateCcw} label="Undo" onClick={undo} className={historyIndex<=0?"opacity-30 pointer-events-none":""} />
<ToolBtn icon={RotateCw} label="Redo" onClick={redo} className={historyIndex>=history.length-1?"opacity-30 pointer-events-none":""} />
<Divider />
<ToolBtn icon={isExpanded ? Minimize2 : Maximize2} label="Fullscreen" onClick={() => setIsExpanded(!isExpanded)} />
</div>
<textarea ref={textareaRef} value={value} onChange={e => handleChange(e.target.value)} onKeyDown={handleKeyDown} required={required} rows={isExpanded ? 40 : rows} placeholder={placeholder} className={"w-full bg-black/40 border border-white/10 border-t-0 p-4 text-white font-mono text-sm focus:border-amber-500 outline-none resize-none leading-relaxed "+(isExpanded ? "flex-1 rounded-b-2xl text-base" : "rounded-b-xl")} style={{ tabSize: 2 }} />
<div className="flex items-center justify-between mt-1.5 px-1"><div className="flex items-center gap-3 text-[10px] text-[#86868B] font-mono"><span>{value.split('\n').length} lines</span><span className="text-white/10">|</span><span>{value.length} chars</span></div></div>
{slug && <AssetManager scope="parts" slug={slug} isOpen={isAssetManagerOpen} onClose={() => setIsAssetManagerOpen(false)} onSelect={handleAssetInsert} accentColor="#f59e0b" />}
</div>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// MAIN PAGE — Parts Manager (Component Matrix CMS)
// ─────────────────────────────────────────────────────────────────────────────
export default function PartsManager() {
const [parts, setParts] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState("");
const [editingPart, setEditingPart] = useState<any|null>(null);
const [media, setMedia] = useState<string[]>([]);
const [specs, setSpecs] = useState<{label:string;value:string}[]>([]);
const [mediaAssetsOpen, setMediaAssetsOpen] = useState(false);
// 🔥 ESTADOS PARA EL HERO MODAL
const [isHeroModalOpen, setIsHeroModalOpen] = useState(false);
const [heroData, setHeroData] = useState<any | null>(null);
// 🔥 FETCH INICIAL UNIFICADO
const fetchInitialData = async () => {
setIsLoading(true);
const [resParts, resHero] = await Promise.all([getParts(), getPartsCatalogHero()]);
if (resParts.success && resParts.parts) setParts(resParts.parts);
if (resHero.success && resHero.content) setHeroData(resHero.content);
setIsLoading(false);
};
useEffect(() => { fetchInitialData(); }, []);
const openEdit = (part: any) => {
setEditingPart(part);
try { setMedia(JSON.parse(part.mediaJson || "[]")); } catch { setMedia([]); }
try { setSpecs(JSON.parse(part.specsJson || "[]")); } catch { setSpecs([]); }
};
const handleCreate = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); setIsSubmitting(true); setError("");
const res = await createPart(new FormData(e.currentTarget));
if (res.error) setError(res.error);
else { setIsCreateOpen(false); fetchInitialData(); }
setIsSubmitting(false);
};
const handleSave = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); setIsSubmitting(true); setError("");
const formData = new FormData(e.currentTarget);
formData.append("mediaJson", JSON.stringify(media.filter(m => m.trim())));
formData.append("specsJson", JSON.stringify(specs.filter(s => s.label.trim() || s.value.trim())));
const res = await updatePart(formData);
if (res.error) setError(res.error);
else { setEditingPart(null); fetchInitialData(); }
setIsSubmitting(false);
};
// 🔥 GUARDAR HERO
const handleSaveHero = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); setIsSubmitting(true); setError("");
const res = await updatePartsCatalogHero(new FormData(e.currentTarget));
if (res.error) setError(res.error);
else { setIsHeroModalOpen(false); fetchInitialData(); }
setIsSubmitting(false);
};
const partSlug = editingPart?.sku?.toLowerCase().replace(/\s+/g, '-') || "new-part";
return (
<div className="min-h-screen p-6 md:p-12 max-w-7xl mx-auto">
<div className="mb-10">
<Link href="/hq-command/dashboard" className="inline-flex items-center gap-2 text-sm text-[#86868B] hover:text-amber-400 transition-colors mb-6 group"><ArrowLeft size={16} className="group-hover:-translate-x-1 transition-transform" /> Back to Command Center</Link>
<div className="flex flex-col md:flex-row justify-between items-start md:items-end gap-6">
<div><h1 className="text-3xl font-light text-white flex items-center gap-3"><Wrench className="text-amber-400" /> Component Matrix</h1><p className="text-[#86868B] mt-2">Manage spare parts, pricing, specs, and product media.</p></div>
<div className="flex items-center gap-3">
<button onClick={() => setIsHeroModalOpen(true)} className="flex items-center gap-2 bg-white/5 text-white border border-white/10 px-5 py-2.5 rounded-xl font-medium hover:bg-white/10 transition-all">
<Edit3 size={18} /> Edit Page Hero
</button>
<button onClick={() => setIsCreateOpen(true)} className="flex items-center gap-2 bg-amber-500/10 text-amber-400 border border-amber-500/20 px-5 py-2.5 rounded-xl font-medium hover:bg-amber-500 hover:text-black transition-all">
<Plus size={18} /> New Component
</button>
</div>
</div>
</div>
{error && <div className="mb-8 p-4 bg-red-500/10 border border-red-500/20 rounded-xl text-red-400 text-sm">{error}</div>}
{/* TABLE */}
<div className="bg-[#111] border border-white/5 rounded-3xl overflow-hidden shadow-2xl"><div className="overflow-x-auto"><table className="w-full text-left border-collapse"><thead><tr className="border-b border-white/5 text-[10px] uppercase tracking-widest text-[#86868B] bg-black/40"><th className="p-6 font-semibold">Component / SKU</th><th className="p-6 font-semibold">Price</th><th className="p-6 font-semibold">Status</th><th className="p-6 font-semibold text-right">Actions</th></tr></thead>
<tbody>
{isLoading ? <tr><td colSpan={4} className="p-12 text-center text-[#86868B]"><Loader2 className="animate-spin mx-auto mb-2" size={24} /> Loading matrix...</td></tr>
: parts.length === 0 ? <tr><td colSpan={4} className="p-12 text-center text-[#86868B]">No components registered.</td></tr>
: parts.map(part => (
<tr key={part.id} className={`border-b border-white/5 transition-colors group ${!part.isActive ? 'opacity-50' : 'hover:bg-white/[0.02]'}`}>
<td className="p-6"><p className="font-medium text-white">{part.title}</p><p className="text-xs text-amber-400/70 font-mono mt-1">{part.sku}</p></td>
<td className="p-6">{part.showPrice && part.price ? <span className="text-white font-mono">{part.price.toFixed(2)}</span> : <span className="text-[9px] text-[#86868B] uppercase tracking-widest">Quote Based</span>}</td>
<td className="p-6"><button onClick={async () => { await togglePartStatus(part.id, part.isActive); fetchInitialData(); }} className={`text-xs font-medium px-3 py-1 rounded-full flex items-center gap-1.5 ${part.isActive ? 'bg-emerald-500/10 text-emerald-400' : 'bg-red-500/10 text-red-400'}`}>{part.isActive ? <><Eye size={12} /> Active</> : <><EyeOff size={12} /> Hidden</>}</button></td>
<td className="p-6 text-right"><div className="flex justify-end gap-2"><button onClick={() => openEdit(part)} className="text-[#86868B] hover:text-amber-400 hover:bg-amber-400/10 p-2 rounded-lg opacity-0 group-hover:opacity-100"><Edit3 size={18} /></button><button onClick={async () => { if (confirm("Delete?")) { await deletePart(part.id); fetchInitialData(); } }} className="text-[#86868B] hover:text-red-400 hover:bg-red-400/10 p-2 rounded-lg opacity-0 group-hover:opacity-100"><Trash2 size={18} /></button></div></td>
</tr>
))}
</tbody></table></div></div>
{/* CREATE MODAL */}
{isCreateOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm">
<div className="bg-[#111] border border-white/10 w-full max-w-lg rounded-[2rem] p-8 relative shadow-2xl">
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-transparent via-amber-500 to-transparent"></div>
<button onClick={() => setIsCreateOpen(false)} className="absolute top-6 right-6 text-[#86868B] hover:text-white"><X size={20} /></button>
<h3 className="text-2xl font-light mb-6 text-amber-400 flex items-center gap-2"><Wrench size={24} /> New Component</h3>
<form onSubmit={handleCreate} className="space-y-4">
<div><label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5">SKU (Unique ID)</label><input name="sku" required placeholder="e.g. FLXD-60A-BELT" className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-amber-400 font-mono text-sm focus:border-amber-500 outline-none uppercase" /></div>
<div><label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5">Component Name</label><input name="title" required placeholder="e.g. Drive Belt Assembly" className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-white text-sm focus:border-amber-500 outline-none" /></div>
<button type="submit" disabled={isSubmitting} className="w-full bg-amber-500 text-black py-3 mt-2 rounded-xl text-sm font-semibold hover:bg-amber-400 flex items-center justify-center gap-2">{isSubmitting ? <Loader2 size={16} className="animate-spin" /> : "Register Component"}</button>
</form>
</div>
</div>
)}
{/* EDIT MODAL — Component Data Editor */}
{editingPart && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm">
<div className="bg-[#111] border border-white/10 w-full max-w-4xl rounded-[2rem] p-0 relative shadow-2xl flex flex-col max-h-[90vh]">
<div className="p-6 md:p-8 border-b border-white/10 relative">
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-transparent via-amber-500 to-transparent"></div>
<button onClick={() => setEditingPart(null)} className="absolute top-6 right-6 text-[#86868B] hover:text-white"><X size={20} /></button>
<h3 className="text-2xl font-light text-amber-400 flex items-center gap-2"><Wrench size={24} /> Component Editor</h3>
<p className="text-[#86868B] text-xs font-mono uppercase tracking-widest mt-1">SKU: {editingPart.sku}</p>
</div>
<form id="parts-form" onSubmit={handleSave} className="flex-1 overflow-y-auto p-6 md:p-8 [scrollbar-width:none]">
<input type="hidden" name="id" value={editingPart.id} />
<div className="space-y-6">
{/* Basic Info */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div><label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5">Component Name</label><input name="title" defaultValue={editingPart.title} required className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-white text-lg font-medium focus:border-amber-500 outline-none" /></div>
<div><label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5">SKU</label><input name="sku" defaultValue={editingPart.sku} required className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-amber-400 font-mono text-sm focus:border-amber-500 outline-none uppercase" /></div>
</div>
{/* Pricing */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div><label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5 flex items-center gap-1"><DollarSign size={10} /> Price (EUR)</label><input name="price" type="number" step="0.01" min="0" defaultValue={editingPart.price || ""} placeholder="e.g. 125.00" className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-white font-mono text-sm focus:border-amber-500 outline-none" /></div>
<div className="flex items-end pb-3"><label className="flex items-center gap-3 text-sm text-white cursor-pointer"><input type="checkbox" name="showPrice" defaultChecked={editingPart.showPrice} className="accent-amber-500 w-5 h-5" /><span>Show price publicly</span></label></div>
</div>
{/* Description with MarkdownEditor */}
<div>
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-2 flex justify-between"><span>Description (Markdown)</span><span className="text-amber-400/60 text-[9px] font-normal normal-case">Rich Editor + Assets</span></label>
<MarkdownEditorAmber name="description" defaultValue={editingPart.description || ""} required rows={8} placeholder="Technical description, compatibility notes..." slug={partSlug} />
</div>
{/* Product Media */}
<div className="bg-black/20 border border-white/5 p-4 rounded-xl">
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-3 flex items-center gap-2"><ImageIcon size={12} /> Product Photos public/parts/{partSlug}</label>
{media.map((img, idx) => (
<div key={idx} className="flex gap-2 mb-3">
<input value={img} onChange={e => { const n = [...media]; n[idx] = e.target.value; setMedia(n); }} placeholder="e.g. front-view.jpg" className="flex-1 bg-black/60 border border-white/10 rounded-lg px-4 py-2 text-amber-400 font-mono text-sm focus:border-amber-500 outline-none" />
<button type="button" onClick={() => setMedia(media.filter((_,i) => i !== idx))} className="text-[#86868B] hover:text-red-400 px-3 bg-black/40 rounded-lg"><Trash2 size={14} /></button>
</div>
))}
<div className="flex gap-2">
<button type="button" onClick={() => setMedia([...media, ""])} className="flex-1 border border-dashed border-white/20 text-[#86868B] hover:text-white hover:border-amber-500 py-3 rounded-lg flex justify-center items-center gap-2 text-xs"><Plus size={14} /> Add Photo</button>
<button type="button" onClick={() => setMediaAssetsOpen(true)} className="flex items-center gap-1.5 px-4 py-3 text-xs text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 rounded-lg hover:bg-emerald-500/20 font-medium shrink-0"><FolderOpen size={14} /> Browse Assets</button>
</div>
</div>
{/* Technical Specs */}
<div className="bg-black/20 border border-white/5 p-4 rounded-xl">
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-3 flex items-center gap-2"><Tag size={12} /> Technical Specifications</label>
{specs.map((spec, idx) => (
<div key={idx} className="flex gap-2 mb-3">
<input value={spec.label} onChange={e => { const n = [...specs]; n[idx].label = e.target.value; setSpecs(n); }} placeholder="e.g. Material" className="w-1/3 bg-black/60 border border-white/10 rounded-lg px-3 py-2 text-[#86868B] text-xs uppercase tracking-wider font-semibold outline-none" />
<input value={spec.value} onChange={e => { const n = [...specs]; n[idx].value = e.target.value; setSpecs(n); }} placeholder="e.g. Stainless Steel 304" className="flex-1 bg-black/60 border border-white/10 rounded-lg px-3 py-2 text-white text-sm outline-none" />
<button type="button" onClick={() => setSpecs(specs.filter((_,i) => i !== idx))} className="text-[#86868B] hover:text-red-400 px-2"><Trash2 size={14} /></button>
</div>
))}
<button type="button" onClick={() => setSpecs([...specs, { label: "", value: "" }])} className="w-full border border-dashed border-white/20 text-[#86868B] hover:text-white hover:border-amber-500 py-3 rounded-lg flex justify-center items-center gap-2 text-xs"><Plus size={14} /> Add Spec</button>
</div>
{/* AI Switch */}
<div className="bg-gradient-to-r from-amber-500/10 to-transparent border border-amber-500/20 p-4 rounded-xl flex items-center justify-between">
<div className="flex items-center gap-3"><div className="p-2 bg-amber-500/20 rounded-lg text-amber-400"><Sparkles size={18} /></div><div><p className="text-sm text-white font-medium">Flux AI Auto-Translate</p><p className="text-[10px] text-[#86868B] uppercase tracking-widest mt-0.5">IT, VEC, ES, DE</p></div></div>
<label className="relative inline-flex items-center cursor-pointer"><input type="checkbox" name="autoTranslate" defaultChecked className="sr-only peer" /><div className="w-11 h-6 bg-black/50 border border-white/10 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-amber-500"></div></label>
</div>
</div>
</form>
{/* Asset Manager OUTSIDE the form */}
<AssetManager scope="parts" slug={partSlug} isOpen={mediaAssetsOpen} onClose={() => setMediaAssetsOpen(false)} onSelect={(item) => { setMedia(prev => [...prev, item.name]); }} accentColor="#f59e0b" />
<div className="p-6 border-t border-white/10 bg-[#111] rounded-b-[2rem] flex justify-end gap-3">
<button type="button" onClick={() => setEditingPart(null)} className="px-5 py-3 text-sm font-medium text-[#86868B] hover:text-white">Cancel</button>
<button onClick={() => (document.getElementById("parts-form") as HTMLFormElement)?.requestSubmit()} disabled={isSubmitting} className="flex items-center gap-2 bg-amber-500 text-black px-8 py-3 rounded-xl text-sm font-semibold hover:bg-amber-400 disabled:opacity-50 shadow-[0_0_15px_rgba(245,158,11,0.3)]">{isSubmitting ? <><Loader2 size={18} className="animate-spin" /> Processing...</> : "Save Component Data"}</button>
</div>
</div>
</div>
)}
{/* 🔥 MODAL PARA EDITAR EL ENCABEZADO DE LA PÁGINA 🔥 */}
{isHeroModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm">
<div className="bg-[#111] border border-white/10 w-full max-w-lg rounded-[2rem] p-8 relative shadow-2xl">
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-transparent via-white to-transparent"></div>
<button onClick={() => setIsHeroModalOpen(false)} className="absolute top-6 right-6 text-[#86868B] hover:text-white"><X size={20} /></button>
<h3 className="text-2xl font-light mb-6 text-white flex items-center gap-2"><Edit3 size={24} /> Catalog Hero Text</h3>
<p className="text-xs text-[#86868B] mb-6">Modify the main title and description of the public Spare Parts page.</p>
<form onSubmit={handleSaveHero} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5">Title Prefix</label><input name="title" defaultValue={heroData?.title || "Component"} required className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-white text-sm focus:border-white outline-none" /></div>
<div><label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5">Title Emphasis</label><input name="subtitle" defaultValue={heroData?.subtitle || "Matrix."} required className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-white text-sm focus:border-white outline-none" /></div>
</div>
<div><label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5">Description</label><textarea name="description" defaultValue={heroData?.description || ""} rows={3} required className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-white text-sm focus:border-white outline-none resize-none" /></div>
<div className="bg-gradient-to-r from-white/5 to-transparent border border-white/10 p-4 rounded-xl flex items-center justify-between mt-6">
<div className="flex items-center gap-3"><div className="p-2 bg-white/10 rounded-lg text-white"><Sparkles size={18} /></div><div><p className="text-sm text-white font-medium">Flux AI Auto-Translate</p><p className="text-[10px] text-[#86868B] uppercase tracking-widest mt-0.5">IT, VEC, ES, DE</p></div></div>
<label className="relative inline-flex items-center cursor-pointer"><input type="checkbox" name="autoTranslate" defaultChecked className="sr-only peer" /><div className="w-11 h-6 bg-black/50 border border-white/10 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-white"></div></label>
</div>
<button type="submit" disabled={isSubmitting} className="w-full bg-white text-black py-3 mt-4 rounded-xl text-sm font-bold hover:bg-gray-200 flex items-center justify-center gap-2">{isSubmitting ? <Loader2 size={16} className="animate-spin" /> : "Save Hero Content"}</button>
</form>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,123 @@
"use server";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
// 🔥 IMPORTAMOS EL TRADUCTOR DE IA
import { translateContentForCMS } from "@/lib/aiTranslator";
// 1. OBTENER HITOS HISTÓRICOS
export async function getTimelineEvents() {
try {
const events = await prisma.timelineEvent.findMany({
orderBy: { order: 'asc' }
});
return { success: true, events };
} catch (error) {
return { error: "Failed to fetch timeline events." };
}
}
// 2. CREAR NUEVO HITO CON IA
export async function createTimelineEvent(formData: FormData) {
try {
const year = formData.get("year") as string;
const title = formData.get("title") as string;
const description = formData.get("description") as string;
const order = parseInt(formData.get("order") as string) || 0;
// Capturamos el switch de la IA
const autoTranslate = formData.get("autoTranslate") === "on";
if (!year || !title || !description) return { error: "All fields are required." };
let translationsJson = "{}";
// 🔥 LA MAGIA DE LA IA 🔥
if (autoTranslate) {
const aiResult = await translateContentForCMS({ title, description });
if (aiResult) translationsJson = JSON.stringify(aiResult);
}
await prisma.timelineEvent.create({
data: { year, title, description, order, isActive: true, translationsJson }
});
revalidatePath("/hq-command/dashboard/timeline");
revalidatePath("/[locale]", "layout");
return { success: true };
} catch (error) {
return { error: "Failed to create milestone." };
}
}
// 3. ACTUALIZAR HITO EXISTENTE CON IA
export async function updateTimelineEvent(formData: FormData) {
try {
const id = formData.get("id") as string;
const year = formData.get("year") as string;
const title = formData.get("title") as string;
const description = formData.get("description") as string;
const order = parseInt(formData.get("order") as string) || 0;
const autoTranslate = formData.get("autoTranslate") === "on";
if (!id || !year || !title || !description) return { error: "All fields are required." };
let updateData: any = { year, title, description, order };
// 🔥 LA MAGIA DE LA IA 🔥
if (autoTranslate) {
const aiResult = await translateContentForCMS({ title, description });
if (aiResult) updateData.translationsJson = JSON.stringify(aiResult);
}
await prisma.timelineEvent.update({
where: { id },
data: updateData
});
revalidatePath("/hq-command/dashboard/timeline");
revalidatePath("/[locale]", "layout");
return { success: true };
} catch (error) {
return { error: "Failed to update milestone." };
}
}
// 4. ELIMINAR HITO
export async function deleteTimelineEvent(id: string) {
try {
await prisma.timelineEvent.delete({ where: { id } });
revalidatePath("/hq-command/dashboard/timeline");
revalidatePath("/[locale]", "layout");
return { success: true };
} catch (error) {
return { error: "Failed to delete milestone." };
}
}
// 5. LA SEMILLA
export async function seedTimeline() {
try {
const count = await prisma.timelineEvent.count();
if (count > 0) return { error: "Timeline already has data." };
const milestones = [
{ year: "1978", title: "The Foundation", description: "Patrizio Grando establishes the core engineering principles that would define our solid-state RF technology.", order: 1 },
{ year: "1995", title: "Industrial Scale", description: "First global deployments of volumetric heating systems for the textile industry, setting new efficiency standards.", order: 2 },
{ year: "2010", title: "Sector Expansion", description: "Adapting our proprietary RF technology to food processing, rubber vulcanization, and advanced materials.", order: 3 },
{ year: "2026", title: "The Next Era", description: "FLUX introduces the next generation of smart, AI-monitored RF systems with unparalleled energy efficiency.", order: 4 }
];
for (const m of milestones) {
await prisma.timelineEvent.create({ data: m });
}
revalidatePath("/hq-command/dashboard/timeline");
revalidatePath("/[locale]", "layout");
return { success: true };
} catch (error) {
return { error: "Failed to seed timeline." };
}
}
@@ -0,0 +1,209 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { ArrowLeft, History, Plus, Trash2, Loader2, X, DatabaseZap, Clock, Edit3, Sparkles } from "lucide-react";
import { getTimelineEvents, createTimelineEvent, updateTimelineEvent, deleteTimelineEvent, seedTimeline } from "./actions";
export default function TimelineManager() {
const [events, setEvents] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isSeeding, setIsSeeding] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [editingEvent, setEditingEvent] = useState<any | null>(null);
const [error, setError] = useState("");
const fetchEvents = async () => {
setIsLoading(true);
const res = await getTimelineEvents();
if (res.success && res.events) setEvents(res.events);
setIsLoading(false);
};
useEffect(() => { fetchEvents(); }, []);
const handleSeed = async () => {
setIsSeeding(true); setError("");
const res = await seedTimeline();
if (res.error) setError(res.error); else await fetchEvents();
setIsSeeding(false);
};
const openCreateModal = () => {
setEditingEvent(null);
setIsModalOpen(true);
};
const openEditModal = (event: any) => {
setEditingEvent(event);
setIsModalOpen(true);
};
const handleSave = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsSubmitting(true); setError("");
const formData = new FormData(e.currentTarget);
let res;
if (editingEvent) {
res = await updateTimelineEvent(formData);
} else {
res = await createTimelineEvent(formData);
}
if (res.error) {
setError(res.error);
} else {
setIsModalOpen(false);
fetchEvents();
}
setIsSubmitting(false);
};
const handleDelete = async (id: string) => {
if (confirm("Are you sure you want to delete this historical milestone?")) {
await deleteTimelineEvent(id);
fetchEvents();
}
};
return (
<div className="min-h-screen p-6 md:p-12 max-w-7xl mx-auto">
{/* HEADER */}
<div className="mb-10">
<Link href="/hq-command/dashboard" className="inline-flex items-center gap-2 text-sm text-[#86868B] hover:text-amber-400 transition-colors mb-6 group">
<ArrowLeft size={16} className="group-hover:-translate-x-1 transition-transform" /> Back to Command Center
</Link>
<div className="flex flex-col md:flex-row justify-between items-start md:items-end gap-6">
<div>
<h1 className="text-3xl font-light text-white flex items-center gap-3">
<History className="text-amber-400" /> Company Legacy
</h1>
<p className="text-[#86868B] mt-2">Manage historical milestones and the timeline of FLUX.</p>
</div>
<div className="flex gap-3">
{!isLoading && events.length === 0 && (
<button onClick={handleSeed} disabled={isSeeding} className="flex items-center gap-2 bg-amber-500/10 text-amber-400 border border-amber-500/20 px-5 py-2.5 rounded-xl font-medium hover:bg-amber-500 hover:text-black transition-all">
{isSeeding ? <Loader2 size={18} className="animate-spin" /> : <DatabaseZap size={18} />} Seed History
</button>
)}
<button onClick={openCreateModal} className="flex items-center gap-2 bg-white text-black px-5 py-2.5 rounded-xl font-medium hover:bg-gray-200 transition-all">
<Plus size={18} /> Add Milestone
</button>
</div>
</div>
</div>
{error && <div className="mb-8 p-4 bg-red-500/10 border border-red-500/20 rounded-xl text-red-400 text-sm">{error}</div>}
{/* TIMELINE LIST */}
<div className="bg-[#111] border border-white/5 rounded-3xl overflow-hidden shadow-2xl p-6 md:p-10">
{isLoading ? (
<div className="py-12 text-center text-[#86868B]"><Loader2 className="animate-spin mx-auto mb-2" size={24} /> Loading legacy data...</div>
) : events.length === 0 ? (
<div className="py-12 text-center"><Clock size={48} className="mx-auto text-amber-400/30 mb-4" /><p className="text-[#86868B]">No milestones recorded yet.</p></div>
) : (
<div className="space-y-6 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-white/10 before:to-transparent">
{events.map((event) => (
<div key={event.id} className="relative flex items-center justify-between md:justify-normal md:odd:flex-row-reverse group is-active">
{/* Timeline Dot */}
<div className="flex items-center justify-center w-10 h-10 rounded-full border border-white/20 bg-[#111] text-amber-400 shadow 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-amber-400 rounded-full"></div>
</div>
{/* Content Card */}
<div className="w-[calc(100%-4rem)] md:w-[calc(50%-3rem)] bg-black/40 border border-white/5 p-6 rounded-2xl hover:border-amber-400/30 transition-colors relative">
{/* Botones de acción */}
<div className="absolute top-4 right-4 flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button onClick={() => openEditModal(event)} className="text-[#86868B] hover:text-amber-400 p-1"><Edit3 size={16}/></button>
<button onClick={() => handleDelete(event.id)} className="text-[#86868B] hover:text-red-400 p-1"><Trash2 size={16}/></button>
</div>
<span className="text-amber-400 font-mono text-sm tracking-widest bg-amber-400/10 px-3 py-1 rounded-full inline-block mb-3">{event.year}</span>
<h4 className="text-xl text-white font-medium mb-2">{event.title}</h4>
<p className="text-[#86868B] text-sm leading-relaxed line-clamp-3">{event.description}</p>
<p className="text-[10px] text-white/20 mt-4 uppercase tracking-widest">Order: {event.order}</p>
</div>
</div>
))}
</div>
)}
</div>
{/* CREATE / EDIT MODAL */}
{isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm">
<div className="bg-[#111] border border-white/10 w-full max-w-3xl rounded-[2rem] p-0 relative shadow-2xl flex flex-col max-h-[90vh]">
<div className="p-6 md:p-8 border-b border-white/10 relative shrink-0">
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-transparent via-amber-400 to-transparent"></div>
<button onClick={() => setIsModalOpen(false)} className="absolute top-6 right-6 text-[#86868B] hover:text-white transition-colors"><X size={20} /></button>
<h3 className="text-2xl font-light text-amber-400">
{editingEvent ? "Edit Milestone" : "Add New Milestone"}
</h3>
</div>
<form id="timeline-form" onSubmit={handleSave} className="flex-1 overflow-y-auto p-6 md:p-8 [scrollbar-width:none]">
{editingEvent && <input type="hidden" name="id" value={editingEvent.id} />}
<div className="grid grid-cols-2 gap-4 mb-6">
<div>
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5">Year / Date</label>
<input name="year" type="text" defaultValue={editingEvent?.year} required placeholder="e.g., 2026" className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-amber-400 font-mono text-sm focus:border-amber-400 outline-none" />
</div>
<div>
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5">Timeline Position (1, 2, 3)</label>
<input name="order" type="number" defaultValue={editingEvent?.order} required placeholder="e.g., 5" className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-white text-sm focus:border-amber-400 outline-none" />
</div>
</div>
<div className="mb-6">
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5">Milestone Title</label>
<input name="title" type="text" defaultValue={editingEvent?.title} required placeholder="e.g., Next-Gen E-Dryer" className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-white text-sm focus:border-amber-400 outline-none" />
</div>
<div>
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-2 flex justify-between items-center">
<span>Description (Markdown Supported)</span>
</label>
<textarea name="description" defaultValue={editingEvent?.description} required rows={6} placeholder="Write the history here..." className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-4 text-white font-mono text-sm focus:border-amber-400 outline-none resize-none leading-relaxed mb-3" />
<div className="bg-amber-400/5 border border-amber-400/20 rounded-xl p-4 text-xs text-[#86868B] font-mono leading-relaxed">
<p className="text-amber-400 font-semibold uppercase tracking-widest mb-2 text-[10px]">Markdown Cheat Sheet:</p>
<p><strong>**Bold**</strong> &nbsp;|&nbsp; <strong>*Italic*</strong> &nbsp;|&nbsp; <strong>- List Item</strong></p>
<p className="mt-1"><strong>&gt; Blockquote</strong></p>
</div>
</div>
{/* 🔥 SWITCH DE LA IA 🔥 */}
<div className="bg-gradient-to-r from-amber-400/10 to-transparent border border-amber-400/20 p-4 rounded-xl flex items-center justify-between mt-6">
<div className="flex items-center gap-3">
<div className="p-2 bg-amber-400/20 rounded-lg text-amber-400"><Sparkles size={18} /></div>
<div>
<p className="text-sm text-white font-medium">Flux AI Auto-Translate</p>
<p className="text-[10px] text-[#86868B] uppercase tracking-widest mt-0.5">Translates Markdown to IT, VEC, ES, DE</p>
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input type="checkbox" name="autoTranslate" defaultChecked className="sr-only peer" />
<div className="w-11 h-6 bg-black/50 border border-white/10 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-amber-400"></div>
</label>
</div>
</form>
<div className="p-6 border-t border-white/10 bg-[#111] rounded-b-[2rem] flex justify-end gap-3 shrink-0">
<button type="button" onClick={() => setIsModalOpen(false)} className="px-5 py-3 text-sm font-medium text-[#86868B] hover:text-white transition-colors">Cancel</button>
<button onClick={() => (document.getElementById("timeline-form") as HTMLFormElement)?.requestSubmit()} disabled={isSubmitting} className="flex items-center gap-2 bg-amber-400 text-black px-8 py-3 rounded-xl text-sm font-semibold hover:bg-white transition-colors disabled:opacity-50 shadow-[0_0_15px_rgba(251,191,36,0.3)]">
{isSubmitting ? <><Loader2 size={18} className="animate-spin" /> Processing AI...</> : "Publish to Legacy"}
</button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,112 @@
"use server";
import { prisma } from "@/lib/prisma";
import bcrypt from "bcryptjs";
import speakeasy from "speakeasy";
import QRCode from "qrcode";
import { revalidatePath } from "next/cache";
// 1. OBTENER TODOS LOS USUARIOS
export async function getUsers() {
try {
const users = await prisma.adminUser.findMany({
select: {
id: true,
username: true,
email: true, // 🔥 Ahora también pedimos el correo
is2FAEnabled: true,
createdAt: true,
},
orderBy: { createdAt: "asc" }
});
return { success: true, users };
} catch (error) {
return { error: "Failed to fetch users." };
}
}
// 2. CREAR UN NUEVO USUARIO Y GENERAR SU QR
export async function createUser(formData: FormData) {
const username = formData.get("username") as string;
const email = formData.get("email") as string; // 🔥 Nuevo
const password = formData.get("password") as string;
if (!username || username.length < 4) return { error: "Username must be at least 4 characters." };
if (!password || password.length < 8) return { error: "Password must be at least 8 characters." };
try {
const existing = await prisma.adminUser.findUnique({ where: { username: username.toLowerCase().trim() } });
if (existing) return { error: "Username already exists." };
const hashedPassword = await bcrypt.hash(password, 12);
const secret = speakeasy.generateSecret({ name: `FLUX HQ (${username})` });
if (!secret.otpauth_url) throw new Error("Failed to generate OTP URL");
const qrCodeUrl = await QRCode.toDataURL(secret.otpauth_url, {
color: { dark: '#000000', light: '#FFFFFF' },
margin: 2
});
await prisma.adminUser.create({
data: {
username: username.toLowerCase().trim(),
email: email ? email.toLowerCase().trim() : null, // 🔥 Guardamos el correo
passwordHash: hashedPassword,
twoFactorSecret: secret.base32,
is2FAEnabled: true,
}
});
revalidatePath("/hq-command/dashboard/users");
return { success: true, qrCodeUrl, secret: secret.base32 };
} catch (error) {
return { error: "An error occurred while creating the user." };
}
}
// 3. EDITAR USUARIO (Correo y/o Contraseña) 🔥 NUEVA FUNCIÓN
export async function updateUser(formData: FormData) {
const id = formData.get("id") as string;
const email = formData.get("email") as string;
const newPassword = formData.get("newPassword") as string;
try {
// Preparamos el objeto de datos a actualizar
const updateData: any = {
email: email ? email.toLowerCase().trim() : null,
};
// Si el administrador escribió una nueva contraseña, la encriptamos
if (newPassword && newPassword.trim().length > 0) {
if (newPassword.length < 8) return { error: "New password must be at least 8 characters." };
updateData.passwordHash = await bcrypt.hash(newPassword, 12);
}
await prisma.adminUser.update({
where: { id },
data: updateData,
});
revalidatePath("/hq-command/dashboard/users");
return { success: true };
} catch (error) {
console.error("Update User Error:", error);
return { error: "Failed to update user." };
}
}
// 4. ELIMINAR UN USUARIO
export async function deleteUser(id: string) {
try {
const count = await prisma.adminUser.count();
if (count <= 1) return { error: "Security Lock: You cannot delete the last administrator." };
await prisma.adminUser.delete({ where: { id } });
revalidatePath("/hq-command/dashboard/users");
return { success: true };
} catch (error) {
return { error: "Failed to delete user." };
}
}
+305
View File
@@ -0,0 +1,305 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import Image from "next/image";
import { ArrowLeft, Users, Plus, Trash2, ShieldCheck, KeyRound, Loader2, X, Settings } from "lucide-react";
import { getUsers, createUser, deleteUser, updateUser } from "./actions";
export default function UsersManager() {
const [users, setUsers] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
// Estados para el Modal de Creación
const [isModalOpen, setIsModalOpen] = useState(false);
// Estados para el Modal de Edición 🔥 NUEVO
const [editingUser, setEditingUser] = useState<any | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState("");
// Estados para el QR de éxito
const [newQr, setNewQr] = useState<string | null>(null);
const [newSecret, setNewSecret] = useState<string | null>(null);
const fetchUsers = async () => {
setIsLoading(true);
const res = await getUsers();
if (res.success && res.users) {
setUsers(res.users);
}
setIsLoading(false);
};
useEffect(() => {
fetchUsers();
}, []);
// -- FUNCIÓN DE CREACIÓN --
const handleCreateUser = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsSubmitting(true);
setError("");
const formData = new FormData(e.currentTarget);
const res = await createUser(formData);
if (res.error) {
setError(res.error);
} else if (res.success && res.qrCodeUrl) {
setNewQr(res.qrCodeUrl);
setNewSecret(res.secret || null);
fetchUsers();
}
setIsSubmitting(false);
};
// -- FUNCIÓN DE EDICIÓN 🔥 NUEVO --
const handleEditUser = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsSubmitting(true);
setError("");
const formData = new FormData(e.currentTarget);
const res = await updateUser(formData);
if (res.error) {
setError(res.error);
} else {
setEditingUser(null);
fetchUsers(); // Refrescamos la tabla para ver el nuevo correo
}
setIsSubmitting(false);
};
const handleDelete = async (id: string) => {
if (confirm("Are you sure you want to revoke this architect's access? This cannot be undone.")) {
const res = await deleteUser(id);
if (res.error) alert(res.error);
else fetchUsers();
}
};
const closeAndResetModal = () => {
setIsModalOpen(false);
setNewQr(null);
setNewSecret(null);
setError("");
};
return (
<div className="min-h-screen p-6 md:p-12 max-w-7xl mx-auto">
{/* HEADER Y NAVEGACIÓN */}
<div className="mb-10">
<Link href="/hq-command/dashboard" className="inline-flex items-center gap-2 text-sm text-[#86868B] hover:text-[#00F0FF] transition-colors mb-6 group">
<ArrowLeft size={16} className="group-hover:-translate-x-1 transition-transform" /> Back to Command Center
</Link>
<div className="flex flex-col md:flex-row justify-between items-start md:items-end gap-6">
<div>
<h1 className="text-3xl font-light text-white flex items-center gap-3">
<Users className="text-emerald-400" /> Access Control
</h1>
<p className="text-[#86868B] mt-2">Manage system architects, emails, and 2FA credentials.</p>
</div>
<button
onClick={() => setIsModalOpen(true)}
className="flex items-center gap-2 bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 px-5 py-2.5 rounded-xl font-medium hover:bg-emerald-500 hover:text-black transition-all"
>
<Plus size={18} /> Provision New Access
</button>
</div>
</div>
{/* TABLA DE USUARIOS */}
<div className="bg-[#111] border border-white/5 rounded-3xl overflow-hidden shadow-2xl">
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="border-b border-white/5 text-[10px] uppercase tracking-widest text-[#86868B] bg-black/40">
<th className="p-6 font-semibold">Architect</th>
<th className="p-6 font-semibold">Email</th>
<th className="p-6 font-semibold">Security Level</th>
<th className="p-6 font-semibold text-right">Actions</th>
</tr>
</thead>
<tbody>
{isLoading ? (
<tr>
<td colSpan={4} className="p-8 text-center text-[#86868B]">
<Loader2 className="animate-spin mx-auto mb-2" size={24} /> Loading secure data...
</td>
</tr>
) : (
users.map((user) => (
<tr key={user.id} className="border-b border-white/5 hover:bg-white/[0.02] transition-colors group">
<td className="p-6 font-medium text-white flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-white/10 flex items-center justify-center text-xs">
{user.username.charAt(0).toUpperCase()}
</div>
{user.username}
</td>
<td className="p-6 text-sm text-[#86868B]">
{user.email || <span className="italic opacity-50">Not set</span>}
</td>
<td className="p-6">
{user.is2FAEnabled ? (
<span className="inline-flex items-center gap-1.5 text-xs font-medium text-emerald-400 bg-emerald-400/10 px-3 py-1 rounded-full">
<ShieldCheck size={14} /> 2FA Active
</span>
) : (
<span className="inline-flex items-center gap-1.5 text-xs font-medium text-red-400 bg-red-400/10 px-3 py-1 rounded-full">
Unsecured
</span>
)}
</td>
<td className="p-6 text-right">
{/* 🔥 BOTONERA DE ACCIONES 🔥 */}
<div className="flex items-center justify-end gap-2">
<button
onClick={() => setEditingUser(user)}
className="text-[#86868B] hover:text-emerald-400 hover:bg-emerald-400/10 p-2 rounded-lg transition-colors opacity-0 group-hover:opacity-100"
title="Edit User"
>
<Settings size={18} />
</button>
<button
onClick={() => handleDelete(user.id)}
className="text-[#86868B] hover:text-red-400 hover:bg-red-400/10 p-2 rounded-lg transition-colors opacity-0 group-hover:opacity-100"
title="Revoke Access"
>
<Trash2 size={18} />
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* ────────────────────────────────────────────────────────── */}
{/* MODAL DE EDICIÓN DE USUARIO 🔥 NUEVO */}
{/* ────────────────────────────────────────────────────────── */}
{editingUser && (
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm">
<div className="bg-[#111] border border-white/10 w-full max-w-md rounded-[2rem] p-8 relative shadow-2xl">
<button onClick={() => setEditingUser(null)} className="absolute top-6 right-6 text-[#86868B] hover:text-white transition-colors">
<X size={20} />
</button>
<h3 className="text-2xl font-light mb-2 text-emerald-400 flex items-center gap-2">
<Settings size={22} /> Edit Profile
</h3>
<p className="text-[#86868B] text-sm mb-8">Update email or set a new password for <strong className="text-white">{editingUser.username}</strong>.</p>
<form onSubmit={handleEditUser} className="space-y-5">
<input type="hidden" name="id" value={editingUser.id} />
<div>
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-2 font-semibold">Contact Email</label>
<input
name="email"
type="email"
defaultValue={editingUser.email || ""}
placeholder="e.g., admin@fluxsrl.com"
className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-white text-sm focus:border-emerald-400 focus:bg-black transition-all outline-none"
/>
</div>
<div>
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-2 font-semibold">New Password (Optional)</label>
<input
name="newPassword"
type="password"
placeholder="Leave blank to keep current"
className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-white text-sm focus:border-emerald-400 focus:bg-black transition-all outline-none"
/>
<p className="text-[10px] text-[#86868B] mt-2">Note: Changing the password does not invalidate the existing 2FA Google Authenticator code.</p>
</div>
{error && <p className="text-red-400 text-xs text-center bg-red-400/10 py-2 rounded-lg">{error}</p>}
<button type="submit" disabled={isSubmitting} className="w-full flex items-center justify-center gap-2 bg-emerald-500 text-black py-3 mt-4 rounded-xl text-sm font-semibold hover:bg-emerald-400 transition-colors disabled:opacity-50">
{isSubmitting ? <Loader2 size={18} className="animate-spin" /> : "Save Changes"}
</button>
</form>
</div>
</div>
)}
{/* ────────────────────────────────────────────────────────── */}
{/* MODAL DE CREACIÓN / MOSTRAR QR */}
{/* ────────────────────────────────────────────────────────── */}
{isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm">
<div className="bg-[#111] border border-white/10 w-full max-w-md rounded-[2rem] p-8 relative shadow-2xl">
{!newQr && (
<button onClick={closeAndResetModal} className="absolute top-6 right-6 text-[#86868B] hover:text-white transition-colors">
<X size={20} />
</button>
)}
{newQr ? (
// PANTALLA DE ÉXITO (QR CODE)
<div className="flex flex-col items-center text-center">
<div className="w-16 h-16 rounded-full bg-emerald-500/10 text-emerald-400 flex items-center justify-center mb-6">
<KeyRound size={32} />
</div>
<h3 className="text-2xl font-light mb-2">Access Granted</h3>
<p className="text-[#86868B] text-sm mb-6">
Account created successfully. <strong className="text-white">Have the new architect scan this QR code immediately.</strong> It will never be shown again.
</p>
<div className="bg-white p-4 rounded-2xl mb-6">
<Image src={newQr} alt="2FA QR" width={200} height={200} className="rounded-lg" />
</div>
<div className="bg-black/50 border border-white/5 p-4 rounded-xl w-full mb-8">
<span className="text-[10px] text-[#86868B] uppercase tracking-widest block mb-1">Manual Key</span>
<code className="text-emerald-400 text-sm font-mono break-all">{newSecret}</code>
</div>
<button onClick={closeAndResetModal} className="w-full bg-white text-black py-3 rounded-xl font-semibold hover:bg-gray-200 transition-colors">
Acknowledge & Close
</button>
</div>
) : (
// PANTALLA DE FORMULARIO
<>
<h3 className="text-2xl font-light mb-2 text-emerald-400">Provision Access</h3>
<p className="text-[#86868B] text-sm mb-8">Create a new architect credential for the FLUX CMS.</p>
<form onSubmit={handleCreateUser} className="space-y-5">
<div>
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-2 font-semibold">Username</label>
<input name="username" type="text" required placeholder="e.g., patrizio" className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-white text-sm focus:border-emerald-400 focus:bg-black transition-all outline-none" />
</div>
<div>
{/* 🔥 NUEVO CAMPO EN CREACIÓN: Email Opcional */}
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-2 font-semibold">Email (Optional)</label>
<input name="email" type="email" placeholder="e.g., patrizio@fluxsrl.com" className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-white text-sm focus:border-emerald-400 focus:bg-black transition-all outline-none" />
</div>
<div>
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-2 font-semibold">Temporary Password</label>
<input name="password" type="password" required placeholder="••••••••••••" className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-white text-sm focus:border-emerald-400 focus:bg-black transition-all outline-none" />
</div>
{error && <p className="text-red-400 text-xs text-center bg-red-400/10 py-2 rounded-lg">{error}</p>}
<button type="submit" disabled={isSubmitting} className="w-full flex items-center justify-center gap-2 bg-emerald-500 text-black py-3 mt-4 rounded-xl text-sm font-semibold hover:bg-emerald-400 transition-colors disabled:opacity-50">
{isSubmitting ? <Loader2 size={18} className="animate-spin" /> : "Generate Credentials"}
</button>
</form>
</>
)}
</div>
</div>
)}
</div>
);
}
+30
View File
@@ -0,0 +1,30 @@
import type { Metadata } from "next";
import "@/app/globals.css";
export const metadata: Metadata = {
title: "FLUX Command Center",
description: "CMS and Administration",
robots: "noindex, nofollow", // Evita que Google indexe el CMS
};
export default function HQLayout({ children }: { children: React.ReactNode }) {
return (
// 🔥 El CMS siempre estará en inglés y forzado en modo oscuro
<html lang="en" className="dark">
{/* Mantenemos tu fondo negro absoluto, texto blanco y el color de selección cyan */}
<body className="min-h-screen bg-[#050505] text-[#F5F5F7] antialiased selection:bg-[#00F0FF] selection:text-black">
{/* Patrón de puntos sutil en el fondo para dar aspecto técnico */}
<div
className="fixed inset-0 opacity-[0.03] pointer-events-none"
style={{ backgroundImage: 'radial-gradient(circle at 2px 2px, white 1px, transparent 0)', backgroundSize: '32px 32px' }}
></div>
<main className="relative z-10">
{children}
</main>
</body>
</html>
);
}
+66
View File
@@ -0,0 +1,66 @@
"use server";
import { prisma } from "@/lib/prisma";
import bcrypt from "bcryptjs";
import speakeasy from "speakeasy";
import { createSession, deleteSession } from "@/lib/session";
import { redirect } from "next/navigation";
export async function loginAdmin(formData: FormData) {
const username = formData.get("username") as string;
const password = formData.get("password") as string;
const token2FA = formData.get("token2FA") as string;
if (!username || !password || !token2FA) {
return { error: "All fields are required." };
}
try {
// 1. Buscamos al usuario en la base de datos
const user = await prisma.adminUser.findUnique({
where: { username: username.toLowerCase().trim() }
});
if (!user) {
return { error: "Invalid credentials." }; // Mensaje genérico por seguridad
}
// 2. Verificamos la contraseña encriptada
const isPasswordValid = await bcrypt.compare(password, user.passwordHash);
if (!isPasswordValid) {
return { error: "Invalid credentials." };
}
// 3. Verificamos el código de Google Authenticator (El Token de 6 dígitos)
if (user.is2FAEnabled && user.twoFactorSecret) {
const isTokenValid = speakeasy.totp.verify({
secret: user.twoFactorSecret,
encoding: "base32",
token: token2FA,
window: 1 // Permite un margen de 30 segundos
});
if (!isTokenValid) {
return { error: "Invalid 2FA code. Please check your Authenticator app." };
}
} else {
return { error: "Security Error: 2FA is not enabled for this account." };
}
// 4. Si pasamos todas las pruebas, creamos la sesión
await createSession(user.id, user.username);
} catch (error) {
console.error("Login Error:", error);
return { error: "An internal error occurred." };
}
// 5. Redireccionamos al Dashboard
redirect("/hq-command/dashboard");
}
// ── DESTRUCTOR DE SESIÓN (NUEVO) ──
export async function logoutAdmin() {
await deleteSession();
redirect("/hq-command/login");
}
+137
View File
@@ -0,0 +1,137 @@
"use client";
import { useState, useRef } from "react";
import { ShieldAlert, User, Lock, KeyRound, ArrowRight, Loader2 } from "lucide-react";
import { loginAdmin } from "./actions";
export default function LoginHQ() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const formRef = useRef<HTMLFormElement>(null);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setLoading(true);
setError("");
const formData = new FormData(e.currentTarget);
const result = await loginAdmin(formData);
if (result && result.error) {
setError(result.error);
setLoading(false);
}
};
// 🔥 MAGIA UX: Si el token llega a 6 dígitos, disparamos el login automáticamente
const handleTokenChange = (e: React.ChangeEvent<HTMLInputElement>) => {
e.target.value = e.target.value.replace(/\D/g, ''); // Solo números
if (e.target.value.length === 6) {
setTimeout(() => {
formRef.current?.requestSubmit();
}, 150);
}
};
return (
<div className="min-h-screen bg-[#050505] flex flex-col items-center justify-center p-6 relative overflow-hidden">
{/* Fondo enigmático */}
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_center,rgba(0,240,255,0.05)_0%,transparent_50%)] pointer-events-none" />
<div className="w-full max-w-md relative z-10 animate-in fade-in zoom-in-95 duration-700 ease-out">
<div className="flex flex-col items-center justify-center mb-10">
<div className="w-16 h-16 rounded-full border border-[#00F0FF]/20 bg-[#00F0FF]/5 flex items-center justify-center mb-4 shadow-[0_0_30px_rgba(0,240,255,0.15)] backdrop-blur-sm">
<ShieldAlert className="text-[#00F0FF]" size={28} />
</div>
<h1 className="text-3xl font-light text-white tracking-tight">FLUX <span className="font-medium">Command</span></h1>
<p className="text-[#00F0FF] text-xs uppercase tracking-widest mt-2 font-semibold">Restricted Area</p>
</div>
<div className="bg-[#111]/80 backdrop-blur-2xl border border-white/10 p-8 md:p-10 rounded-[2.5rem] shadow-2xl relative overflow-hidden">
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-transparent via-[#00F0FF]/80 to-transparent" />
<form ref={formRef} onSubmit={handleSubmit} className="space-y-5 relative z-10">
<div>
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-2 font-semibold">Username</label>
<div className="relative">
<User size={16} className="absolute left-4 top-1/2 -translate-y-1/2 text-[#86868B]" />
<input
type="text"
name="username"
required
placeholder="Admin ID"
className="w-full bg-black/60 border border-white/10 rounded-xl pl-11 pr-4 py-3.5 text-white text-sm focus:border-[#00F0FF] focus:bg-black transition-all outline-none"
autoFocus
/>
</div>
</div>
<div>
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-2 font-semibold">Password</label>
<div className="relative">
<Lock size={16} className="absolute left-4 top-1/2 -translate-y-1/2 text-[#86868B]" />
<input
type="password"
name="password"
required
placeholder="••••••••••••"
className="w-full bg-black/60 border border-white/10 rounded-xl pl-11 pr-4 py-3.5 text-white text-sm focus:border-[#00F0FF] focus:bg-black transition-all outline-none"
/>
</div>
</div>
<div className="pt-2">
<label className="block text-[10px] uppercase tracking-widest text-[#00F0FF] mb-2 font-semibold flex items-center gap-1.5">
<KeyRound size={12} /> 2FA Authentication
</label>
<div className="relative">
<input
type="text"
name="token2FA"
required
maxLength={6}
pattern="\d*"
placeholder="000000"
autoComplete="one-time-code"
onChange={handleTokenChange}
disabled={loading}
className="w-full bg-black/60 border border-[#00F0FF]/30 rounded-xl px-4 py-3.5 text-[#00F0FF] text-center text-xl tracking-[0.5em] focus:border-[#00F0FF] focus:bg-black focus:shadow-[0_0_20px_rgba(0,240,255,0.1)] transition-all outline-none font-mono placeholder:text-[#00F0FF]/20 disabled:opacity-50"
/>
</div>
<p className="text-[10px] text-[#86868B] text-center mt-3">Enter the 6-digit code from your Authenticator app</p>
</div>
{error && (
<div className="bg-red-500/10 border border-red-500/20 rounded-xl p-3 text-center animate-in zoom-in-95 duration-300">
<p className="text-red-400 text-xs font-medium">{error}</p>
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full flex items-center justify-center gap-2 bg-white text-black py-4 mt-4 rounded-xl text-sm font-bold uppercase tracking-widest hover:bg-[#00F0FF] transition-all disabled:opacity-30 disabled:cursor-not-allowed group shadow-lg"
>
{loading ? (
<><Loader2 size={18} className="animate-spin" /> Verifying Protocol...</>
) : (
<>Authorize Access <ArrowRight size={16} className="group-hover:translate-x-1 transition-transform" /></>
)}
</button>
</form>
<div className="mt-8 pt-6 border-t border-white/5 text-center">
<p className="text-[9px] text-[#86868B] uppercase tracking-widest font-mono">
IP Logged Unauthorized access is strictly prohibited
</p>
</div>
</div>
</div>
</div>
);
}
+49
View File
@@ -0,0 +1,49 @@
"use server";
import { prisma } from "@/lib/prisma";
import bcrypt from "bcryptjs";
import speakeasy from "speakeasy";
import QRCode from "qrcode";
export async function initializeAdmin(username: string, password: string) {
try {
// 1. VERIFICACIÓN DE SEGURIDAD
const adminCount = await prisma.adminUser.count();
if (adminCount > 0) {
return { error: "SECURITY LOCK: System already initialized. Use the internal CMS User Management to add more admins." };
}
// 2. Encriptación
const hashedPassword = await bcrypt.hash(password, 12);
// 3. Generación de la Llave Maestra con Speakeasy
const secret = speakeasy.generateSecret({
name: `FLUX HQ (${username})` // Así aparecerá en Google Authenticator
});
// Generamos el QR a partir de la URL de Speakeasy
if (!secret.otpauth_url) throw new Error("Failed to generate OTP URL");
const qrCodeUrl = await QRCode.toDataURL(secret.otpauth_url, {
color: { dark: '#000000', light: '#FFFFFF' },
margin: 2
});
// 4. Guardamos al PRIMER usuario maestro
await prisma.adminUser.create({
data: {
username: username.toLowerCase().trim(),
passwordHash: hashedPassword,
twoFactorSecret: secret.base32, // Guardamos la llave en Base32
is2FAEnabled: true,
}
});
return { qrCodeUrl, secret: secret.base32 };
} catch (error) {
console.error("Setup Error:", error);
return { error: "An internal server error occurred during setup." };
}
}
+149
View File
@@ -0,0 +1,149 @@
"use client";
import { useState } from "react";
import { ShieldCheck, QrCode, Lock, ArrowRight, Loader2, User } from "lucide-react";
import Image from "next/image";
import { initializeAdmin } from "./actions";
export default function SetupHQ() {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [qrCode, setQrCode] = useState<string | null>(null);
const [secret, setSecret] = useState<string | null>(null);
const [error, setError] = useState("");
const handleSetup = async (e: React.FormEvent) => {
e.preventDefault();
if (password.length < 8) {
setError("Password must be at least 8 characters.");
return;
}
if (username.length < 4) {
setError("Username must be at least 4 characters.");
return;
}
setLoading(true);
setError("");
try {
const result = await initializeAdmin(username, password);
if (result.error) {
setError(result.error);
} else if (result.qrCodeUrl && result.secret) {
setQrCode(result.qrCodeUrl);
setSecret(result.secret);
}
} catch (err) {
setError("An unexpected error occurred.");
} finally {
setLoading(false);
}
};
// --- PANTALLA DE ÉXITO (Código QR) ---
if (qrCode) {
return (
<div className="min-h-screen flex items-center justify-center p-6">
<div className="w-full max-w-md bg-[#111] border border-white/10 p-8 rounded-[2rem] shadow-2xl flex flex-col items-center text-center relative overflow-hidden">
<div className="absolute top-0 w-full h-1 bg-gradient-to-r from-transparent via-[#00F0FF] to-transparent"></div>
<div className="w-16 h-16 rounded-full bg-emerald-500/10 text-emerald-400 flex items-center justify-center mb-6">
<ShieldCheck size={32} />
</div>
<h1 className="text-3xl font-light mb-2">Master Key Generated</h1>
<p className="text-[#86868B] text-sm mb-8">
Scan this QR code with Google Authenticator or Authy. This is the only time it will be shown.
</p>
<div className="bg-white p-4 rounded-2xl mb-6">
<Image src={qrCode} alt="2FA QR Code" width={200} height={200} className="rounded-lg" />
</div>
<div className="bg-black/50 border border-white/5 p-4 rounded-xl w-full mb-8">
<span className="text-xs text-[#86868B] uppercase tracking-widest block mb-1">Manual Setup Key</span>
<code className="text-[#00F0FF] text-lg font-mono break-all">{secret}</code>
</div>
<button
onClick={() => window.location.href = "/hq-command/login"}
className="w-full flex items-center justify-center gap-2 bg-white text-black py-3 rounded-xl font-medium hover:bg-gray-200 transition-colors"
>
Proceed to Login <ArrowRight size={18} />
</button>
</div>
</div>
);
}
// --- PANTALLA DEL FORMULARIO INICIAL ---
return (
<div className="min-h-screen flex flex-col items-center justify-center p-6">
<div className="w-full max-w-md">
<div className="flex items-center justify-center gap-3 mb-10">
<ShieldCheck className="text-[#00F0FF]" size={32} />
<h1 className="text-3xl font-light tracking-tight">FLUX <span className="font-medium">Command</span></h1>
</div>
<div className="bg-[#111] border border-white/10 p-8 md:p-10 rounded-[2.5rem] shadow-2xl relative overflow-hidden">
{/* Luz superior decorativa */}
<div className="absolute top-0 left-0 w-full h-32 bg-gradient-to-b from-white/[0.03] to-transparent pointer-events-none" />
<h2 className="text-xl font-medium mb-2 relative z-10">Initialize System</h2>
<p className="text-[#86868B] text-sm mb-8 relative z-10 leading-relaxed">
Create the primary System Architect account. Once initialized, you can create additional accounts inside the dashboard.
</p>
<form onSubmit={handleSetup} className="space-y-5 relative z-10">
<div>
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-2 font-semibold">Master Username</label>
<div className="relative">
<User size={16} className="absolute left-4 top-1/2 -translate-y-1/2 text-[#86868B]" />
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="e.g., david_architect"
className="w-full bg-black/60 border border-white/10 rounded-xl pl-11 pr-4 py-3.5 text-white text-sm focus:border-[#00F0FF] focus:bg-black transition-all outline-none"
autoFocus
/>
</div>
</div>
<div>
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-2 font-semibold">Master Password</label>
<div className="relative">
<Lock size={16} className="absolute left-4 top-1/2 -translate-y-1/2 text-[#86868B]" />
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••••••"
className="w-full bg-black/60 border border-white/10 rounded-xl pl-11 pr-4 py-3.5 text-white text-sm focus:border-[#00F0FF] focus:bg-black transition-all outline-none"
/>
</div>
</div>
{error && <p className="text-red-400 text-xs text-center bg-red-400/10 py-2 rounded-lg">{error}</p>}
<button
type="submit"
disabled={loading || password.length < 8 || username.length < 4}
className="w-full flex items-center justify-center gap-2 bg-[#00F0FF] text-black py-3.5 mt-4 rounded-xl text-sm font-semibold hover:bg-white transition-all disabled:opacity-30 disabled:cursor-not-allowed group"
>
{loading ? (
<Loader2 size={18} className="animate-spin" />
) : (
<>
<QrCode size={18} className="group-hover:scale-110 transition-transform" />
Generate Master Key
</>
)}
</button>
</form>
</div>
</div>
</div>
);
}
-34
View File
@@ -1,34 +0,0 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
</body>
</html>
);
}
-65
View File
@@ -1,65 +0,0 @@
import Image from "next/image";
export default function Home() {
return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
);
}
+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>
);
}
+17
View File
@@ -0,0 +1,17 @@
import {getRequestConfig} from 'next-intl/server';
import {routing} from './routing';
export default getRequestConfig(async ({requestLocale}) => {
let locale = await requestLocale;
// Validamos que el idioma pedido sea válido
if (!locale || !routing.locales.includes(locale as any)) {
locale = routing.defaultLocale;
}
return {
locale,
// Cargamos el JSON correspondiente desde la carpeta raíz messages/
messages: (await import(`../../messages/${locale}.json`)).default
};
});
+13
View File
@@ -0,0 +1,13 @@
import {defineRouting} from 'next-intl/routing';
import {createNavigation} from 'next-intl/navigation';
export const routing = defineRouting({
// Nuestros 5 idiomas estrella (en: Inglés, it: Italiano, vec: Véneto, es: Español, de: Alemán)
locales: ['en', 'it', 'vec', 'es', 'de'],
// Si alguien entra a flux.com, lo mandamos a /en por defecto
defaultLocale: 'en'
});
// Exportamos nuestras propias versiones de Link y useRouter que entienden de idiomas
export const {Link, redirect, usePathname, useRouter, getPathname} = createNavigation(routing);
+53
View File
@@ -0,0 +1,53 @@
import { generateText } from 'ai';
import { openai } from '@ai-sdk/openai';
/**
* Motor de traducción impulsado por Vercel AI SDK y OpenAI.
* Usa generateText para evitar bugs de compatibilidad con Zod.
* @param content Objeto con los textos a traducir. Ej: { title: "...", content: "..." }
* @returns Objeto con los idiomas y sus traducciones
*/
export async function translateContentForCMS(content: Record<string, string>) {
try {
const { text } = await generateText({
model: openai('gpt-4o'),
system: `You are an elite technical translator for FLUX, a premium brand of Radio Frequency (RF) industrial machinery.
Your task is to translate the user's JSON content into 4 specific locales:
1. 'it': Standard Professional Italian.
2. 'vec': Venetian dialect (from Bassano del Grappa). Maintain a proud, industrial, and authentic tone.
3. 'es': Professional Spanish (Neutral/Global).
4. 'de': Professional Industrial German.
CRITICAL RULES:
- NEVER translate Markdown syntax (#, **, *, >, |---|).
- NEVER translate URLs, file paths (like /cases/img.jpg), or code blocks.
- NEVER translate technical acronyms like "RF", "kW", "MHz", "FLUX".
- Keep the exact same JSON key names as the input.
OUTPUT FORMAT:
You MUST return ONLY a raw, valid JSON object. Do not wrap it in \`\`\`json blocks. No pleasantries.
The output must strictly follow this structure:
{
"it": { "key1": "translated text..." },
"vec": { "key1": "translated text..." },
"es": { "key1": "translated text..." },
"de": { "key1": "translated text..." }
}`,
prompt: JSON.stringify(content),
});
// Limpiamos el texto por si GPT-4o decide ponerle "```json" alrededor
const cleanedText = text.replace(/```json/g, '').replace(/```/g, '').trim();
// Convertimos la respuesta de la IA en un objeto real de Javascript
const parsedObject = JSON.parse(cleanedText);
return parsedObject;
} catch (error) {
console.error("Error in AI Translation:", error);
return null;
}
}
+32
View File
@@ -0,0 +1,32 @@
/**
* Traduce dinámicamente cualquier objeto de la base de datos (Prisma)
* leyendo su campo `translationsJson`.
* * @param item El objeto original de la base de datos (ej: una Noticia o Aplicación)
* @param locale El idioma actual de la URL (ej: 'it', 'es', 'vec')
* @returns El mismo objeto, pero con sus campos sobreescritos en el idioma correcto.
*/
export function getLocalizedData<T extends { translationsJson?: string | null }>(item: T, locale: string): T {
// 1. Si el idioma es el maestro (inglés) o no hay traducciones, devolvemos el original
if (locale === 'en' || !item.translationsJson) {
return item;
}
try {
// 2. Desempaquetamos el JSON de traducciones
const translations = JSON.parse(item.translationsJson);
// 3. Buscamos si existe el idioma que pide el usuario (ej: translations['it'])
const localeData = translations[locale];
if (localeData) {
// 4. Fusionamos el objeto original con las traducciones.
// Lo que esté traducido sobrescribe al original. Lo que no, queda en inglés.
return { ...item, ...localeData };
}
} catch (error) {
console.error("Error parsing translations JSON for item:", item, error);
}
// Fallback de seguridad: Si algo falla, siempre mostramos inglés
return item;
}
+119
View File
@@ -0,0 +1,119 @@
// /src/lib/mailer.ts — SMTP Email Service (No external dependencies like Resend)
// Uses nodemailer with standard SMTP protocol
// Works with: Gmail, Outlook/Office365, custom SMTP servers, etc.
//
// Required env vars:
// SMTP_HOST=smtp.gmail.com (or smtp.office365.com, mail.fluxsrl.com, etc.)
// SMTP_PORT=587 (587 for TLS, 465 for SSL)
// SMTP_USER=davidherran@dreamhousestudios.co
// SMTP_PASS=your-app-password (Gmail: use App Password, not regular password)
// SMTP_FROM=FLUX Operations <operations@fluxsrl.com>
//
// Optional:
// SMTP_SECURE=false (true for port 465, false for 587 with STARTTLS)
//
// For Gmail specifically:
// 1. Enable 2FA on the Google account
// 2. Go to myaccount.google.com > Security > App Passwords
// 3. Generate an "App Password" for "Mail"
// 4. Use that 16-char password as SMTP_PASS
import nodemailer from "nodemailer";
interface EmailPayload {
to: string[];
subject: string;
html: string;
replyTo?: string;
}
interface EmailResult {
success: boolean;
sentTo: string[];
sentAt: Date | null;
error: string | null;
messageId: string | null;
}
// Create transporter (reusable across requests)
function getTransporter() {
const host = process.env.SMTP_HOST;
const port = parseInt(process.env.SMTP_PORT || "587");
const user = process.env.SMTP_USER;
const pass = process.env.SMTP_PASS;
if (!host || !user || !pass) {
return null; // SMTP not configured
}
return nodemailer.createTransport({
host,
port,
secure: process.env.SMTP_SECURE === "true" || port === 465,
auth: { user, pass },
tls: {
// Allow self-signed certificates in development
rejectUnauthorized: process.env.NODE_ENV === "production",
},
});
}
export async function sendEmail(payload: EmailPayload): Promise<EmailResult> {
const transporter = getTransporter();
if (!transporter) {
console.warn("⚠️ SMTP not configured. Email not sent. Set SMTP_HOST, SMTP_USER, SMTP_PASS in .env");
return {
success: false,
sentTo: payload.to,
sentAt: null,
error: "SMTP not configured (missing env vars)",
messageId: null,
};
}
try {
const from = process.env.SMTP_FROM || process.env.SMTP_USER || "noreply@flux.com";
const info = await transporter.sendMail({
from,
to: payload.to.join(", "),
subject: payload.subject,
html: payload.html,
replyTo: payload.replyTo || undefined,
});
console.log(`✅ Email sent: ${info.messageId}${payload.to.join(", ")}`);
return {
success: true,
sentTo: payload.to,
sentAt: new Date(),
error: null,
messageId: info.messageId,
};
} catch (error: any) {
console.error("❌ Email send failed:", error.message);
return {
success: false,
sentTo: payload.to,
sentAt: null,
error: error.message || "Unknown SMTP error",
messageId: null,
};
}
}
// Utility: Verify SMTP connection (call from a health check endpoint)
export async function verifySMTP(): Promise<{ ok: boolean; error?: string }> {
const transporter = getTransporter();
if (!transporter) return { ok: false, error: "SMTP not configured" };
try {
await transporter.verify();
return { ok: true };
} catch (error: any) {
return { ok: false, error: error.message };
}
}
+168
View File
@@ -0,0 +1,168 @@
import React from "react";
import { Play, Maximize2 } from "lucide-react";
// Nota: No incluí el componente 3D directamente aquí para no complicar dependencias,
// pero soporta tablas, listas, citas, videos e imágenes con lightbox.
export const renderMarkdown = (text: string, onImageClick?: (url: string) => void) => {
if (!text) return null;
const lines = text.split('\n');
// 🔥 FIX TYPESCRIPT: JSX.Element -> React.ReactNode
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-6 w-full overflow-x-auto pb-2 [scrollbar-width:none]">
<table className="w-full text-left border-collapse min-w-[500px] shadow-sm rounded-xl overflow-hidden border border-black/5 dark:border-white/5">
<thead>
<tr className="bg-black/5 dark:bg-white/5">
{tableHeaders.map((th, i) => (
<th key={i} className={`p-3 border-b border-black/10 dark:border-white/10 text-[10px] md:text-xs uppercase tracking-widest font-semibold text-[#1D1D1F] dark:text-white`}>
{parseInline(th)}
</th>
))}
</tr>
</thead>
<tbody className="bg-white/50 dark:bg-black/20">
{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-3 border-b border-black/5 dark:border-white/5 text-xs md:text-sm text-[#1D1D1F]/80 dark:text-white/80`}>
{parseInline(cell)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
inTable = false;
tableHeaders = [];
tableRows = [];
}
};
const pushList = () => {
if (listItems.length > 0) {
elements.push(
isOrderedList ? (
<ol key={`ol-${elements.length}`} className="list-decimal ml-5 mb-5 text-[#86868B] dark:text-[#A1A1A6] space-y-1.5 text-sm font-light marker:text-[#0066CC] dark:marker:text-amber-500">
{listItems}
</ol>
) : (
<ul key={`ul-${elements.length}`} className="list-disc ml-5 mb-5 text-[#86868B] dark:text-[#A1A1A6] space-y-1.5 text-sm font-light marker:text-[#0066CC] dark:marker:text-amber-500">
{listItems}
</ul>
)
);
listItems = [];
}
};
const parseInline = (str: string) => {
const boldRegex = /\*\*(.*?)\*\*/g;
const italicRegex = /\*(.*?)\*/g;
let parts = str.split(boldRegex);
return parts.map((part, i) => {
if (i % 2 === 1) return <strong key={i} className="font-semibold text-[#1D1D1F] dark:text-white">{part}</strong>;
let subParts = part.split(italicRegex);
return subParts.map((subPart, j) => {
if (j % 2 === 1) return <em key={`${i}-${j}`} className="italic text-[#1D1D1F]/90 dark:text-white/90">{subPart}</em>;
return subPart;
});
});
};
lines.forEach((line, idx) => {
const trimmed = line.trim();
if (!trimmed) { pushList(); pushTable(); return; }
// ── TABLE ──
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(/^[-:]+$/))) { tableRows.push(cells); }
return;
} else { pushTable(); }
// ── IMAGE ──
const imgMatch = trimmed.match(/^!\[(.*?)\]\((.*?)\)$/);
if (imgMatch) {
pushList(); pushTable();
const imageUrl = imgMatch[2];
elements.push(
<div key={`img-${idx}`} className="relative w-full my-6 rounded-xl overflow-hidden border border-black/10 dark:border-white/10 shadow-sm bg-black/5 dark:bg-white/5 cursor-pointer group" onClick={() => onImageClick?.(imageUrl)}>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-all z-10 flex items-center justify-center opacity-0 group-hover:opacity-100 backdrop-blur-sm">
<Maximize2 size={24} className="text-white drop-shadow-md scale-90 group-hover:scale-100 transition-transform" />
</div>
<img src={imageUrl} alt={imgMatch[1]} className="w-full h-auto object-cover group-hover:scale-105 transition-transform duration-700" loading="lazy" />
</div>
);
return;
}
// ── VIDEO ──
const videoMatch = trimmed.match(/^\[VIDEO:(.*?)\]$/);
if (videoMatch) {
pushList(); pushTable();
const videoSrc = videoMatch[1].trim();
elements.push(
<div key={`vid-${idx}`} className="relative w-full my-6 rounded-xl overflow-hidden border border-black/10 dark:border-white/10 bg-black aspect-video">
<div className="absolute top-2 left-2 z-10 pointer-events-none bg-black/70 backdrop-blur-sm rounded-full px-2 py-0.5 flex items-center gap-1">
<Play size={10} className="text-white" /><span className="text-[9px] font-bold text-white uppercase tracking-widest">Video</span>
</div>
<video src={videoSrc} controls playsInline className="w-full h-full object-contain" />
</div>
);
return;
}
// ── HEADINGS ──
const h3Match = trimmed.match(/^###\s*(.*)/);
if (h3Match) { pushList(); elements.push(<h3 key={idx} className="text-sm font-semibold uppercase tracking-widest text-[#0066CC] dark:text-amber-500 mt-6 mb-2">{parseInline(h3Match[1])}</h3>); return; }
const h2Match = trimmed.match(/^##\s*(.*)/);
if (h2Match) { pushList(); elements.push(<h2 key={idx} className="text-xl font-medium text-[#1D1D1F] dark:text-white mt-6 mb-3">{parseInline(h2Match[1])}</h2>); return; }
const h1Match = trimmed.match(/^#\s*(.*)/);
if (h1Match) { pushList(); elements.push(<h1 key={idx} className="text-2xl font-light text-[#1D1D1F] dark:text-white mt-8 mb-4">{parseInline(h1Match[1])}</h1>); return; }
// ── BLOCKQUOTE ──
const quoteMatch = trimmed.match(/^>\s*(.*)/);
if (quoteMatch) {
pushList();
elements.push(
<blockquote key={idx} className="border-l-2 border-[#0066CC] dark:border-amber-500 pl-3 py-1 my-4 text-sm font-light italic text-[#86868B] bg-black/5 dark:bg-white/5 rounded-r-lg">
{parseInline(quoteMatch[1])}
</blockquote>
);
return;
}
// ── LISTS ──
const ulMatch = trimmed.match(/^[-*]\s*(.*)/);
if (ulMatch) { isOrderedList = false; listItems.push(<li key={idx}>{parseInline(ulMatch[1])}</li>); return; }
const olMatch = trimmed.match(/^\d+\.\s*(.*)/);
if (olMatch) { isOrderedList = true; listItems.push(<li key={idx}>{parseInline(olMatch[1])}</li>); return; }
// ── PARAGRAPH ──
pushList();
elements.push(<p key={idx} className="text-[#86868B] dark:text-[#A1A1A6] font-light leading-relaxed mb-3 text-sm">{parseInline(trimmed)}</p>);
});
pushList(); pushTable();
return <>{elements}</>;
};
+31
View File
@@ -0,0 +1,31 @@
// src/lib/prisma.ts
// ✅ CORRECCIÓN: Validación de DATABASE_URL + configuración del pool de conexiones
import { PrismaClient } from '@prisma/client';
import { Pool } from 'pg';
import { PrismaPg } from '@prisma/adapter-pg';
const globalForPrisma = global as unknown as { prisma: PrismaClient };
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
throw new Error(
"❌ DATABASE_URL is not defined. " +
"Make sure it's set in your .env file or Docker environment variables. " +
"Example: DATABASE_URL=postgresql://user:password@db:5432/flux"
);
}
const pool = new Pool({
connectionString,
max: 10, // Máximo de conexiones simultáneas
idleTimeoutMillis: 30000, // Cerrar conexiones inactivas después de 30s
connectionTimeoutMillis: 5000, // Timeout al intentar conectar
});
const adapter = new PrismaPg(pool as any);
export const prisma = globalForPrisma.prisma || new PrismaClient({ adapter });
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
+32
View File
@@ -0,0 +1,32 @@
import { SignJWT, jwtVerify } from "jose";
import { cookies } from "next/headers";
// Usamos una variable de entorno secreta, o un fallback súper seguro para desarrollo
const secretKey = process.env.SESSION_SECRET || "FLUX_SUPER_SECRET_KEY_2026_ARCHITECTURE";
const encodedKey = new TextEncoder().encode(secretKey);
export async function createSession(userId: string, username: string) {
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 días de duración
// Creamos el token (El "pase de acceso" encriptado)
const sessionToken = await new SignJWT({ userId, username })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("7d")
.sign(encodedKey);
// En Next.js 15+, cookies() es una Promesa
const cookieStore = await cookies();
cookieStore.set("flux_session", sessionToken, {
httpOnly: true, // Evita ataques XSS (nadie puede robar la cookie con JavaScript)
secure: process.env.NODE_ENV === "production", // Solo HTTPS en producción
expires: expiresAt,
sameSite: "lax",
path: "/",
});
}
export async function deleteSession() {
const cookieStore = await cookies();
cookieStore.delete("flux_session");
}
+130
View File
@@ -0,0 +1,130 @@
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware'; // 🔥 Añadimos persistencia
export type GlobalFilterType = 'all' | 'installations' | 'events' | 'legacy';
// Definición de un artículo en el carrito
export interface CartItem {
id: string; // ID de la base de datos
sku: string;
title: string;
mediaUrl?: string; // La imagen miniatura
price?: number | null; // El precio (si showPrice es true)
showPrice: boolean;
quantity: number;
}
interface UIState {
// ── Layout ──
isNavBarVisible: boolean;
isAiExpanded: boolean;
isCartOpen: boolean; // 🔥 Nuevo: Para abrir/cerrar el drawer del carrito
// ── Globe/Map ──
activeGlobalFilter: GlobalFilterType;
activeSubFilter: string | null;
selectedMarkerId: string | null;
highlightedMapNode: string | null;
// ── AI Context ──
currentSection: string;
activeApplicationTab: string;
// ── Carrito de Repuestos (Spare Parts) ──
cartItems: CartItem[];
// ── Actions Layout & Map ──
setNavBarVisible: (isVisible: boolean) => void;
toggleAi: () => void;
setAiExpanded: (expanded: boolean) => void;
toggleCart: () => void; // 🔥 Nuevo
setActiveGlobalFilter: (filter: GlobalFilterType) => void;
setActiveSubFilter: (subFilter: string | null) => void;
setSelectedMarkerId: (id: string | null) => void;
setHighlightedMapNode: (id: string | null) => void;
setCurrentSection: (section: string) => void;
setActiveApplicationTab: (tab: string) => void;
// ── Actions Carrito ──
addToCart: (part: Omit<CartItem, 'quantity'>) => void;
removeFromCart: (sku: string) => void;
updateQuantity: (sku: string, quantity: number) => void;
clearCart: () => void;
}
export const useUIStore = create<UIState>()(
devtools(
// 🔥 Envolvemos el store en `persist` para que se guarde en localStorage
persist(
(set) => ({
// Initial values
isNavBarVisible: true,
isAiExpanded: false,
isCartOpen: false,
activeGlobalFilter: 'all',
activeSubFilter: null,
selectedMarkerId: null,
highlightedMapNode: null,
currentSection: 'home',
activeApplicationTab: 'defrosting',
// Initial Cart
cartItems: [],
// Actions
setNavBarVisible: (isVisible) => set({ isNavBarVisible: isVisible }),
toggleAi: () => set((state) => ({ isAiExpanded: !state.isAiExpanded })),
setAiExpanded: (expanded) => set({ isAiExpanded: expanded }),
toggleCart: () => set((state) => ({ isCartOpen: !state.isCartOpen })),
setActiveGlobalFilter: (filter) => set({
activeGlobalFilter: filter,
activeSubFilter: null,
selectedMarkerId: null,
}),
setActiveSubFilter: (subFilter) => set({
activeSubFilter: subFilter,
selectedMarkerId: null,
}),
setSelectedMarkerId: (id) => set({ selectedMarkerId: id }),
setHighlightedMapNode: (id) => set({ highlightedMapNode: id }),
setCurrentSection: (section) => set({ currentSection: section }),
setActiveApplicationTab: (tab) => set({ activeApplicationTab: tab }),
// 🛒 Lógica del Carrito
addToCart: (part) => set((state) => {
const existingItem = state.cartItems.find(item => item.sku === part.sku);
if (existingItem) {
// Si ya existe, sumamos 1 a la cantidad
return {
cartItems: state.cartItems.map(item =>
item.sku === part.sku ? { ...item, quantity: item.quantity + 1 } : item
),
isCartOpen: true // Abrimos el carrito visualmente para dar feedback
};
}
// Si no existe, lo agregamos con cantidad 1
return {
cartItems: [...state.cartItems, { ...part, quantity: 1 }],
isCartOpen: true
};
}),
removeFromCart: (sku) => set((state) => ({
cartItems: state.cartItems.filter(item => item.sku !== sku)
})),
updateQuantity: (sku, quantity) => set((state) => ({
cartItems: state.cartItems.map(item =>
item.sku === sku ? { ...item, quantity: Math.max(1, quantity) } : item
)
})),
clearCart: () => set({ cartItems: [], isCartOpen: false }),
}),
{
name: 'flux-ui-store',
// 🔥 IMPORTANTE: Solo queremos persistir el carrito, no el estado de los modales o filtros del mapa
partialize: (state) => ({ cartItems: state.cartItems })
}
),
{ name: 'UI Store' }
)
);
+69
View File
@@ -0,0 +1,69 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { jwtVerify } from 'jose';
// 🌍 1. Importamos el motor de idiomas
import createIntlMiddleware from 'next-intl/middleware';
import { routing } from './i18n/routing';
// Configuramos el proxy de next-intl
const handleI18nRouting = createIntlMiddleware(routing);
// 🔒 2. Llave de seguridad del CMS
const secretKey = process.env.SESSION_SECRET || "FLUX_SUPER_SECRET_KEY_2026_ARCHITECTURE";
const encodedKey = new TextEncoder().encode(secretKey);
// 🔥 AHORA SE LLAMA "proxy" EN LUGAR DE "middleware" 🔥
export async function proxy(request: NextRequest) {
const path = request.nextUrl.pathname;
// --------------------------------------------------------
// ZONA ROJA: CENTRO DE MANDO (Seguridad estricta, sin idiomas)
// --------------------------------------------------------
if (path.startsWith('/hq-command')) {
const isPublicHQRoute = path === '/hq-command/login' || path === '/hq-command/setup';
const cookie = request.cookies.get('flux_session')?.value;
// Si intenta entrar al dashboard sin pase
if (!isPublicHQRoute && !cookie) {
return NextResponse.redirect(new URL('/hq-command/login', request.url));
}
// Verificamos el pase
if (cookie) {
try {
await jwtVerify(cookie, encodedKey);
// Si tiene pase y está en el login, lo mandamos al dashboard
if (isPublicHQRoute) {
return NextResponse.redirect(new URL('/hq-command/dashboard', request.url));
}
return NextResponse.next(); // Pasa al CMS tranquilamente
} catch (error) {
// Pase falso o expirado
if (!isPublicHQRoute) {
return NextResponse.redirect(new URL('/hq-command/login', request.url));
}
}
}
return NextResponse.next(); // Pasa a login/setup si no hay cookie
}
// --------------------------------------------------------
// ZONA VERDE: WEB PÚBLICA (Motor de Traducciones i18n)
// --------------------------------------------------------
// Si el usuario no va al CMS, le pasamos el control a next-intl
// para que gestione los prefijos /en/, /it/, /vec/, etc.
return handleI18nRouting(request);
}
// --------------------------------------------------------
// OPTIMIZACIÓN EXTREMA DEL MATCHER
// --------------------------------------------------------
export const config = {
// Coincide con TODAS las rutas de la aplicación EXCEPTO:
// - /api (nuestras rutas backend)
// - /_next/static y /_next/image (archivos internos de React/Next)
// - Archivos estáticos como .jpg, .png, .mp4, .glb, .svg (.*\\..*)
// IMPORTANTE: Al ignorar los estáticos, las fotos y videos del CMS cargarán rapidísimo.
matcher: ['/((?!api|_next/static|_next/image|.*\\..*).*)'],
};
+30
View File
@@ -0,0 +1,30 @@
declare global {
namespace JSX {
interface IntrinsicElements {
'model-viewer': React.DetailedHTMLProps<
React.HTMLAttributes<HTMLElement> & {
src?: string;
'ios-src'?: string;
alt?: string;
ar?: boolean | string;
'ar-modes'?: string;
'camera-controls'?: boolean | string;
'touch-action'?: string;
'auto-rotate'?: boolean | string;
'shadow-intensity'?: string;
'environment-image'?: string;
exposure?: string;
poster?: string;
loading?: string;
reveal?: string;
style?: React.CSSProperties;
id?: string;
className?: string;
},
HTMLElement
>;
}
}
}
export {};